From d6fb351db1c4fb4d67fe1a12c558c9dda852c827 Mon Sep 17 00:00:00 2001 From: David Kirstein Date: Fri, 11 Mar 2016 22:53:35 +0100 Subject: [PATCH] initial commit --- .gitignore | 71 +++++ LICENSE | 8 + README.md | 64 +++++ build.gradle | 53 ++++ gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 164 ++++++++++++ gradlew.bat | 90 +++++++ settings.gradle | 1 + .../batix/rundeck/AnsibleFailureReason.java | 8 + .../com/batix/rundeck/AnsibleFileCopier.java | 100 +++++++ .../batix/rundeck/AnsibleModuleNodeStep.java | 65 +++++ .../rundeck/AnsibleModuleWorkflowStep.java | 64 +++++ .../batix/rundeck/AnsibleNodeExecutor.java | 84 ++++++ .../rundeck/AnsiblePlaybookNodeStep.java | 58 +++++ .../AnsiblePlaybookPropertyValidator.java | 17 ++ .../rundeck/AnsiblePlaybookWorkflowStep.java | 57 ++++ .../rundeck/AnsibleResourceModelSource.java | 238 +++++++++++++++++ .../AnsibleResourceModelSourceFactory.java | 46 ++++ .../java/com/batix/rundeck/AnsibleRunner.java | 243 ++++++++++++++++++ src/main/resources/gather-hosts.yml | 9 + src/main/resources/host-tpl.j2 | 1 + 21 files changed, 1447 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.gradle create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/com/batix/rundeck/AnsibleFailureReason.java create mode 100644 src/main/java/com/batix/rundeck/AnsibleFileCopier.java create mode 100644 src/main/java/com/batix/rundeck/AnsibleModuleNodeStep.java create mode 100644 src/main/java/com/batix/rundeck/AnsibleModuleWorkflowStep.java create mode 100644 src/main/java/com/batix/rundeck/AnsibleNodeExecutor.java create mode 100644 src/main/java/com/batix/rundeck/AnsiblePlaybookNodeStep.java create mode 100644 src/main/java/com/batix/rundeck/AnsiblePlaybookPropertyValidator.java create mode 100644 src/main/java/com/batix/rundeck/AnsiblePlaybookWorkflowStep.java create mode 100644 src/main/java/com/batix/rundeck/AnsibleResourceModelSource.java create mode 100644 src/main/java/com/batix/rundeck/AnsibleResourceModelSourceFactory.java create mode 100644 src/main/java/com/batix/rundeck/AnsibleRunner.java create mode 100644 src/main/resources/gather-hosts.yml create mode 100644 src/main/resources/host-tpl.j2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..7153bf89 --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# Created by .ignore support plugin (hsz.mobi) +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio + +*.iml + +## Directory-based project format: +.idea/ +# if you remove the above rule, at least ignore the following: + +# User-specific stuff: +# .idea/workspace.xml +# .idea/tasks.xml +# .idea/dictionaries + +# Sensitive or high-churn files: +# .idea/dataSources.ids +# .idea/dataSources.xml +# .idea/sqlDataSources.xml +# .idea/dynamic.xml +# .idea/uiDesigner.xml + +# Gradle: +# .idea/gradle.xml +# .idea/libraries + +# Mongo Explorer plugin: +# .idea/mongoSettings.xml + +## File-based project format: +*.ipr +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +### Gradle template +.gradle +build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar +### Java template +*.class + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.ear + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..23aacf35 --- /dev/null +++ b/LICENSE @@ -0,0 +1,8 @@ +The MIT License (MIT) +Copyright (c) 2016 David Kirstein + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..4e63d7d9 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +**This is an alpha release!** Use with caution. + +Please [report](https://github.com/Batix/rundeck-ansible-plugin/issues) any errors or suggestions! + +## Rundeck Ansible Plugin ## + +This plugin brings basic Ansible support to Rundeck. It imports hosts from Ansible's inventory, including a bunch of facts, and can run modules and playbooks. There is also a node executor and file copier for your project. + +No SSH-Keys need to be shared between Ansible and Rundeck, everything is run through either `ansible` or `ansible-playbook` (even the node import). + +The following bits are included: + +### Resource Model Source ### + +Uses the default configured inventory to scan for nodes. Facts are discovered by default, but you can turn that off (although I highly recommend leaving it on). + +Host groups are imported as tags, you can limit the import to just some selected [patterns](http://docs.ansible.com/ansible/intro_patterns.html), if you want. + +A bunch of facts are imported as attributes ([sample screenshot](http://batix.de/static/files/rundeck-ansible/node.png)). + +### Node Executor ### + +This makes it possible to run commands via the "Commands" menu or the default "Command" node step in a job. + +The command is passed to Ansible's `shell` module. You can specify which shell to use in the project settings. + +### File Copier ### + +Enables usage of the default "Copy File" and (in combination with the above) "Script" node steps. + +Files are transferred using Ansible's `copy` module. + +### Run Ansible Modules ### + +Run any Ansible module! You can specify the module name and arguments. + +This is available as both a node and workflow step. + +Note: The node step runs Ansible for every node, targeting only one node. The workflow step runs Ansible only once with a list of targets, so it should perform a bit better, if you don't need the individuality. + +### Run Ansible Playbooks ### + +Run a playbook as a node or workflow step (see note above). You specify a path to a file, which must be accessible to Rundeck. + +## Requirements ## + +- Ansible executable(s) in `PATH` of Rundeck user +- Rundeck user needs to be able to successfully run Ansible commands, that includes access to Ansible's config files and keys + +## Installation ## + +- Download the .jar file from GitHub or compile it yourself (using Gradle, either your own the included wrapper) +- Copy the .jar file to your Rundeck plugins directory (`/var/lib/rundeck/libext` if you installed the .deb, for example) +- Create a new project (this assumes you want every node in your project to be controlled via Ansible) +- Choose "Ansible Resource Model Source" as the resource model source +- Choose "Ansible Ad-Hoc Node Executor" as the default node executor +- Choose "Ansible File Copier" as the default node file copier +- Save, it can take a short time to import all the nodes, depending on your fleet +- You're all set! Try running a command + +## Notes ## +I'm new to both Rundeck and Ansible so I expect there to be room for improvements. Only basic features have been implemented in this first pass, so I can play around with both tools. Liking it very much so far! :) + +Tested on Debian. diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..b4faea3c --- /dev/null +++ b/build.gradle @@ -0,0 +1,53 @@ +group 'com.batix.rundeck' +version '1.0.0' + +ext.rundeckPluginVersion = '1.1' +ext.pluginClassNames = [ + 'com.batix.rundeck.AnsibleResourceModelSourceFactory', + 'com.batix.rundeck.AnsibleNodeExecutor', + 'com.batix.rundeck.AnsibleFileCopier', + 'com.batix.rundeck.AnsiblePlaybookNodeStep', + 'com.batix.rundeck.AnsiblePlaybookWorkflowStep', + 'com.batix.rundeck.AnsibleModuleNodeStep', + 'com.batix.rundeck.AnsibleModuleWorkflowStep' +].join(',') + +apply plugin: 'java' + +sourceCompatibility = '1.7' + +repositories { + mavenCentral() +} + +configurations { + pluginLibs + + compile { + extendsFrom pluginLibs + } +} + +dependencies { + pluginLibs 'com.google.code.gson:gson:2.5' + + compile 'org.rundeck:rundeck-core:2.6.3' +} + +task copyToLib(type: Copy) { + into "$buildDir/output/lib" + from configurations.pluginLibs +} + +jar { + from "$buildDir/output" + manifest { + def libList = configurations.pluginLibs.collect{'lib/' + it.name}.join(' ') + attributes 'Rundeck-Plugin-Classnames': pluginClassNames + attributes 'Rundeck-Plugin-File-Version': version + attributes 'Rundeck-Plugin-Version': rundeckPluginVersion + attributes 'Rundeck-Plugin-Archive': 'true' + attributes 'Rundeck-Plugin-Libs': "${libList}" + } + dependsOn(copyToLib) +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..29772e63 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Mar 01 19:25:01 CET 2016 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-2.5-all.zip diff --git a/gradlew b/gradlew new file mode 100644 index 00000000..91a7e269 --- /dev/null +++ b/gradlew @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# For Cygwin, ensure paths are in UNIX format before anything is touched. +if $cygwin ; then + [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` +fi + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >&- +APP_HOME="`pwd -P`" +cd "$SAVED" >&- + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..8a0b282a --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..cdb60193 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'ansible-plugin' diff --git a/src/main/java/com/batix/rundeck/AnsibleFailureReason.java b/src/main/java/com/batix/rundeck/AnsibleFailureReason.java new file mode 100644 index 00000000..d9fe82b1 --- /dev/null +++ b/src/main/java/com/batix/rundeck/AnsibleFailureReason.java @@ -0,0 +1,8 @@ +package com.batix.rundeck; + +import com.dtolabs.rundeck.core.execution.workflow.steps.FailureReason; + +public enum AnsibleFailureReason implements FailureReason { + AnsibleNonZero, // Ansible process exited with non-zero value + AnsibleError // Ansible not found etc. +} diff --git a/src/main/java/com/batix/rundeck/AnsibleFileCopier.java b/src/main/java/com/batix/rundeck/AnsibleFileCopier.java new file mode 100644 index 00000000..eedd3e7d --- /dev/null +++ b/src/main/java/com/batix/rundeck/AnsibleFileCopier.java @@ -0,0 +1,100 @@ +package com.batix.rundeck; + +import com.dtolabs.rundeck.core.common.INodeEntry; +import com.dtolabs.rundeck.core.execution.ExecutionContext; +import com.dtolabs.rundeck.core.execution.impl.jsch.JschScpFileCopier; +import com.dtolabs.rundeck.core.execution.service.DestinationFileCopier; +import com.dtolabs.rundeck.core.execution.service.FileCopierException; +import com.dtolabs.rundeck.core.plugins.Plugin; +import com.dtolabs.rundeck.core.plugins.configuration.Describable; +import com.dtolabs.rundeck.core.plugins.configuration.Description; +import com.dtolabs.rundeck.plugins.ServiceNameConstants; +import com.dtolabs.rundeck.plugins.util.DescriptionBuilder; + +import java.io.File; +import java.io.InputStream; + +@Plugin(name = AnsibleFileCopier.SERVICE_PROVIDER_NAME, service = ServiceNameConstants.FileCopier) +public class AnsibleFileCopier implements DestinationFileCopier, Describable { + public static final String SERVICE_PROVIDER_NAME = "com.batix.rundeck.AnsibleFileCopier"; + + @Override + public String copyFileStream(ExecutionContext context, InputStream input, INodeEntry node, String destination) throws FileCopierException { + return doFileCopy(context, null, input, null, node, destination); + } + + @Override + public String copyFile(ExecutionContext context, File file, INodeEntry node, String destination) throws FileCopierException { + return doFileCopy(context, file, null, null, node, destination); + } + + @Override + public String copyScriptContent(ExecutionContext context, String script, INodeEntry node, String destination) throws FileCopierException { + return doFileCopy(context, null, null, script, node, destination); + } + + @Override + public String copyFileStream(ExecutionContext context, InputStream input, INodeEntry node) throws FileCopierException { + return doFileCopy(context, null, input, null, node, null); + } + + @Override + public String copyFile(ExecutionContext context, File file, INodeEntry node) throws FileCopierException { + return doFileCopy(context, file, null, null, node, null); + } + + @Override + public String copyScriptContent(ExecutionContext context, String script, INodeEntry node) throws FileCopierException { + return doFileCopy(context, null, null, script, node, null); + } + + private String doFileCopy( + final ExecutionContext context, + final File scriptFile, + final InputStream input, + final String script, + final INodeEntry node, + String destinationPath + ) throws FileCopierException { + if (destinationPath == null) { + String identity = (context.getDataContext() != null && context.getDataContext().get("job") != null) ? + context.getDataContext().get("job").get("execid") : null; + destinationPath = JschScpFileCopier.generateRemoteFilepathForNode( + node, + context.getFramework().getFrameworkProjectMgr().getFrameworkProject(context.getFrameworkProject()), + context.getFramework(), + scriptFile != null ? scriptFile.getName() : "dispatch-script", + null, + identity + ); + } + + File localTempFile = scriptFile != null ? + scriptFile : JschScpFileCopier.writeTempFile(context, null, input, script); + + String cmdArgs = "src='" + localTempFile.getAbsolutePath() + "' dest='" + destinationPath + "'"; + + AnsibleRunner runner = AnsibleRunner.adHoc("copy", cmdArgs).limit(node.getNodename()); + int result; + try { + result = runner.run(); + } catch (Exception e) { + throw new FileCopierException("Error running Ansible.", AnsibleFailureReason.AnsibleError, e); + } + + if (result != 0) { + throw new FileCopierException("Ansible exited with non-zero code.", AnsibleFailureReason.AnsibleNonZero); + } + + return destinationPath; + } + + @Override + public Description getDescription() { + return DescriptionBuilder.builder() + .name(SERVICE_PROVIDER_NAME) + .title("Ansible File Copier") + .description("Sends a file to a node via the copy module.") + .build(); + } +} diff --git a/src/main/java/com/batix/rundeck/AnsibleModuleNodeStep.java b/src/main/java/com/batix/rundeck/AnsibleModuleNodeStep.java new file mode 100644 index 00000000..6fd039f7 --- /dev/null +++ b/src/main/java/com/batix/rundeck/AnsibleModuleNodeStep.java @@ -0,0 +1,65 @@ +package com.batix.rundeck; + +import com.dtolabs.rundeck.core.common.INodeEntry; +import com.dtolabs.rundeck.core.execution.workflow.steps.node.NodeStepException; +import com.dtolabs.rundeck.core.plugins.Plugin; +import com.dtolabs.rundeck.core.plugins.configuration.Describable; +import com.dtolabs.rundeck.core.plugins.configuration.Description; +import com.dtolabs.rundeck.core.plugins.configuration.PropertyUtil; +import com.dtolabs.rundeck.plugins.PluginLogger; +import com.dtolabs.rundeck.plugins.ServiceNameConstants; +import com.dtolabs.rundeck.plugins.step.NodeStepPlugin; +import com.dtolabs.rundeck.plugins.step.PluginStepContext; +import com.dtolabs.rundeck.plugins.util.DescriptionBuilder; +import org.apache.tools.ant.Project; + +import java.util.Map; + +@Plugin(name = AnsibleModuleNodeStep.SERVICE_PROVIDER_NAME, service = ServiceNameConstants.WorkflowNodeStep) +public class AnsibleModuleNodeStep implements NodeStepPlugin, Describable { + public static final String SERVICE_PROVIDER_NAME = "com.batix.rundeck.AnsibleModuleNodeStep"; + + @Override + public void executeNodeStep(PluginStepContext context, Map configuration, INodeEntry entry) throws NodeStepException { + String module = (String) configuration.get("module"); + String args = (String) configuration.get("args"); + + AnsibleRunner runner = AnsibleRunner.adHoc(module, args).limit(entry.getNodename()); + int result; + try { + result = runner.run(); + } catch (Exception e) { + throw new NodeStepException("Error running Ansible.", e, AnsibleFailureReason.AnsibleError, entry.getNodename()); + } + + PluginLogger logger = context.getLogger(); + logger.log(Project.MSG_INFO, runner.getOutput()); + + if (result != 0) { + throw new NodeStepException("Ansible exited with non-zero code.", AnsibleFailureReason.AnsibleNonZero, entry.getNodename()); + } + } + + @Override + public Description getDescription() { + return DescriptionBuilder.builder() + .name(SERVICE_PROVIDER_NAME) + .title("Ansible Module") + .description("Runs an Ansible Module on a single node.") + .property(PropertyUtil.string( + "module", + "Module", + "Module name", + true, + null + )) + .property(PropertyUtil.string( + "args", + "Arguments", + "Arguments to pass to the module", + false, + null + )) + .build(); + } +} diff --git a/src/main/java/com/batix/rundeck/AnsibleModuleWorkflowStep.java b/src/main/java/com/batix/rundeck/AnsibleModuleWorkflowStep.java new file mode 100644 index 00000000..f75f2099 --- /dev/null +++ b/src/main/java/com/batix/rundeck/AnsibleModuleWorkflowStep.java @@ -0,0 +1,64 @@ +package com.batix.rundeck; + +import com.dtolabs.rundeck.core.execution.workflow.steps.StepException; +import com.dtolabs.rundeck.core.plugins.Plugin; +import com.dtolabs.rundeck.core.plugins.configuration.Describable; +import com.dtolabs.rundeck.core.plugins.configuration.Description; +import com.dtolabs.rundeck.core.plugins.configuration.PropertyUtil; +import com.dtolabs.rundeck.plugins.PluginLogger; +import com.dtolabs.rundeck.plugins.ServiceNameConstants; +import com.dtolabs.rundeck.plugins.step.PluginStepContext; +import com.dtolabs.rundeck.plugins.step.StepPlugin; +import com.dtolabs.rundeck.plugins.util.DescriptionBuilder; +import org.apache.tools.ant.Project; + +import java.util.Map; + +@Plugin(name = AnsibleModuleWorkflowStep.SERVICE_PROVIDER_NAME, service = ServiceNameConstants.WorkflowStep) +public class AnsibleModuleWorkflowStep implements StepPlugin, Describable { + public static final String SERVICE_PROVIDER_NAME = "com.batix.rundeck.AnsibleModuleWorkflowStep"; + + @Override + public void executeStep(PluginStepContext context, Map configuration) throws StepException { + String module = (String) configuration.get("module"); + String args = (String) configuration.get("args"); + + AnsibleRunner runner = AnsibleRunner.adHoc(module, args).limit(context.getNodes()); + int result; + try { + result = runner.run(); + } catch (Exception e) { + throw new StepException("Error running Ansible.", e, AnsibleFailureReason.AnsibleError); + } + + PluginLogger logger = context.getLogger(); + logger.log(Project.MSG_INFO, runner.getOutput()); + + if (result != 0) { + throw new StepException("Ansible exited with non-zero code.", AnsibleFailureReason.AnsibleNonZero); + } + } + + @Override + public Description getDescription() { + return DescriptionBuilder.builder() + .name(SERVICE_PROVIDER_NAME) + .title("Ansible Module") + .description("Runs an Ansible Module on selected node.") + .property(PropertyUtil.string( + "module", + "Module", + "Module name", + true, + null + )) + .property(PropertyUtil.string( + "args", + "Arguments", + "Arguments to pass to the module", + false, + null + )) + .build(); + } +} diff --git a/src/main/java/com/batix/rundeck/AnsibleNodeExecutor.java b/src/main/java/com/batix/rundeck/AnsibleNodeExecutor.java new file mode 100644 index 00000000..272e9eee --- /dev/null +++ b/src/main/java/com/batix/rundeck/AnsibleNodeExecutor.java @@ -0,0 +1,84 @@ +package com.batix.rundeck; + +import com.dtolabs.rundeck.core.cli.CLIUtils; +import com.dtolabs.rundeck.core.common.INodeEntry; +import com.dtolabs.rundeck.core.common.IRundeckProject; +import com.dtolabs.rundeck.core.common.ProjectManager; +import com.dtolabs.rundeck.core.execution.ExecArgList; +import com.dtolabs.rundeck.core.execution.ExecutionContext; +import com.dtolabs.rundeck.core.execution.service.NodeExecutor; +import com.dtolabs.rundeck.core.execution.service.NodeExecutorResult; +import com.dtolabs.rundeck.core.execution.service.NodeExecutorResultImpl; +import com.dtolabs.rundeck.core.plugins.Plugin; +import com.dtolabs.rundeck.core.plugins.configuration.Describable; +import com.dtolabs.rundeck.core.plugins.configuration.Description; +import com.dtolabs.rundeck.core.plugins.configuration.PropertyUtil; +import com.dtolabs.rundeck.core.utils.Converter; +import com.dtolabs.rundeck.plugins.ServiceNameConstants; +import com.dtolabs.rundeck.plugins.util.DescriptionBuilder; +import com.google.gson.JsonObject; + +import java.util.Arrays; + +@Plugin(name = AnsibleNodeExecutor.SERVICE_PROVIDER_NAME, service = ServiceNameConstants.NodeExecutor) +public class AnsibleNodeExecutor implements NodeExecutor, Describable { + public static final String SERVICE_PROVIDER_NAME = "com.batix.rundeck.AnsibleNodeExecutor"; + + @Override + public NodeExecutorResult executeCommand(ExecutionContext context, String[] command, INodeEntry node) { + String cmdArgs = ""; + ProjectManager projectManager = context.getFramework().getProjectManager(); + IRundeckProject frameworkProject = projectManager.getFrameworkProject(context.getFrameworkProject()); + cmdArgs += "executable=" + frameworkProject.getProperty("executable") + " "; + final Converter quote = CLIUtils.argumentQuoteForOperatingSystem(node.getOsFamily()); + String flatCmd = ExecArgList.joinAndQuote(Arrays.asList(command), quote); + flatCmd = flatCmd.replaceAll("^'|'$", ""); + cmdArgs += flatCmd; + + AnsibleRunner runner = AnsibleRunner.adHoc("shell", cmdArgs).limit(node.getNodename()); + int result; + try { + result = runner.run(); + } catch (Exception e) { + return NodeExecutorResultImpl.createFailure(AnsibleFailureReason.AnsibleError, e.getMessage(), e, node, runner.getResult()); + } + + JsonObject json = runner.getResults().get(0).results.get(0).json; + + if (json.has("stdout")) { + String string = json.get("stdout").getAsString(); + if (string != null && string.length() > 0) { + System.out.println(string); + } + } + if (json.has("stderr")) { + String string = json.get("stderr").getAsString(); + if (string != null && string.length() > 0) { + System.err.println(string); + } + } + + if (result != 0) { + return NodeExecutorResultImpl.createFailure(AnsibleFailureReason.AnsibleNonZero, "Ansible exited with non-zero code.", node, result); + } + + return NodeExecutorResultImpl.createSuccess(node); + } + + @Override + public Description getDescription() { + return DescriptionBuilder.builder() + .name(SERVICE_PROVIDER_NAME) + .title("Ansible Ad-Hoc Node Executor") + .description("Runs Ansible Ad-Hoc commands on the nodes using the shell module.") + .property(PropertyUtil.freeSelect( + "executable", + "Executable", + "Change the remote shell used to execute the command. Should be an absolute path to the executable.", + true, + "/bin/bash", + Arrays.asList("/bin/sh", "/bin/bash") + )) + .build(); + } +} diff --git a/src/main/java/com/batix/rundeck/AnsiblePlaybookNodeStep.java b/src/main/java/com/batix/rundeck/AnsiblePlaybookNodeStep.java new file mode 100644 index 00000000..054d3f53 --- /dev/null +++ b/src/main/java/com/batix/rundeck/AnsiblePlaybookNodeStep.java @@ -0,0 +1,58 @@ +package com.batix.rundeck; + +import com.dtolabs.rundeck.core.common.INodeEntry; +import com.dtolabs.rundeck.core.execution.workflow.steps.node.NodeStepException; +import com.dtolabs.rundeck.core.plugins.Plugin; +import com.dtolabs.rundeck.core.plugins.configuration.Describable; +import com.dtolabs.rundeck.core.plugins.configuration.Description; +import com.dtolabs.rundeck.core.plugins.configuration.PropertyUtil; +import com.dtolabs.rundeck.plugins.PluginLogger; +import com.dtolabs.rundeck.plugins.ServiceNameConstants; +import com.dtolabs.rundeck.plugins.step.NodeStepPlugin; +import com.dtolabs.rundeck.plugins.step.PluginStepContext; +import com.dtolabs.rundeck.plugins.util.DescriptionBuilder; +import org.apache.tools.ant.Project; + +import java.util.Map; + +@Plugin(name = AnsiblePlaybookNodeStep.SERVICE_PROVIDER_NAME, service = ServiceNameConstants.WorkflowNodeStep) +public class AnsiblePlaybookNodeStep implements NodeStepPlugin, Describable { + public static final String SERVICE_PROVIDER_NAME = "com.batix.rundeck.AnsiblePlaybookNodeStep"; + + @Override + public void executeNodeStep(PluginStepContext context, Map configuration, INodeEntry entry) throws NodeStepException { + String playbook = (String) configuration.get("playbook"); + + AnsibleRunner runner = AnsibleRunner.playbook(playbook).limit(entry.getNodename()); + int result; + try { + result = runner.run(); + } catch (Exception e) { + throw new NodeStepException("Error running Ansible.", e, AnsibleFailureReason.AnsibleError, entry.getNodename()); + } + + PluginLogger logger = context.getLogger(); + logger.log(Project.MSG_INFO, runner.getOutput()); + + if (result != 0) { + throw new NodeStepException("Ansible exited with non-zero code.", AnsibleFailureReason.AnsibleNonZero, entry.getNodename()); + } + } + + @Override + public Description getDescription() { + return DescriptionBuilder.builder() + .name(SERVICE_PROVIDER_NAME) + .title("Ansible Playbook") + .description("Runs an Ansible Playbook on a single node.") + .property(PropertyUtil.string( + "playbook", + "Playbook", + "Path to a playbook", + true, + null, + new AnsiblePlaybookPropertyValidator() + )) + .build(); + } +} diff --git a/src/main/java/com/batix/rundeck/AnsiblePlaybookPropertyValidator.java b/src/main/java/com/batix/rundeck/AnsiblePlaybookPropertyValidator.java new file mode 100644 index 00000000..8284f3bb --- /dev/null +++ b/src/main/java/com/batix/rundeck/AnsiblePlaybookPropertyValidator.java @@ -0,0 +1,17 @@ +package com.batix.rundeck; + +import com.dtolabs.rundeck.core.plugins.configuration.PropertyValidator; +import com.dtolabs.rundeck.core.plugins.configuration.ValidationException; + +import java.io.File; + +public class AnsiblePlaybookPropertyValidator implements PropertyValidator { + @Override + public boolean isValid(String value) throws ValidationException { + File file = new File(value); + if (!file.exists()) { + throw new ValidationException("File not found."); + } + return true; + } +} diff --git a/src/main/java/com/batix/rundeck/AnsiblePlaybookWorkflowStep.java b/src/main/java/com/batix/rundeck/AnsiblePlaybookWorkflowStep.java new file mode 100644 index 00000000..09766cdc --- /dev/null +++ b/src/main/java/com/batix/rundeck/AnsiblePlaybookWorkflowStep.java @@ -0,0 +1,57 @@ +package com.batix.rundeck; + +import com.dtolabs.rundeck.core.execution.workflow.steps.StepException; +import com.dtolabs.rundeck.core.plugins.Plugin; +import com.dtolabs.rundeck.core.plugins.configuration.Describable; +import com.dtolabs.rundeck.core.plugins.configuration.Description; +import com.dtolabs.rundeck.core.plugins.configuration.PropertyUtil; +import com.dtolabs.rundeck.plugins.PluginLogger; +import com.dtolabs.rundeck.plugins.ServiceNameConstants; +import com.dtolabs.rundeck.plugins.step.PluginStepContext; +import com.dtolabs.rundeck.plugins.step.StepPlugin; +import com.dtolabs.rundeck.plugins.util.DescriptionBuilder; +import org.apache.tools.ant.Project; + +import java.util.Map; + +@Plugin(name = AnsiblePlaybookWorkflowStep.SERVICE_PROVIDER_NAME, service = ServiceNameConstants.WorkflowStep) +public class AnsiblePlaybookWorkflowStep implements StepPlugin, Describable { + public static final String SERVICE_PROVIDER_NAME = "com.batix.rundeck.AnsiblePlaybookWorkflowStep"; + + @Override + public void executeStep(PluginStepContext context, Map configuration) throws StepException { + String playbook = (String) configuration.get("playbook"); + + AnsibleRunner runner = AnsibleRunner.playbook(playbook).limit(context.getNodes()); + int result; + try { + result = runner.run(); + } catch (Exception e) { + throw new StepException("Error running Ansible.", e, AnsibleFailureReason.AnsibleError); + } + + PluginLogger logger = context.getLogger(); + logger.log(Project.MSG_INFO, runner.getOutput()); + + if (result != 0) { + throw new StepException("Ansible exited with non-zero code.", AnsibleFailureReason.AnsibleNonZero); + } + } + + @Override + public Description getDescription() { + return DescriptionBuilder.builder() + .name(SERVICE_PROVIDER_NAME) + .title("Ansible Playbook") + .description("Runs an Ansible Playbook on selected nodes.") + .property(PropertyUtil.string( + "playbook", + "Playbook", + "Path to a playbook", + true, + null, + new AnsiblePlaybookPropertyValidator() + )) + .build(); + } +} diff --git a/src/main/java/com/batix/rundeck/AnsibleResourceModelSource.java b/src/main/java/com/batix/rundeck/AnsibleResourceModelSource.java new file mode 100644 index 00000000..ae6a7d64 --- /dev/null +++ b/src/main/java/com/batix/rundeck/AnsibleResourceModelSource.java @@ -0,0 +1,238 @@ +package com.batix.rundeck; + +import com.dtolabs.rundeck.core.common.INodeSet; +import com.dtolabs.rundeck.core.common.NodeEntryImpl; +import com.dtolabs.rundeck.core.common.NodeSetImpl; +import com.dtolabs.rundeck.core.resources.ResourceModelSource; +import com.dtolabs.rundeck.core.resources.ResourceModelSourceException; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.*; + +public class AnsibleResourceModelSource implements ResourceModelSource { + final boolean gatherFacts; + final String limit; + + public AnsibleResourceModelSource(Properties configuration) { + gatherFacts = "true".equals(configuration.get("gatherFacts")); + limit = (String) configuration.get("limit"); + } + + @Override + public INodeSet getNodes() throws ResourceModelSourceException { + NodeSetImpl nodes = new NodeSetImpl(); + + Path tempDirectory; + try { + tempDirectory = Files.createTempDirectory("ansible-hosts"); + } catch (IOException e) { + throw new ResourceModelSourceException("Error creating temporary directory.", e); + } + + try { + Files.copy(this.getClass().getClassLoader().getResourceAsStream("host-tpl.j2"), tempDirectory.resolve("host-tpl.j2")); + Files.copy(this.getClass().getClassLoader().getResourceAsStream("gather-hosts.yml"), tempDirectory.resolve("gather-hosts.yml")); + } catch (IOException e) { + throw new ResourceModelSourceException("Error copying files."); + } + + ArrayList args = new ArrayList<>(); + args.add("ansible-playbook"); + args.add("gather-hosts.yml"); + args.add("-e facts=" + (gatherFacts ? "True" : "False")); + if (limit != null && limit.length() > 0) { + args.add("-l " + limit); + } + + try { + Process proc = new ProcessBuilder() + .command(args) + .directory(tempDirectory.toFile()) + .start(); + proc.waitFor(); + } catch (IOException e) { + throw new ResourceModelSourceException("Error running playbook.", e); + } catch (InterruptedException e) { + throw new ResourceModelSourceException("Error while waiting for playbook to finish.", e); + } + + try { + DirectoryStream directoryStream = Files.newDirectoryStream(tempDirectory.resolve("data")); + for (Path factFile : directoryStream) { + NodeEntryImpl node = new NodeEntryImpl(); + + JsonElement json = new JsonParser().parse(Files.newBufferedReader(factFile, Charset.forName("utf-8"))); + JsonObject root = json.getAsJsonObject(); + + String hostname = root.get("inventory_hostname").getAsString(); + if (root.has("ansible_host")) { + hostname = root.get("ansible_host").getAsString(); + } else if (root.has("ansible_ssh_host")) { // deprecated variable + hostname = root.get("ansible_ssh_host").getAsString(); + } + + String nodename = root.get("inventory_hostname").getAsString(); + + node.setHostname(hostname); + node.setNodename(nodename); + + String username = System.getProperty("user.name"); // TODO better default? + if (root.has("ansible_user")) { + username = root.get("ansible_user").getAsString(); + } else if (root.has("ansible_ssh_user")) { // deprecated variable + username = root.get("ansible_ssh_user").getAsString(); + } else if (root.has("ansible_user_id")) { // fact + username = root.get("ansible_user_id").getAsString(); + } + node.setUsername(username); + + HashSet tags = new HashSet<>(); + for (JsonElement ele : root.getAsJsonArray("group_names")) { + tags.add(ele.getAsString()); + } + node.setTags(tags); + + if (root.has("ansible_lsb")) { + node.setDescription(root.getAsJsonObject("ansible_lsb").get("description").getAsString()); + } else { + StringBuilder sb = new StringBuilder(); + + if (root.has("ansible_distribution")) { + sb.append(root.get("ansible_distribution").getAsString()).append(" "); + } + if (root.has("ansible_distribution_version")) { + sb.append(root.get("ansible_distribution_version").getAsString()).append(" "); + } + + if (sb.length() > 0) { + node.setDescription(sb.toString().trim()); + } + } + + // ansible_system = Linux = osFamily in Rundeck + // ansible_os_family = Debian = osName in Rundeck + + if (root.has("ansible_system")) { + node.setOsFamily(root.get("ansible_system").getAsString()); + } + + if (root.has("ansible_os_family")) { + node.setOsName(root.get("ansible_os_family").getAsString()); + } + + if (root.has("ansible_architecture")) { + node.setOsArch(root.get("ansible_architecture").getAsString()); + } + + if (root.has("ansible_kernel")) { + node.setOsVersion(root.get("ansible_kernel").getAsString()); + } + + // JSON-Path -> Attribute-Name + Map interestingItems = new HashMap<>(); + + interestingItems.put("ansible_form_factor", "form_factor"); + + interestingItems.put("ansible_system_vendor", "system_vendor"); + + interestingItems.put("ansible_product_name", "product_name"); + interestingItems.put("ansible_product_version", "product_version"); + interestingItems.put("ansible_product_serial", "product_serial"); + + interestingItems.put("ansible_bios_version", "bios_version"); + interestingItems.put("ansible_bios_date", "bios_date"); + + interestingItems.put("ansible_machine_id", "machine_id"); + + interestingItems.put("ansible_virtualization_type", "virtualization_type"); + interestingItems.put("ansible_virtualization_role", "virtualization_role"); + + interestingItems.put("ansible_selinux", "selinux"); + interestingItems.put("ansible_fips", "fips"); + + interestingItems.put("ansible_service_mgr", "service_mgr"); + interestingItems.put("ansible_pkg_mgr", "pkg_mgr"); + + interestingItems.put("ansible_distribution", "distribution"); + interestingItems.put("ansible_distribution_version", "distribution_version"); + interestingItems.put("ansible_distribution_major_version", "distribution_major_version"); + interestingItems.put("ansible_distribution_release", "distribution_release"); + interestingItems.put("ansible_lsb.codename", "lsb_codename"); + + interestingItems.put("ansible_domain", "domain"); + + interestingItems.put("ansible_date_time.tz", "tz"); + interestingItems.put("ansible_date_time.tz_offset", "tz_offset"); + + interestingItems.put("ansible_processor_count", "processor_count"); + interestingItems.put("ansible_processor_cores", "processor_cores"); + interestingItems.put("ansible_processor_vcpus", "processor_vcpus"); + interestingItems.put("ansible_processor_threads_per_core", "processor_threads_per_core"); + + interestingItems.put("ansible_userspace_architecture", "userspace_architecture"); + interestingItems.put("ansible_userspace_bits", "userspace_bits"); + + interestingItems.put("ansible_memtotal_mb", "memtotal_mb"); + interestingItems.put("ansible_swaptotal_mb", "swaptotal_mb"); + interestingItems.put("ansible_processor.0", "processor0"); + interestingItems.put("ansible_processor.1", "processor1"); + + for (Map.Entry item : interestingItems.entrySet()) { + String[] itemParts = item.getKey().split("\\."); + + if (itemParts.length > 1) { + JsonElement ele = root; + for (String itemPart : itemParts) { + if (ele.isJsonArray() && itemPart.matches("^\\d+$") && ele.getAsJsonArray().size() > Integer.parseInt(itemPart)) { + ele = ele.getAsJsonArray().get(Integer.parseInt(itemPart)); + } else if (ele.isJsonObject() && ele.getAsJsonObject().has(itemPart)) { + ele = ele.getAsJsonObject().get(itemPart); + } else { + ele = null; + break; + } + } + + if (ele != null && ele.getAsString().length() > 0) { + node.setAttribute(item.getValue(), ele.getAsString()); + } + } else { + if (root.get(item.getKey()).getAsString().length() > 0) { + node.setAttribute(item.getValue(), root.get(item.getKey()).getAsString()); + } + } + } + + nodes.putNode(node); + } + } catch (IOException e) { + throw new ResourceModelSourceException("Error reading facts.", e); + } + + try { + Files.walkFileTree(tempDirectory, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.delete(dir); + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + throw new ResourceModelSourceException("Error deleting temporary directory.", e); + } + + return nodes; + } +} diff --git a/src/main/java/com/batix/rundeck/AnsibleResourceModelSourceFactory.java b/src/main/java/com/batix/rundeck/AnsibleResourceModelSourceFactory.java new file mode 100644 index 00000000..bea43091 --- /dev/null +++ b/src/main/java/com/batix/rundeck/AnsibleResourceModelSourceFactory.java @@ -0,0 +1,46 @@ +package com.batix.rundeck; + +import com.dtolabs.rundeck.core.plugins.Plugin; +import com.dtolabs.rundeck.core.plugins.configuration.ConfigurationException; +import com.dtolabs.rundeck.core.plugins.configuration.Describable; +import com.dtolabs.rundeck.core.plugins.configuration.Description; +import com.dtolabs.rundeck.core.plugins.configuration.PropertyUtil; +import com.dtolabs.rundeck.core.resources.ResourceModelSource; +import com.dtolabs.rundeck.core.resources.ResourceModelSourceFactory; +import com.dtolabs.rundeck.plugins.ServiceNameConstants; +import com.dtolabs.rundeck.plugins.util.DescriptionBuilder; + +import java.util.Properties; + +@Plugin(name = AnsibleResourceModelSourceFactory.SERVICE_PROVIDER_NAME, service = ServiceNameConstants.ResourceModelSource) +public class AnsibleResourceModelSourceFactory implements ResourceModelSourceFactory, Describable { + public static final String SERVICE_PROVIDER_NAME = "com.batix.rundeck.AnsibleResourceModelSourceFactory"; + + @Override + public ResourceModelSource createResourceModelSource(Properties configuration) throws ConfigurationException { + return new AnsibleResourceModelSource(configuration); + } + + @Override + public Description getDescription() { + return DescriptionBuilder.builder() + .name(SERVICE_PROVIDER_NAME) + .title("Ansible Resource Model Source") + .description("Imports nodes from Ansible's inventory.") + .property(PropertyUtil.bool( + "gatherFacts", + "Gather Facts", + "Gather fresh facts before importing? (recommended)", + true, + "true" + )) + .property(PropertyUtil.string( + "limit", + "Limit Targets", + "Select only specified hosts/groups from the Ansible inventory. See http://docs.ansible.com/ansible/intro_patterns.html for syntax help.", + false, + "" + )) + .build(); + } +} diff --git a/src/main/java/com/batix/rundeck/AnsibleRunner.java b/src/main/java/com/batix/rundeck/AnsibleRunner.java new file mode 100644 index 00000000..59ffc06f --- /dev/null +++ b/src/main/java/com/batix/rundeck/AnsibleRunner.java @@ -0,0 +1,243 @@ +package com.batix.rundeck; + +import com.dtolabs.rundeck.core.common.INodeSet; +import com.dtolabs.utils.Streams; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +class AnsibleRunner { + enum AnsibleCommand { + AdHoc("ansible"), + Playbook("ansible-playbook"); + + final String command; + AnsibleCommand(String command) { + this.command = command; + } + } + + public static AnsibleRunner adHoc(String module, String... args) { + AnsibleRunner ar = new AnsibleRunner(AnsibleCommand.AdHoc); + ar.module = module; + ar.args = args; + return ar; + } + + public static AnsibleRunner playbook(String playbook) { + AnsibleRunner ar = new AnsibleRunner(AnsibleCommand.Playbook); + ar.playbook = playbook; + return ar; + } + + private static final Pattern pHostPlaybook = Pattern.compile( + "^(.+?): \\[(.+?)\\] => (\\{.+?\\})$" + ); + private static final Pattern pTask = Pattern.compile( + "^TASK \\[(.+?)\\].*$" + ); + + private boolean done = false; + private String output; + private final List results = new ArrayList<>(); + + private final AnsibleCommand type; + private String module; + private String[] args; + private String playbook; + private final List limits = new ArrayList<>(); + private int result; + + private AnsibleRunner(AnsibleCommand type) { + this.type = type; + } + + public AnsibleRunner limit(String host) { + limits.add(host); + return this; + } + + public AnsibleRunner limit(INodeSet nodes) { + limits.addAll(nodes.getNodeNames()); + return this; + } + + public int run() throws Exception { + if (done) { + throw new IllegalStateException("already done"); + } + done = true; + + Path tempDirectory = null; + File tempFile = null; + + List procArgs = new ArrayList<>(); + procArgs.add(type.command); + + if (type == AnsibleCommand.AdHoc) { + procArgs.add("all"); + + procArgs.add("-m"); + procArgs.add(module); + + // remove null args + List newArgs = new ArrayList<>(); + for (String arg : args) { + if (arg != null) { + newArgs.add(arg); + } + } + + if (newArgs.size() > 0) { + procArgs.add("-a"); + procArgs.addAll(newArgs); + } + + tempDirectory = Files.createTempDirectory("ansible-hosts"); + procArgs.add("-t"); + procArgs.add(tempDirectory.toFile().getAbsolutePath()); + } else if (type == AnsibleCommand.Playbook) { + procArgs.add(playbook); + + procArgs.add("-v"); // to get JSON output, one line per host and task + } + + if (limits.size() == 1) { + procArgs.add("-l"); + procArgs.add(limits.get(0)); + } else if (limits.size() > 1) { + tempFile = File.createTempFile("ansible-runner", "targets"); + StringBuilder sb = new StringBuilder(); + for (String limit : limits) { + sb.append(limit).append("\n"); + } + Files.write(tempFile.toPath(), sb.toString().getBytes()); + + procArgs.add("-l"); + procArgs.add("@" + tempFile.getAbsolutePath()); + } + + Process proc = new ProcessBuilder() + .command(procArgs) + .redirectErrorStream(true) + .start(); + proc.waitFor(); + result = proc.exitValue(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Streams.copyStream(proc.getInputStream(), baos); + output = new String(baos.toByteArray()); + + if (type == AnsibleCommand.AdHoc) { + results.add(parseTreeDir(tempDirectory)); + } else { + parseOutput(); + } + + if (tempFile != null && !tempFile.delete()) { + tempFile.deleteOnExit(); + } + if (tempDirectory != null) { + Files.walkFileTree(tempDirectory, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.delete(dir); + return FileVisitResult.CONTINUE; + } + }); + } + + return result; + } + + private AnsibleTask parseTreeDir(Path dir) throws IOException { + final AnsibleTask task = new AnsibleTask(); + + Files.walkFileTree(dir, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + AnsibleTaskResult result = new AnsibleTaskResult(); + task.results.add(result); + + result.host = file.toFile().getName(); + result.json = new JsonParser().parse(new FileReader(file.toFile())).getAsJsonObject(); + + return FileVisitResult.CONTINUE; + } + }); + + return task; + } + + private void parseOutput() { + if (type == AnsibleCommand.Playbook) { + AnsibleTask curTask = null; + + for (String line : output.split("\\r?\\n")) { + line = line.trim(); + + Matcher mTask = pTask.matcher(line); + if (mTask.find()) { + curTask = new AnsibleTask(); + results.add(curTask); + curTask.name = mTask.group(1); + continue; + } + + if (curTask == null) continue; + + Matcher mHost = pHostPlaybook.matcher(line); + if (mHost.find()) { + AnsibleTaskResult taskResult = new AnsibleTaskResult(); + curTask.results.add(taskResult); + + taskResult.result = mHost.group(1); + taskResult.host = mHost.group(2); + String jsonStr = mHost.group(3); + taskResult.json = new JsonParser().parse(jsonStr).getAsJsonObject(); + } + } + } + } + + public int getResult() { + return result; + } + + public String getOutput() { + return output; + } + + public List getResults() { + return Collections.unmodifiableList(results); + } + + public static class AnsibleTask { + String name; + final List results = new ArrayList<>(); + } + + public static class AnsibleTaskResult { + String host; + String result; + JsonObject json; + } +} diff --git a/src/main/resources/gather-hosts.yml b/src/main/resources/gather-hosts.yml new file mode 100644 index 00000000..0ee9c2ab --- /dev/null +++ b/src/main/resources/gather-hosts.yml @@ -0,0 +1,9 @@ +--- +- hosts: all + vars: + facts: True + gather_facts: "{{facts}}" + tasks: + - local_action: file path=data state=directory + run_once: yes + - local_action: template src=host-tpl.j2 dest="data/{{inventory_hostname}}" diff --git a/src/main/resources/host-tpl.j2 b/src/main/resources/host-tpl.j2 new file mode 100644 index 00000000..e2848f81 --- /dev/null +++ b/src/main/resources/host-tpl.j2 @@ -0,0 +1 @@ +{{ hostvars[inventory_hostname] | to_json | safe }}