diff --git a/src/main/java/com/bioraft/rundeck/rancher/RancherAddService.java b/src/main/java/com/bioraft/rundeck/rancher/RancherAddService.java index a29b5f1..d0aab28 100644 --- a/src/main/java/com/bioraft/rundeck/rancher/RancherAddService.java +++ b/src/main/java/com/bioraft/rundeck/rancher/RancherAddService.java @@ -252,4 +252,5 @@ private void addJsonData(String name, String data, ImmutableMap.Builder + * @since 2019-12-20 + */ +public class RancherLaunchConfig { + + private String environment = ""; + + private String dataVolumes = ""; + + private String labels = ""; + + private String removeEnvironment = ""; + + private String removeLabels = ""; + + private String secrets = ""; + + private String nodeName; + + private PluginLogger logger; + + ObjectNode launchConfigObject; + + public RancherLaunchConfig(String nodeName, ObjectNode launchConfigObject, PluginLogger logger) { + this.nodeName = nodeName; + this.launchConfigObject = launchConfigObject; + this.logger = logger; + } + + public ObjectNode update() throws NodeStepException { + setField("environment", environment); + removeField("environment", removeEnvironment); + + setField("labels", labels); + removeField("labels", removeLabels); + + addSecrets(launchConfigObject); + + setMountArray(dataVolumes); + + return launchConfigObject; + } + + public void setDockerImage(String dockerImage) { + logger.log(Constants.INFO_LEVEL, "Setting image to " + dockerImage); + launchConfigObject.put("imageUuid","docker:" + dockerImage); + } + + public void setEnvironment(String environment) { + this.environment = environment; + } + + public void setDataVolumes(String dataVolumes) { + this.dataVolumes = dataVolumes; + } + + public void setLabels(String labels) { + this.labels = labels; + } + + public void removeEnvironment(String removeEnvironment) { + this.removeEnvironment = removeEnvironment; + } + + public void removeLabels(String removeLabels) { + this.removeLabels = removeLabels; + } + + public void setSecrets(String secrets) { + this.secrets = secrets; + } + + /** + * Adds/modifies values in a launchConfig field. + * + * @param field The field to update. + * @param newData JSON Object representing the new name-value pairs. + */ + private void setField(String field, String newData) throws NodeStepException { + if (newData == null || newData.length() == 0) { + return; + } + + ObjectNode objectNode = (ObjectNode) launchConfigObject.get(field); + ObjectMapper objectMapper = new ObjectMapper(); + try { + JsonNode map = objectMapper.readTree(ensureStringIsJsonObject(newData)); + Iterator> iterator = map.fields(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + String key = entry.getKey(); + String value = entry.getValue().asText(); + objectNode.put(key, value); + logger.log(Constants.INFO_LEVEL, "Setting " + field + ":" + key + " to " + value); + } + } catch (JsonProcessingException e) { + throw new NodeStepException("Invalid " + field + " JSON data", ErrorCause.InvalidJson, this.nodeName); + } + } + + /** + * Adds/modifies environment variables. + * + * @param field Name of the object to remove from. + * @param remove String representation of fields to be removed (JSON array). + */ + private void removeField(String field, String remove) throws NodeStepException { + if (remove == null || remove.length() == 0) { + return; + } + if (!launchConfigObject.has(field) || launchConfigObject.get(field).isNull()) { + return; + } + + ObjectNode objectNode = (ObjectNode) launchConfigObject.get(field); + ObjectMapper objectMapper = new ObjectMapper(); + try { + JsonNode map = objectMapper.readTree(ensureStringIsJsonArray(remove)); + Iterator iterator = map.elements(); + while (iterator.hasNext()) { + String entry = iterator.next().asText(); + logger.log(Constants.INFO_LEVEL, "Removing " + entry + " from " + field); + objectNode.remove(entry); + } + } catch (JsonProcessingException e) { + throw new NodeStepException("Invalid " + field + " array", ErrorCause.InvalidJson, this.nodeName); + } + } + + /** + * Add or replace secrets. + * + * @param launchConfig JsonNode representing the target upgraded configuration. + * @throws NodeStepException when secret JSON is malformed (passed up from {@see this.buildSecret()}. + */ + private void addSecrets(ObjectNode launchConfig) throws NodeStepException { + if (secrets != null && secrets.length() > 0) { + // Copy existing secrets, skipping any that we want to add or overwrite. + Iterator elements = null; + boolean hasOldSecrets = false; + if (launchConfig.has("secrets") && !launchConfig.get("secrets").isNull()) { + hasOldSecrets = true; + elements = launchConfig.get("secrets").elements(); + } + + ArrayNode secretsArray = launchConfig.putArray("secrets"); + + // Copy existing secrets, skipping any that we want to add or overwrite. + if (hasOldSecrets && elements != null) { + while (elements.hasNext()) { + JsonNode secretObject = elements.next(); + // @todo this only works for a single secret added. + if (!secretObject.path("secretId").asText().equals(secrets)) { + secretsArray.add(secretObject); + } + } + } + + // Add in the new or replacement secrets specified in the step. + for (String secretId : secrets.split("/[,; ]+/")) { + secretsArray.add(buildSecret(secretId, this.nodeName)); + logger.log(Constants.INFO_LEVEL, "Adding secret map to " + secretId); + } + } + } + + /** + * Add or replace secrets. + * + * @throws NodeStepException when secret JSON is malformed (passed up from {@see this.buildSecret()}. + */ + private void setMountArray(String newData) throws NodeStepException { + if (newData != null && newData.length() > 0) { + HashMap hashMap = new HashMap<>(); + + // Copy existing mounts into hash keyed by mount point. + if (launchConfigObject.has("dataVolumes") && !launchConfigObject.get("dataVolumes").isNull()) { + Iterator elements = launchConfigObject.get("dataVolumes").elements(); + while (elements.hasNext()) { + String element = elements.next().asText(); + hashMap.put(mountPoint(element), element); + } + } + + // Copy new mounts into hash, possibly overwriting some vales. + ObjectMapper objectMapper = new ObjectMapper(); + try { + JsonNode map = objectMapper.readTree(ensureStringIsJsonArray(newData)); + Iterator mounts = map.elements(); + mounts.forEachRemaining(spec -> hashMap.put(mountPoint(spec.asText()), spec.asText())); + + } catch (JsonProcessingException e) { + throw new NodeStepException("Could not parse JSON for " + "dataVolumes" + "\n" + newData, e, ErrorCause.InvalidConfiguration, nodeName); + } + + ArrayNode updatedArray = launchConfigObject.putArray("dataVolumes"); + + // Copy the merged array. + hashMap.forEach((k, v) -> updatedArray.add(v)); + } + } +} diff --git a/src/main/java/com/bioraft/rundeck/rancher/RancherShared.java b/src/main/java/com/bioraft/rundeck/rancher/RancherShared.java index f2d8267..53d5519 100644 --- a/src/main/java/com/bioraft/rundeck/rancher/RancherShared.java +++ b/src/main/java/com/bioraft/rundeck/rancher/RancherShared.java @@ -133,6 +133,10 @@ public static String secretJson(String secretId) { + secretId + "\", \"uid\": \"0\"}"; } + public static String mountPoint(String mountSpec) { + return mountSpec.replaceFirst("[^:]+:", "").replaceFirst(":.*", ""); + } + public enum ErrorCause implements FailureReason { InvalidConfiguration, InvalidJson, diff --git a/src/main/java/com/bioraft/rundeck/rancher/RancherUpgradeService.java b/src/main/java/com/bioraft/rundeck/rancher/RancherUpgradeService.java index 2d48050..c6dff7d 100644 --- a/src/main/java/com/bioraft/rundeck/rancher/RancherUpgradeService.java +++ b/src/main/java/com/bioraft/rundeck/rancher/RancherUpgradeService.java @@ -15,10 +15,6 @@ */ package com.bioraft.rundeck.rancher; -import java.io.IOException; -import java.util.Iterator; -import java.util.Map; - import com.dtolabs.rundeck.core.Constants; import com.dtolabs.rundeck.core.common.INodeEntry; import com.dtolabs.rundeck.core.execution.ExecutionContext; @@ -32,22 +28,18 @@ import com.dtolabs.rundeck.plugins.descriptions.RenderingOptions; import com.dtolabs.rundeck.plugins.step.NodeStepPlugin; import com.dtolabs.rundeck.plugins.step.PluginStepContext; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; - import com.google.common.collect.ImmutableMap; -import okhttp3.Credentials; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; +import okhttp3.*; import okhttp3.Request.Builder; -import okhttp3.RequestBody; -import okhttp3.Response; -import static com.bioraft.rundeck.rancher.RancherShared.*; +import java.io.IOException; +import java.util.Map; + +import static com.bioraft.rundeck.rancher.RancherShared.ErrorCause; +import static com.bioraft.rundeck.rancher.RancherShared.loadStoragePathData; import static com.dtolabs.rundeck.core.Constants.DEBUG_LEVEL; import static com.dtolabs.rundeck.core.plugins.configuration.StringRenderingConstants.CODE_SYNTAX_MODE; import static com.dtolabs.rundeck.core.plugins.configuration.StringRenderingConstants.DISPLAY_TYPE_KEY; @@ -65,6 +57,20 @@ public class RancherUpgradeService implements NodeStepPlugin { @PluginProperty(title = "Docker Image", description = "The fully specified Docker image to upgrade to.") private String dockerImage; + @PluginProperty(title = "Container OS Environment", description = "JSON object of \"variable\": \"value\"") + @RenderingOptions({ + @RenderingOption(key = DISPLAY_TYPE_KEY, value = "CODE"), + @RenderingOption(key = CODE_SYNTAX_MODE, value = "json"), + }) + private String environment; + + @PluginProperty(title = "Data Volumes", description = "JSON array Lines of \"source:mountPoint\"") + @RenderingOptions({ + @RenderingOption(key = DISPLAY_TYPE_KEY, value = "CODE"), + @RenderingOption(key = CODE_SYNTAX_MODE, value = "json"), + }) + private String dataVolumes; + @PluginProperty(title = "Service Labels", description = "JSON object of \"variable\": \"value\"") @RenderingOptions({ @RenderingOption(key = DISPLAY_TYPE_KEY, value = "CODE"), @@ -72,12 +78,19 @@ public class RancherUpgradeService implements NodeStepPlugin { }) private String labels; - @PluginProperty(title = "Container OS Environment", description = "JSON object of \"variable\": \"value\"") + @PluginProperty(title = "Remove OS Environment", description = "JSON array of variables (quoted)") @RenderingOptions({ @RenderingOption(key = DISPLAY_TYPE_KEY, value = "CODE"), @RenderingOption(key = CODE_SYNTAX_MODE, value = "json"), }) - private String environment; + private String removeEnvironment; + + @PluginProperty(title = "Remove Service Labels", description = "JSON array of labels (quoted)") + @RenderingOptions({ + @RenderingOption(key = DISPLAY_TYPE_KEY, value = "CODE"), + @RenderingOption(key = CODE_SYNTAX_MODE, value = "json"), + }) + private String removeLabels; @PluginProperty(title = "Secrets", description = "Keys for secrets separated by commas or spaces") private String secrets; @@ -91,6 +104,10 @@ public class RancherUpgradeService implements NodeStepPlugin { OkHttpClient client; + JsonNode launchConfig; + + ObjectNode launchConfigObject; + private final static int intervalMillis = 2000; public RancherUpgradeService() { @@ -131,27 +148,33 @@ public void executeNodeStep(PluginStepContext ctx, Map cfg, INod if (upgradeUrl.length() == 0) { throw new NodeStepException("No upgrade URL found", ErrorCause.MissingUpgradeURL, node.getNodename()); } - JsonNode launchConfig = service.path("upgrade").path("inServiceStrategy").path("launchConfig"); + + launchConfig = service.path("upgrade").path("inServiceStrategy").path("launchConfig"); if (launchConfig.isMissingNode() || launchConfig.isNull()) { launchConfig = service.path("launchConfig"); } if (launchConfig.isMissingNode() || launchConfig.isNull()) { throw new NodeStepException("No upgrade data found", ErrorCause.NoUpgradeData, node.getNodename()); } - ObjectNode launchConfigObject = (ObjectNode) launchConfig; + launchConfigObject = (ObjectNode) launchConfig; + + RancherLaunchConfig rancherLaunchConfig = new RancherLaunchConfig(nodeName, launchConfigObject, logger); if ((dockerImage == null || dockerImage.length() == 0) && cfg.containsKey("dockerImage")) { dockerImage = (String) cfg.get("dockerImage"); } if (dockerImage != null && dockerImage.length() > 0) { - logger.log(Constants.INFO_LEVEL, "Setting image to " + dockerImage); - launchConfigObject.put("imageUuid","docker:" + dockerImage); + rancherLaunchConfig.setDockerImage(dockerImage); } if ((environment == null || environment.isEmpty()) && cfg.containsKey("environment")) { environment = (String) cfg.get("environment"); } + if ((dataVolumes == null || dataVolumes.isEmpty()) && cfg.containsKey("dataVolumes")) { + dataVolumes = (String) cfg.get("dataVolumes"); + } + if ((labels == null || labels.isEmpty()) && cfg.containsKey("labels")) { labels = (String) cfg.get("labels"); } @@ -160,6 +183,21 @@ public void executeNodeStep(PluginStepContext ctx, Map cfg, INod secrets = (String) cfg.get("secrets"); } + if (cfg.containsKey("removeEnvironment")) { + removeEnvironment = (String) cfg.get("removeEnvironment"); + } + + if (cfg.containsKey(removeLabels)) { + removeLabels = (String) cfg.get("removeLabels"); + } + + rancherLaunchConfig.setEnvironment(environment); + rancherLaunchConfig.setDataVolumes(dataVolumes); + rancherLaunchConfig.setLabels(labels); + rancherLaunchConfig.setSecrets(secrets); + rancherLaunchConfig.removeEnvironment(removeEnvironment); + rancherLaunchConfig.removeLabels(removeLabels); + if (cfg.containsKey("startFirst")) { startFirst = cfg.get("startFirst").equals("true"); } @@ -167,101 +205,11 @@ public void executeNodeStep(PluginStepContext ctx, Map cfg, INod startFirst = true; } - this.setEnvVars(launchConfigObject); - this.setLabels(launchConfigObject); - this.addSecrets(launchConfigObject); - - doUpgrade(accessKey, secretKey, upgradeUrl, launchConfigObject); + doUpgrade(accessKey, secretKey, upgradeUrl, rancherLaunchConfig.update()); logger.log(Constants.INFO_LEVEL, "Upgraded " + nodeName); } - /** - * Adds/modifies environment variables. - * - * @param launchConfig JsonNode representing the target upgraded configuration. - */ - private void setEnvVars(ObjectNode launchConfig) throws NodeStepException { - if (environment != null && environment.length() > 0) { - ObjectNode envObject = (ObjectNode) launchConfig.get("environment"); - ObjectMapper objectMapper = new ObjectMapper(); - try { - JsonNode map = objectMapper.readTree(ensureStringIsJsonObject(environment)); - Iterator> iterator = map.fields(); - while (iterator.hasNext()) { - Map.Entry entry = iterator.next(); - String key = entry.getKey(); - String value = entry.getValue().asText(); - envObject.put(key, value); - logger.log(Constants.INFO_LEVEL, "Setting environment variable " + key + " to " + value); - } - } catch (JsonProcessingException e) { - throw new NodeStepException("Invalid Labels JSON", ErrorCause.InvalidJson, this.nodeName); - } - } - } - - /** - * Adds/modifies labels based on the step's labels setting. - * - * @param launchConfig JsonNode representing the target upgraded configuration. - */ - private void setLabels(ObjectNode launchConfig) throws NodeStepException { - if (labels != null && labels.length() > 0) { - ObjectNode labelObject = (ObjectNode) launchConfig.get("labels"); - ObjectMapper objectMapper = new ObjectMapper(); - try { - JsonNode map = objectMapper.readTree(ensureStringIsJsonObject(labels)); - Iterator> iterator = map.fields(); - while (iterator.hasNext()) { - Map.Entry entry = iterator.next(); - String key = entry.getKey(); - String value = entry.getValue().asText(); - labelObject.put(key, value); - logger.log(Constants.INFO_LEVEL, "Setting environment variable " + key + " to " + value); - } - } catch (JsonProcessingException e) { - throw new NodeStepException("Invalid Labels JSON", ErrorCause.InvalidJson, this.nodeName); - } - } - } - - /** - * Add or replace secrets. - * - * @param launchConfig JsonNode representing the target upgraded configuration. - * @throws NodeStepException when secret JSON is malformed (passed up from {@see this.buildSecret()}. - */ - private void addSecrets(ObjectNode launchConfig) throws NodeStepException { - if (secrets != null && secrets.length() > 0) { - // Copy existing secrets, skipping any that we want to add or overwrite. - Iterator elements = null; - boolean hasOldSecrets = false; - if (launchConfig.has("secrets") && !launchConfig.get("secrets").isNull()) { - hasOldSecrets = true; - elements = launchConfig.get("secrets").elements(); - } - - ArrayNode secretsArray = launchConfig.putArray("secrets"); - - // Copy existing secrets, skipping any that we want to add or overwrite. - if (hasOldSecrets && elements != null) { - while (elements.hasNext()) { - JsonNode secretObject = elements.next(); - // @todo this only works for a single secret added. - if (!secretObject.path("secretId").asText().equals(secrets)) { - secretsArray.add(secretObject); - } - } - } - - // Add in the new or replacement secrets specified in the step. - for (String secretId : secrets.split("/[,; ]+/")) { - secretsArray.add(buildSecret(secretId, this.nodeName)); - logger.log(Constants.INFO_LEVEL, "Adding secret map to " + secretId); - } - } - } /** * Performs the actual upgrade. diff --git a/src/main/java/com/bioraft/rundeck/rancher/RancherWebSocketListener.java b/src/main/java/com/bioraft/rundeck/rancher/RancherWebSocketListener.java index 543b707..51d399b 100644 --- a/src/main/java/com/bioraft/rundeck/rancher/RancherWebSocketListener.java +++ b/src/main/java/com/bioraft/rundeck/rancher/RancherWebSocketListener.java @@ -23,6 +23,7 @@ import java.io.StringReader; import java.nio.file.Files; import java.util.Base64; +import java.util.Objects; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -91,12 +92,12 @@ public final class RancherWebSocketListener extends WebSocketListener { @Override public void onMessage(WebSocket webSocket, String text) { - logDockerStream(webSocket, Bytes.concat(nextHeader, Base64.getDecoder().decode(text))); + logDockerStream(Bytes.concat(nextHeader, Base64.getDecoder().decode(text))); } @Override public void onMessage(WebSocket webSocket, ByteString bytes) { - logDockerStream(webSocket, Bytes.concat(nextHeader, bytes.toByteArray())); + logDockerStream(Bytes.concat(nextHeader, bytes.toByteArray())); } @Override @@ -115,14 +116,14 @@ public void onFailure(WebSocket webSocket, Throwable t, Response response) { * Runs the overall job step: sends output to a listener; saves PID and exit * status to a temporary file. * - * @param url - * @param accessKey - * @param secretKey - * @param command - * @param listener - * @param temp - * @throws IOException - * @throws InterruptedException + * @param url The URL the listener should use to launch the job. + * @param accessKey Rancher credentials AccessKey. + * @param secretKey Rancher credentials SecretKey. + * @param command The command to run. + * @param listener Log listener from Rundeck. + * @param temp A unique temporary file for this job (".pid" will be appended to the file name) + * @throws IOException When job fails. + * @throws InterruptedException When job is interrupted. */ public static void runJob(String url, String accessKey, String secretKey, String[] command, ExecutionListener listener, String temp, int timeout) throws IOException, InterruptedException { @@ -139,13 +140,13 @@ public static void runJob(String url, String accessKey, String secretKey, String /** * Get contents of a file from server. * - * @param url - * @param accessKey - * @param secretKey - * @param logger - * @param file - * @throws IOException - * @throws InterruptedException + * @param url The URL the listener should use to launch the job. + * @param accessKey Rancher credentials AccessKey. + * @param secretKey Rancher credentials SecretKey. + * @param logger A StringBuilder to which status is appended. + * @param file The file to fetch/cat from the remote container. + * @throws IOException When job fails. + * @throws InterruptedException When job is interrupted. */ public static void getFile(String url, String accessKey, String secretKey, StringBuilder logger, String file) throws IOException, InterruptedException { @@ -156,13 +157,13 @@ public static void getFile(String url, String accessKey, String secretKey, Strin /** * Put text onto a container as the specified file. * - * @param url - * @param accessKey - * @param secretKey - * @param logger - * @param file - * @throws IOException - * @throws InterruptedException + * @param url The URL the listener should use to launch the job. + * @param accessKey Rancher credentials AccessKey. + * @param secretKey Rancher credentials SecretKey. + * @param file The file to copy. + * @param destination Location of target file on specified container. + * @throws IOException When job fails. + * @throws InterruptedException When job is interrupted. */ public static void putFile(String url, String accessKey, String secretKey, File file, String destination) throws IOException, InterruptedException { @@ -174,13 +175,13 @@ public static void putFile(String url, String accessKey, String secretKey, File * * Exit status is read after completion from the job's PID file in /tmp. * - * @param url - * @param accessKey - * @param secretKey - * @param listener - * @param command - * @throws IOException - * @throws InterruptedException + * @param url The URL the listener should use to launch the job. + * @param accessKey Rancher credentials AccessKey. + * @param secretKey Rancher credentials SecretKey. + * @param listener Log listener from Rundeck. + * @param command The command to run. + * @throws IOException When job fails. + * @throws InterruptedException When job is interrupted. */ private void runJob(String url, String accessKey, String secretKey, ExecutionListener listener, String[] command, int timeout) throws IOException, InterruptedException { @@ -198,7 +199,7 @@ private void runJob(String url, String accessKey, String secretKey, ExecutionLis // the message stream so we can pick out lines that are part of STDERR. output = new StringBuilder(); - client.newWebSocket(this.buildRequest(false, true), this); + client.newWebSocket(this.buildRequest(true), this); // Trigger shutdown of the dispatcher's executor so process exits cleanly. client.dispatcher().executorService().shutdown(); @@ -211,14 +212,14 @@ private void runJob(String url, String accessKey, String secretKey, ExecutionLis * * This is used to get the contents of the PID file when the job ends and * determine the exit status. - * - * @param url - * @param accessKey - * @param secretKey - * @param output - * @param command - * @throws IOException - * @throws InterruptedException + * + * @param url The URL the listener should use to launch the job. + * @param accessKey Rancher credentials AccessKey. + * @param secretKey Rancher credentials SecretKey. + * @param output An output buffer used to accumulate results of the command. + * @param command The command to run. + * @throws IOException When job fails. + * @throws InterruptedException When job is interrupted. */ private void run(String url, String accessKey, String secretKey, StringBuilder output, String[] command) throws IOException, InterruptedException { @@ -231,7 +232,7 @@ private void run(String url, String accessKey, String secretKey, StringBuilder o this.output = output; this.nextHeader = new byte[0]; - client.newWebSocket(this.buildRequest(false, true), this); + client.newWebSocket(this.buildRequest(true), this); // Trigger shutdown of the dispatcher's executor so process exits cleanly. client.dispatcher().executorService().shutdown(); @@ -243,14 +244,14 @@ private void run(String url, String accessKey, String secretKey, StringBuilder o * * Neither STDIN or STDOUT are attached. The file is sent as a payload with the * post command. - * - * @param url - * @param accessKey - * @param secretKey - * @param input - * @param file - * @throws IOException - * @throws InterruptedException + * + * @param url The URL the listener should use to launch the job. + * @param accessKey Rancher credentials AccessKey. + * @param secretKey Rancher credentials SecretKey. + * @param input The file to put on the target container. + * @param file The name of the destination file on the target container. + * @throws IOException When job fails. + * @throws InterruptedException When job is interrupted. */ private void put(String url, String accessKey, String secretKey, File input, String file) throws IOException, InterruptedException { @@ -283,10 +284,18 @@ private void put(String url, String accessKey, String secretKey, File input, Str this.runCommand(command, 1); } + /** + * Runs a command on a remote container. + * + * @param command The command to run. + * @param timeout Time to wait before closing unresponsive connections. + * @throws IOException When job fails. + * @throws InterruptedException When job is interrupted. + */ private void runCommand(String[] command, int timeout) throws IOException, InterruptedException { client = new OkHttpClient.Builder().build(); this.commandList = command; - client.newWebSocket(this.buildRequest(false, false), this); + client.newWebSocket(this.buildRequest(false), this); client.dispatcher().executorService().shutdown(); if (timeout > 0) { client.dispatcher().executorService().awaitTermination(timeout, TimeUnit.MILLISECONDS); @@ -296,11 +305,12 @@ private void runCommand(String[] command, int timeout) throws IOException, Inter /** * Builds the web socket request. * - * @return - * @throws IOException + * @param attachStdout Should Rancher attach a TTY to StdOut? + * @return HTTP Request Object + * @throws IOException When connection to the container fails. */ - private Request buildRequest(boolean attachStdin, boolean attachStdout) throws IOException { - JsonNode token = this.getToken(attachStdin, attachStdout); + private Request buildRequest(boolean attachStdout) throws IOException { + JsonNode token = this.getToken(attachStdout); String path = token.path("url").asText() + "?token=" + token.path("token").asText(); return new Request.Builder().url(path).build(); } @@ -308,20 +318,25 @@ private Request buildRequest(boolean attachStdin, boolean attachStdout) throws I /** * Gets the web socket end point and connection token for an execute request. * - * @return - * @throws IOException + * @param attachStdout Should Rancher attach a TTY to StdOut? + * @return WebSocket connection token. + * @throws IOException When connection to the container fails. */ - private JsonNode getToken(boolean attachStdin, boolean attachStdout) throws IOException { - HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder(); + private JsonNode getToken(boolean attachStdout) throws IOException { + HttpUrl.Builder urlBuilder = Objects.requireNonNull(HttpUrl.parse(url)).newBuilder(); String path = urlBuilder.build().toString(); - String content = this.apiData(attachStdin, attachStdout); + String content = this.apiData(attachStdout); try { RequestBody body = RequestBody.create(MediaType.parse("application/json"), content); Request request = new Request.Builder().url(path).post(body) .addHeader("Authorization", Credentials.basic(accessKey, secretKey)).build(); Response response = client.newCall(request).execute(); ObjectMapper mapper = new ObjectMapper(); - return mapper.readTree(response.body().string()); + if (response.body() != null) { + return mapper.readTree(response.body().string()); + } else { + throw new IOException("WebSocket response was null"); + } } catch (IOException e) { System.out.println(e.getMessage()); throw e; @@ -331,16 +346,17 @@ private JsonNode getToken(boolean attachStdin, boolean attachStdout) throws IOEx /** * Builds JSON string of API data. * - * @return - * @throws JsonMappingException - * @throws JsonProcessingException + * @param attachStdout Should Rancher attach a TTY to StdOut? + * @return API Token to use in rancher connections. + * @throws JsonMappingException When JSON is invalid. + * @throws JsonProcessingException When JSON is invalid. */ - private String apiData(boolean attachStdin, boolean attachStdout) + private String apiData(boolean attachStdout) throws JsonMappingException, JsonProcessingException { ObjectMapper mapper = new ObjectMapper(); JsonNode root = mapper.readTree("{}"); ((ObjectNode) root).put("tty", false); - ((ObjectNode) root).put("attachStdin", attachStdin); + ((ObjectNode) root).put("attachStdin", false); ((ObjectNode) root).put("attachStdout", attachStdout); ArrayNode command = ((ObjectNode) root).putArray("command"); for (String atom : commandList) { @@ -351,11 +367,10 @@ private String apiData(boolean attachStdin, boolean attachStdout) /** * Logs a Docker stream passed through Rancher. - * - * @param webSocket - * @param bytes + * + * @param bytes A byte array to send to RunDeck with its log level. */ - public void logDockerStream(WebSocket webSocket, byte[] bytes) { + public void logDockerStream(byte[] bytes) { LogMessage message; BufferedReader stringReader; try { @@ -367,7 +382,7 @@ public void logDockerStream(WebSocket webSocket, byte[] bytes) { // function. if (listener != null) { stringReader = new BufferedReader(new StringReader(new String(message.content.array()))); - log(currentOutputChannel, stringReader); + log(stringReader); } else { output.append(new String(message.content.array())); } @@ -384,11 +399,10 @@ public void logDockerStream(WebSocket webSocket, byte[] bytes) { * Read a Buffer line by line and send lines prefixed by STDERR_TOK to the * WARN_LEVEL channel of RunDeck's console. * - * @param level - * @param message - * @throws IOException + * @param stringReader A buffer of text to be sent to the logger. + * @throws IOException when reading buffer fails. */ - private void log(int level, BufferedReader stringReader) throws IOException { + private void log(BufferedReader stringReader) throws IOException { String line; while ((line = stringReader.readLine()) != null) { if (line.startsWith(STDERR_TOK)) { @@ -407,8 +421,8 @@ private void log(int level, BufferedReader stringReader) throws IOException { * Buffer lines sent to RunDeck's logger so they are sent together and not * line-by-line. * - * @param level - * @param message + * @param level Log level. + * @param message The message to log. */ private void log(int level, String message) { if (listener != null) { diff --git a/src/test/java/com/bioraft/rundeck/rancher/RancherLaunchConfigTest.java b/src/test/java/com/bioraft/rundeck/rancher/RancherLaunchConfigTest.java new file mode 100644 index 0000000..6bd4d3c --- /dev/null +++ b/src/test/java/com/bioraft/rundeck/rancher/RancherLaunchConfigTest.java @@ -0,0 +1,165 @@ +package com.bioraft.rundeck.rancher; + +import com.dtolabs.rundeck.core.execution.workflow.steps.node.NodeStepException; +import com.dtolabs.rundeck.plugins.PluginLogger; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.Iterator; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.*; + +public class RancherLaunchConfigTest { + + String name = "node"; + + ObjectNode objectNode; + ObjectNode reference; + + @Mock + PluginLogger logger; + + @Before + public void setUp() throws Exception { + reference = (ObjectNode) readFromInputStream(getResourceStream()); + objectNode = (ObjectNode) readFromInputStream(getResourceStream()); + MockitoAnnotations.initMocks(this); + } + + @Test + /* + * If nothing is changed, the launchConfig should be unchanged. + */ + public void update() throws NodeStepException { + verify(logger,never()).log(anyInt(), anyString()); + RancherLaunchConfig rancherLaunchConfig = new RancherLaunchConfig(name, objectNode, logger); + assertEquals(reference, rancherLaunchConfig.update()); + } + + @Test + /* + * Test by inserting values and verifying object changes, then removing them and ensuring + * object has return to its original value. + */ + public void updateEnvironment() throws NodeStepException { + RancherLaunchConfig rancherLaunchConfig = new RancherLaunchConfig(name, objectNode, logger); + rancherLaunchConfig.setEnvironment("{\"VAR\": \"value\"}"); + assertNotEquals(reference, rancherLaunchConfig.update()); + verify(logger, times(1)).log(anyInt(), anyString()); + + rancherLaunchConfig.removeEnvironment("[\"VAR\"]"); + ObjectNode result = rancherLaunchConfig.update(); + verify(logger, times(3)).log(anyInt(), anyString()); + + assertEquals(reference, result); + } + + @Test + /* + * Test by inserting values and verifying object changes, then removing them and ensuring + * object has return to its original value. + */ + public void updateLabels() throws NodeStepException { + RancherLaunchConfig rancherLaunchConfig = new RancherLaunchConfig(name, objectNode, logger); + rancherLaunchConfig.setLabels("\"com.example.SERVICE\": \"service\", \"com.example.SITE\": \"site\""); + assertNotEquals(reference, rancherLaunchConfig.update()); + verify(logger, times(2)).log(anyInt(), anyString()); + + rancherLaunchConfig.removeLabels("\"com.example.SERVICE\", \"com.example.SITE\""); + ObjectNode result = rancherLaunchConfig.update(); + verify(logger, times(6)).log(anyInt(), anyString()); + + assertEquals(reference, result); + } + + @Test + /* + * Test by inserting values and verifying object changes, then removing them and ensuring + * object has return to its original value. + */ + public void updateMounts() throws NodeStepException { + RancherLaunchConfig rancherLaunchConfig = new RancherLaunchConfig(name, objectNode, logger); + int originalCount = 0; + Iterator elements = objectNode.path("dataVolumes").elements(); + while (elements.hasNext()) { + elements.next(); + originalCount++; + } + ObjectNode result; + int newCount; + + rancherLaunchConfig.setDataVolumes("\"/source1:/mountpoint1\""); + result = rancherLaunchConfig.update(); + elements = result.path("dataVolumes").elements(); + newCount = 0; + while (elements.hasNext()) { + elements.next(); + newCount++; + } + assertEquals(originalCount, newCount); + assertEquals(result, reference); + + String changedMount = "/source1:/mountpoint1:ro"; + rancherLaunchConfig.setDataVolumes("[\"" + changedMount + "\"]"); + result = rancherLaunchConfig.update(); + elements = result.path("dataVolumes").elements(); + newCount = 0; + while (elements.hasNext()) { + JsonNode element = elements.next(); + assertEquals(changedMount, element.asText()); + newCount++; + } + assertEquals(originalCount, newCount); + assertNotEquals(result, reference); + + String newMount = "/source3:/mountpoint3"; + rancherLaunchConfig.setDataVolumes("[\"" + newMount + "\"]"); + result = rancherLaunchConfig.update(); + elements = result.path("dataVolumes").elements(); + newCount = 0; + while (elements.hasNext()) { + JsonNode element = elements.next(); + newCount++; + // This is a little hacky, but assures us the order of the array does not cause the test to fail. + if (element.asText().equals(changedMount)) { + assertEquals(changedMount, element.asText()); + } else { + assertEquals(newMount, element.asText()); + } + } + assertEquals(originalCount + 1, newCount); + } + + protected InputStream getResourceStream() { + ClassLoader classLoader = getClass().getClassLoader(); + InputStream stream = classLoader.getResourceAsStream("launchConfig.json"); + if (stream == null) throw new AssertionError(); + return stream; + } + + protected JsonNode readFromInputStream(InputStream inputStream) throws IOException { + StringBuilder resultStringBuilder = new StringBuilder(); + InputStreamReader reader = new InputStreamReader(inputStream); + try (BufferedReader br = new BufferedReader(reader)) { + String line; + while ((line = br.readLine()) != null) { + resultStringBuilder.append(line).append("\n"); + } + } + ObjectMapper mapper = new ObjectMapper(); + return mapper.readTree(resultStringBuilder.toString()); + } +} \ No newline at end of file diff --git a/src/test/java/com/bioraft/rundeck/rancher/RancherSharedTest.java b/src/test/java/com/bioraft/rundeck/rancher/RancherSharedTest.java index e10fb96..da5a9c8 100644 --- a/src/test/java/com/bioraft/rundeck/rancher/RancherSharedTest.java +++ b/src/test/java/com/bioraft/rundeck/rancher/RancherSharedTest.java @@ -15,18 +15,12 @@ */ package com.bioraft.rundeck.rancher; -import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.Mock; import org.mockito.runners.MockitoJUnitRunner; -import static com.bioraft.rundeck.rancher.RancherShared.ensureStringIsJsonArray; -import static com.bioraft.rundeck.rancher.RancherShared.ensureStringIsJsonObject; - +import static com.bioraft.rundeck.rancher.RancherShared.*; import static org.junit.Assert.assertEquals; -import static org.mockito.Matchers.any; -import static org.mockito.Mockito.*; /** * Tests for Nexus3OptionProvider. @@ -56,6 +50,16 @@ public void testObjectWrapping() { assertEquals(wrapObject(string), ensureStringIsJsonObject(" \n \t\r{" + string + "} \n\t")); } + @Test + public void testMountSplitter() { + testOneMount("/mount/point", "/local", ""); + testOneMount("/mount", "/local/storage",":ro"); + } + + private void testOneMount(String mountPoint, String local, String options) { + assertEquals(mountPoint, mountPoint(local + ":" + mountPoint + options)); + } + private String wrapArray(String string) { return "[" + string + "]"; } diff --git a/src/test/resources/launchConfig.json b/src/test/resources/launchConfig.json new file mode 100644 index 0000000..1436991 --- /dev/null +++ b/src/test/resources/launchConfig.json @@ -0,0 +1,60 @@ +{ + "type": "launchConfig", + "dataVolumes": [ + "/source1:/mountpoint1" + ], + "environment": { + "DOMAIN": "mysite.development.example.com", + "ENVIRONMENT": "development", + "SITE": "mysite" + }, + "healthCheck": { + "type": "instanceHealthCheck", + "healthyThreshold": 2, + "initializingTimeout": 9000, + "interval": 300, + "port": 80, + "reinitializingTimeout": 9000, + "responseTimeout": 300, + "strategy": "none", + "unhealthyThreshold": 3 + }, + "imageUuid": "docker:nexus.example.com/frontend:v1.2.3_1", + "instanceTriggeredStop": "stop", + "kind": "container", + "labels": { + "com.example.group": "dev", + "com.example.service": "frontend", + "com.example.site": "mysite", + "io.rancher.service.hash": "0123456789012345678901234567890123456789", + "com.example.description": "mysite.development.example.com" + }, + "logConfig": { + "type": "logConfig" + }, + "networkMode": "managed", + "ports": [ + "57372:80/tcp" + ], + "privileged": false, + "publishAllPorts": false, + "readOnly": false, + "runInit": false, + "secrets": [ + { + "type": "secretReference", + "gid": "0", + "mode": "444", + "name": "", + "secretId": "1se1", + "uid": "0" + } + ], + "startOnCreate": true, + "stdinOpen": false, + "system": false, + "tty": false, + "version": "00000000-0000-0000-0000-000000000000", + "vcpu": 1, + "drainTimeoutMs": 0 +} \ No newline at end of file