diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4aadfe3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.iws +*.ipr +*.iml +build +out +.gradle +/.idea diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..4a4c1cf --- /dev/null +++ b/build.gradle @@ -0,0 +1,89 @@ +plugins { + id 'pl.allegro.tech.build.axion-release' version '1.3.2' +} + +group 'com.rundeck' + +ext.rundeckPluginVersion = '1.2' +ext.pluginClassNames='com.rundeck.plugin.GitResourceModelFactory' +ext.pluginName = 'Git Resource Model' +ext.pluginDescription = 'Writable Git Resource Model' + +//sourceCompatibility = 1.8 + +scmVersion { + tag { + prefix = '' + versionSeparator = '' + def origDeserialize=deserialize + //apend .0 to satisfy semver if the tag version is only X.Y + deserialize = { config, position, tagName -> + def orig = origDeserialize(config, position, tagName) + if (orig.split('\\.').length < 3) { + orig += ".0" + } + orig + } + } +} +project.version = scmVersion.version + +apply plugin: 'groovy' +apply plugin: 'java' + +repositories { + mavenCentral() +} + +configurations { + pluginLibs + + compile { + extendsFrom pluginLibs + } +} + + +dependencies { + compile 'org.codehaus.groovy:groovy-all:2.3.11' + testCompile group: 'junit', name: 'junit', version: '4.12' + + compile group: 'org.rundeck', name: 'rundeck-core', version: '2.10.1' + + pluginLibs( 'org.eclipse.jgit:org.eclipse.jgit:3.7.1.201504261725-r') { + exclude module: 'slf4j-api' + exclude module: 'jsch' + exclude module: 'commons-logging' + } + + testCompile "org.codehaus.groovy:groovy-all:2.3.7" + testCompile "org.spockframework:spock-core:0.7-groovy-2.0" + testCompile "cglib:cglib-nodep:2.2.2" + testCompile 'org.objenesis:objenesis:1.4' +} + + +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': project.version + attributes 'Rundeck-Plugin-Version': rundeckPluginVersion + attributes 'Rundeck-Plugin-Archive': 'true' + attributes 'Rundeck-Plugin-Libs': "${libList}" + } + dependsOn(copyToLib) +} + + +task wrapper(type: Wrapper) { + gradleVersion = '3.3' + distributionUrl = "https://services.gradle.org/distributions/gradle-$gradleVersion-all.zip" +} + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..7958dff Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b6b6210 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon Dec 18 16:14:59 CLST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-3.3-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4453cce --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# 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\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# 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 +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +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" -a "$nonstop" = "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"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # 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 + +# Escape application args +save ( ) { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e95643d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@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 + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@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= + +@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 Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_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=%* + +: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 0000000..1763cd1 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'git-resource-model' + diff --git a/settings.png b/settings.png new file mode 100644 index 0000000..d27ad8e Binary files /dev/null and b/settings.png differ diff --git a/src/main/groovy/com/rundeck/plugin/GitManager.groovy b/src/main/groovy/com/rundeck/plugin/GitManager.groovy new file mode 100644 index 0000000..d150cf7 --- /dev/null +++ b/src/main/groovy/com/rundeck/plugin/GitManager.groovy @@ -0,0 +1,215 @@ +package com.rundeck.plugin + +import com.rundeck.plugin.util.PluginSshSessionFactory +import org.apache.log4j.Logger +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.PullResult +import org.eclipse.jgit.api.TransportCommand +import org.eclipse.jgit.lib.Repository +import org.eclipse.jgit.storage.file.FileRepositoryBuilder +import org.eclipse.jgit.transport.RemoteRefUpdate +import org.eclipse.jgit.transport.URIish +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider +import org.eclipse.jgit.util.FileUtils + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths + +/** + * Created by luistoledo on 12/20/17. + */ +class GitManager { + + static Logger logger = Logger.getLogger(GitManager.class); + + public static final String REMOTE_NAME = "origin" + Git git + String branch + String fileName + Repository repo + String strictHostKeyChecking + String sshPrivateKeyPath + String gitPassword + String gitURL + + GitManager(Properties configuration) { + this.gitURL=configuration.getProperty(GitResourceModelFactory.GIT_URL) + this.branch = configuration.getProperty(GitResourceModelFactory.GIT_BRANCH) + this.fileName=configuration.getProperty(GitResourceModelFactory.GIT_FILE) + this.strictHostKeyChecking=configuration.getProperty(GitResourceModelFactory.GIT_HOSTKEY_CHECKING) + sshPrivateKeyPath=configuration.getProperty(GitResourceModelFactory.GIT_KEY_STORAGE) + gitPassword=configuration.getProperty(GitResourceModelFactory.GIT_PASSWORD_STORAGE) + + } + + Map getSshConfig() { + def config = [:] + + if (strictHostKeyChecking in ['yes', 'no']) { + config['StrictHostKeyChecking'] = strictHostKeyChecking + } + config + } + + protected void cloneOrCreate(File base, String url) throws Exception { + if (base.isDirectory() && new File(base, ".git").isDirectory()) { + def arepo = new FileRepositoryBuilder().setGitDir(new File(base, ".git")).setWorkTree(base).build() + def agit = new Git(arepo) + + def config = agit.getRepository().getConfig() + def found = config.getString("remote", REMOTE_NAME, "url") + + def needsClone=false; + + if (found != url) { + logger.debug("url differs, re-cloning ${found}!=${url}") + needsClone = true + }else if (agit.repository.getFullBranch() != "refs/heads/$branch") { + logger.debug("branch differs, re-cloning") + needsClone = true + } + + if(needsClone){ + removeWorkdir(base) + performClone(base, url) + return + } + + git = agit + repo = arepo + } else { + performClone(base, url) + } + } + + + private void removeWorkdir(File base) { + FileUtils.delete(base, FileUtils.RECURSIVE) + } + + + private void performClone(File base, String url) { + + def cloneCommand = Git.cloneRepository(). + setBranch(this.branch). + setRemote(REMOTE_NAME). + setDirectory(base). + setURI(url) + setupTransportAuthentication(sshConfig, cloneCommand, url) + try { + git = cloneCommand.call() + } catch (Exception e) { + logger.debug("Failed cloning the repository from ${url}: ${e.message}", e) + throw new Exception("Failed cloning the repository from ${url}: ${e.message}", e) + } + repo = git.getRepository() + } + + void setupTransportAuthentication( + Map sshConfig, + TransportCommand command, + String url = null) throws Exception{ + if (!url) { + url = command.repository.config.getString('remote', REMOTE_NAME, 'url') + } + if (!url) { + throw new NullPointerException("url for remote was not set") + } + + URIish u = new URIish(url); + logger.debug("transport url ${u}, scheme ${u.scheme}, user ${u.user}") + + if ((u.scheme == null || u.scheme == 'ssh') && u.user && sshPrivateKeyPath) { + logger.debug("using ssh private key path ${sshPrivateKeyPath}") + + Path path = Paths.get(sshPrivateKeyPath); + byte[] keyData = Files.readAllBytes(path); + def factory = new PluginSshSessionFactory(keyData) + factory.sshConfig = sshConfig + command.setTransportConfigCallback(factory) + } else if (u.user && gitPassword) { + logger.debug("using password") + + if (null != gitPassword && gitPassword.length() > 0) { + command.setCredentialsProvider(new UsernamePasswordCredentialsProvider(u.user, gitPassword)) + } + } + } + + PullResult gitPull(Git git1 = null) { + def pullCommand = (git1 ?: git).pull().setRemote(REMOTE_NAME).setRemoteBranchName(branch) + setupTransportAuthentication(sshConfig,pullCommand) + pullCommand.call() + } + + def gitCommitAndPush(){ + + ////PERFORM COMMIT + git.add() + .addFilepattern(this.fileName) + .call(); + + // and then commit the changes + //TODO: define a custom (or imput name) for the commit + git.commit() + .setMessage("Edit node from GUI") + .call(); + + println("Committed file " + this.fileName + " to repository at " + repo.getDirectory()); + + /// PERFORM PUSH + def pushb = git.push() + pushb.setRemote(REMOTE_NAME) + pushb.add(branch) + setupTransportAuthentication(sshConfig, pushb) + + def push + try { + push = pushb.call() + } catch (Exception e) { + logger.debug("Failed push to remote: ${e.message}", e) + throw new Exception("Failed push to remote: ${e.message}", e) + } + def sb = new StringBuilder() + def updates = (push*.remoteUpdates).flatten() + updates.each { + sb.append it.toString() + } + + String message="" + def failedUpdates = updates.findAll { it.status != RemoteRefUpdate.Status.OK } + if (failedUpdates) { + message = "Some updates failed: " + failedUpdates + } else { + message = "Remote push result: OK." + } + + logger.debug(message) + + } + + InputStream getFile(String localPath) { + + File base = new File(localPath) + + if(!base){ + base.mkdir() + } + + //start the new repo, if the repo is create nothing will be done + this.cloneOrCreate(base, gitURL) + + File file = new File(localPath+"/"+fileName) + + //always perform a pull + //TODO: check if it is needed check for the repo status + //and perform the pull when the last commit is different to the last commit on the local repo + this.gitPull() + + return file.newInputStream() + + } + + +} diff --git a/src/main/groovy/com/rundeck/plugin/GitResourceModel.groovy b/src/main/groovy/com/rundeck/plugin/GitResourceModel.groovy new file mode 100644 index 0000000..b660b5b --- /dev/null +++ b/src/main/groovy/com/rundeck/plugin/GitResourceModel.groovy @@ -0,0 +1,169 @@ +package com.rundeck.plugin + +import com.dtolabs.rundeck.core.common.Framework +import com.dtolabs.rundeck.core.common.INodeSet +import com.dtolabs.rundeck.core.resources.ResourceModelSource +import com.dtolabs.rundeck.core.resources.ResourceModelSourceException +import com.dtolabs.rundeck.core.resources.SourceType +import com.dtolabs.rundeck.core.resources.WriteableModelSource +import com.dtolabs.rundeck.core.resources.format.ResourceFormatParser +import com.dtolabs.rundeck.core.resources.format.ResourceFormatParserException +import com.dtolabs.rundeck.core.resources.format.UnsupportedFormatException +import com.dtolabs.utils.Streams +import org.apache.log4j.Logger + + +/** + * Created by luistoledo on 12/18/17. + */ +class GitResourceModel implements ResourceModelSource , WriteableModelSource{ + static Logger logger = Logger.getLogger(GitResourceModel.class); + + private Properties configuration; + private Framework framework; + private boolean writable=false; + + String extension + String fileName + String localPath + + GitManager gitManager + + void setWritable(){ + this.writable=true; + } + + + GitResourceModel(Properties configuration, Framework framework) { + this.configuration = configuration + this.framework = framework + + this.extension=configuration.getProperty(GitResourceModelFactory.GIT_FORMAT_FILE) + this.writable=Boolean.valueOf(configuration.getProperty(GitResourceModelFactory.WRITABLE)) + this.fileName=configuration.getProperty(GitResourceModelFactory.GIT_FILE) + this.localPath=configuration.getProperty(GitResourceModelFactory.GIT_BASE_DIRECTORY) + + if(gitManager==null){ + gitManager = new GitManager(configuration) + } + + } + + @Override + INodeSet getNodes() throws ResourceModelSourceException { + + InputStream remoteFile = gitManager.getFile(this.localPath); + + final ResourceFormatParser parser; + try { + parser = getResourceFormatParser(); + } catch (UnsupportedFormatException e) { + throw new ResourceModelSourceException( + "Response content type is not supported: " + extension, e); + } + try { + return parser.parseDocument(remoteFile); + } catch (ResourceFormatParserException e) { + throw new ResourceModelSourceException( + "Error requesting Resource Model Source from S3, " + + "Content could not be parsed: "+e.getMessage(),e); + } + + + return null + } + + private ResourceFormatParser getResourceFormatParser() throws UnsupportedFormatException { + return framework.getResourceFormatParserService().getParserForMIMEType(getMimeType()); + } + + private String getMimeType(){ + if(extension.equalsIgnoreCase("yaml")){ + return "text/yaml"; + } + if(extension.equalsIgnoreCase("json")){ + return "application/json"; + } + return "application/xml"; + } + + @Override + public SourceType getSourceType() { + return writable ? SourceType.READ_WRITE : SourceType.READ_ONLY; + } + + @Override + public WriteableModelSource getWriteable() { + return writable ? this : null; + } + + + @Override + public String getSyntaxMimeType() { + try { + return getResourceFormatParser().getPreferredMimeType(); + } catch (UnsupportedFormatException e) { + e.printStackTrace() + } + return null + } + + @Override + long readData(OutputStream sink) throws IOException, ResourceModelSourceException { + if (!hasData()) { + return 0; + } + + InputStream inputStream = gitManager.getFile(this.localPath) + + return Streams.copyStream(inputStream, sink) + } + + @Override + boolean hasData() { + try{ + gitManager.getFile(this.localPath); + }catch (Exception e){ + return false; + } + return true; + } + + @Override + public long writeData(InputStream data) throws IOException, ResourceModelSourceException { + if (!writable) { + throw new IllegalArgumentException("Cannot write to file, it is not configured to be writeable"); + } + File newFile = isToFile(data); + try { + getResourceFormatParser().parseDocument(newFile); + } catch (ResourceFormatParserException e) { + throw new ResourceModelSourceException(e); + } + + gitManager.gitCommitAndPush() + + return newFile.length(); + } + + + + private File isToFile(InputStream is) throws IOException, ResourceModelSourceException { + try { + File newFile = new File(localPath+"/"+fileName) + + FileOutputStream fos = new FileOutputStream(newFile) + Streams.copyStream(is, fos); + return newFile; + } catch (UnsupportedFormatException e) { + throw new ResourceModelSourceException( + "Response content type is not supported: " + extension, e); + } + } + + @Override + public String getSourceDescription() { + String gitURL=configuration.getProperty(GitResourceModelFactory.GIT_URL) + return "Git repo: "+gitURL+", file:"+this.fileName; + } +} diff --git a/src/main/groovy/com/rundeck/plugin/GitResourceModelFactory.groovy b/src/main/groovy/com/rundeck/plugin/GitResourceModelFactory.groovy new file mode 100644 index 0000000..3d40811 --- /dev/null +++ b/src/main/groovy/com/rundeck/plugin/GitResourceModelFactory.groovy @@ -0,0 +1,98 @@ +package com.rundeck.plugin + +import com.dtolabs.rundeck.core.common.Framework +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.descriptions.PluginDescription +import com.dtolabs.rundeck.plugins.util.DescriptionBuilder +import com.rundeck.plugin.util.GitPluginUtil + +/** + * Created by luistoledo on 12/18/17. + */ +@Plugin(name = GitResourceModelFactory.PROVIDER_NAME, service = ServiceNameConstants.ResourceModelSource) +@PluginDescription(title = GitResourceModelFactory.PROVIDER_TITLE, description = GitResourceModelFactory.PROVIDER_DESCRIPTION) +class GitResourceModelFactory implements ResourceModelSourceFactory,Describable { + + private Framework framework; + + public static final String PROVIDER_NAME = "git-resource-model"; + public static final String PROVIDER_TITLE = "Git / Resource Model" + public static final String PROVIDER_DESCRIPTION ="Writable Resource Model on a Git repository" + + public static final List LIST_HOSTKEY_CHECKING =['yes', 'no'] + public static final List LIST_FILE_TYPE =['xml', 'yaml','json'] + + public final static String GIT_URL="gitUrl" + public final static String GIT_BASE_DIRECTORY="gitBaseDirectory" + public final static String GIT_FILE="gitFile" + public final static String GIT_FORMAT_FILE="gitFormatFile" + public final static String GIT_BRANCH="gitBranch" + public final static String GIT_HOSTKEY_CHECKING="strictHostKeyChecking" + public final static String GIT_KEY_STORAGE="gitKeyPath" + public final static String GIT_PASSWORD_STORAGE="gitPasswordPath" + public static final String WRITABLE="writable"; + + + final static Map renderingOptionsAuthentication = GitPluginUtil.getRenderOpt("Authentication",false) + final static Map renderingOptionsAuthenticationPassword = GitPluginUtil.getRenderOpt("Authentication",false, true) + final static Map renderingOptionsConfig = GitPluginUtil.getRenderOpt("Configuration",false) + + GitResourceModelFactory(Framework framework) { + this.framework = framework + } + + static Description DESCRIPTION = DescriptionBuilder.builder() + .name(PROVIDER_NAME) + .title(PROVIDER_TITLE) + .description(PROVIDER_DESCRIPTION) + .property(PropertyUtil.string(GIT_BASE_DIRECTORY, "Base Directory", "Directory for checkout.", true, + null,null,null, renderingOptionsConfig)) + .property(PropertyUtil.string(GIT_URL, "Git URL", '''Checkout url. +See [git-clone](https://www.kernel.org/pub/software/scm/git/docs/git-clone.html) +specifically the [GIT URLS](https://www.kernel.org/pub/software/scm/git/docs/git-clone.html#URLS) section. +Some examples: +* `ssh://[user@]host.xz[:port]/path/to/repo.git/` +* `git://host.xz[:port]/path/to/repo.git/` +* `http[s]://host.xz[:port]/path/to/repo.git/` +* `ftp[s]://host.xz[:port]/path/to/repo.git/` +* `rsync://host.xz/path/to/repo.git/`''', true, + null,null,null, renderingOptionsConfig)) + .property(PropertyUtil.string(GIT_BRANCH, "Branch", "Checkout branch.", true, + "master",null,null, renderingOptionsConfig)) + .property(PropertyUtil.string(GIT_FILE, "Resource model File", "Resource model file inside the github repo.", true, + null,null,null, renderingOptionsConfig)) + .property(PropertyUtil.select(GIT_FORMAT_FILE, "File Format", 'File Format', true, + "xml",GitResourceModelFactory.LIST_FILE_TYPE,null, renderingOptionsConfig)) + .property(PropertyUtil.bool(WRITABLE, "Writable", + "Allow to write the remote file.", + false,"false",null,renderingOptionsConfig)) + .property(PropertyUtil.string(GIT_PASSWORD_STORAGE, "Git Password", 'Password to authenticate remotely', false, + null,null,null, renderingOptionsAuthenticationPassword)) + .property(PropertyUtil.select(GIT_HOSTKEY_CHECKING, "SSH: Strict Host Key Checking", '''Use strict host key checking. +If `yes`, require remote host SSH key is defined in the `~/.ssh/known_hosts` file, otherwise do not verify.''', false, + "yes",GitResourceModelFactory.LIST_HOSTKEY_CHECKING,null, renderingOptionsAuthentication)) + .property(PropertyUtil.string(GIT_KEY_STORAGE, "SSH Key Path", 'SSH Key Path', false, + null,null,null, renderingOptionsAuthentication)) + .build() + + + + @Override + Description getDescription() { + return DESCRIPTION + } + + @Override + ResourceModelSource createResourceModelSource(Properties configuration) throws ConfigurationException { + final GitResourceModel resource = new GitResourceModel(configuration,framework) + + return resource + } +} diff --git a/src/main/groovy/com/rundeck/plugin/util/GitPluginUtil.groovy b/src/main/groovy/com/rundeck/plugin/util/GitPluginUtil.groovy new file mode 100644 index 0000000..970e22d --- /dev/null +++ b/src/main/groovy/com/rundeck/plugin/util/GitPluginUtil.groovy @@ -0,0 +1,29 @@ +package com.rundeck.plugin.util + +import com.dtolabs.rundeck.core.plugins.configuration.StringRenderingConstants +import com.dtolabs.rundeck.core.storage.ResourceMeta +import com.dtolabs.rundeck.plugins.step.PluginStepContext + +/** + * Created by luistoledo on 12/18/17. + */ +class GitPluginUtil { + static Map getRenderOpt(String value, boolean secondary, boolean password = false, boolean storagePassword = false) { + Map ret = new HashMap<>(); + ret.put(StringRenderingConstants.GROUP_NAME,value); + if(secondary){ + ret.put(StringRenderingConstants.GROUPING,"secondary"); + } + if(password){ + ret.put("displayType",StringRenderingConstants.DisplayType.PASSWORD) + } + if(storagePassword){ + ret.put(StringRenderingConstants.SELECTION_ACCESSOR_KEY,StringRenderingConstants.SelectionAccessor.STORAGE_PATH) + ret.put(StringRenderingConstants.STORAGE_PATH_ROOT_KEY,"keys") + ret.put(StringRenderingConstants.STORAGE_FILE_META_FILTER_KEY, "Rundeck-data-type=password") + } + + return ret; + } + +} diff --git a/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy b/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy new file mode 100644 index 0000000..e2c87fb --- /dev/null +++ b/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy @@ -0,0 +1,62 @@ +package com.rundeck.plugin.util + +import com.jcraft.jsch.JSch +import com.jcraft.jsch.JSchException +import com.jcraft.jsch.Session +import org.eclipse.jgit.api.TransportConfigCallback +import org.eclipse.jgit.transport.JschConfigSessionFactory +import org.eclipse.jgit.transport.OpenSshConfig +import org.eclipse.jgit.transport.SshTransport +import org.eclipse.jgit.transport.Transport +import org.eclipse.jgit.util.FS + +/** + * Created by luistoledo on 12/20/17. + */ +class PluginSshSessionFactory extends JschConfigSessionFactory implements TransportConfigCallback { + private byte[] privateKey + Map sshConfig + + PluginSshSessionFactory(final byte[] privateKey) { + this.privateKey = privateKey + } + + @Override + protected void configure(final OpenSshConfig.Host hc, final Session session) { + if (sshConfig) { + sshConfig.each { k, v -> + session.setConfig(k, v) + } + } + } + + @Override + protected JSch createDefaultJSch(final FS fs) throws JSchException { + JSch jsch = super.createDefaultJSch(fs) + jsch.removeAllIdentity() + jsch.addIdentity("private", privateKey, null, null) + //todo: explicitly set known host keys? + return jsch + } + + @Override + protected Session createSession( + final OpenSshConfig.Host hc, + final String user, + final String host, + final int port, + final FS fs + ) throws JSchException + { + return super.createSession(hc, user, host, port, fs) + } + + @Override + void configure(final Transport transport) { + if (transport instanceof SshTransport) { + SshTransport sshTransport = (SshTransport) transport + sshTransport.setSshSessionFactory(this) + } + } +} + diff --git a/src/test/groovy/com/rundeck/plugin/GitResourceModelSpec.groovy b/src/test/groovy/com/rundeck/plugin/GitResourceModelSpec.groovy new file mode 100644 index 0000000..ca9acbb --- /dev/null +++ b/src/test/groovy/com/rundeck/plugin/GitResourceModelSpec.groovy @@ -0,0 +1,177 @@ +package com.rundeck.plugin + +import com.dtolabs.rundeck.core.common.Framework +import com.dtolabs.rundeck.core.common.INodeSet +import com.dtolabs.rundeck.core.resources.format.ResourceFormatParser +import com.dtolabs.rundeck.core.resources.format.ResourceFormatParserService +import spock.lang.Specification +import org.apache.log4j.Logger + +/** + * Created by luistoledo on 12/22/17. + */ +class GitResourceModelSpec extends Specification{ + + def "retrieve resource success"() { + given: + + def nodeSet = Mock(INodeSet) + def framework = getFramework(nodeSet) + + File folder = new File(path) + if(!folder.exists()){ + folder.mkdir() + } + + String localPath= path + "/" + fileName + + Properties configuration = [gitBaseDirectory:path,gitFormatFile:format,gitFile:fileName] + + def gitManager = Mock(GitManager) + def resource = new GitResourceModel(configuration,framework) + resource.setGitManager(gitManager) + + def inputStream = GroovyMock(InputStream) + + when: + def result = resource.getNodes() + + then: + 1 * gitManager.getFile(path) >> inputStream + result == nodeSet + + where: + path | fileName | format + 'resources' |'resources.xml' | 'xml' + 'resources' |'resources.yaml' | 'yaml' + 'resources' |'resources.json' | 'json' + + + } + + + def "catch UnsupportedFormatException"(){ + given: + + def nodeSet = Mock(INodeSet) + def framework = getFramework(nodeSet) + + File folder = new File(path) + if(!folder.exists()){ + folder.mkdir() + } + String localPath= path + "/" + fileName + + def file = new File(localPath) + file.withWriter('UTF-8') { writer -> + writer.write('Somo wrong format.') + } + + file.deleteOnExit() + + Properties configuration = [gitBaseDirectory:path,gitFormatFile:format,gitFile:fileName] + + def gitManager = Mock(GitManager) + def resource = new GitResourceModel(configuration,framework) + resource.setGitManager(gitManager) + + + def inputStream = file.newDataInputStream() + + when: + def result = resource.getNodes() + + then: + 1 * gitManager.getFile(path) >> inputStream + result == nodeSet + + where: + path | fileName | format + 'resources' |'resources.xml' | 'xml' + 'resources' |'resources.yaml' | 'yaml' + 'resources' |'resources.json' | 'json' + + + } + + + def "write data remote resource"(){ + given: + + def nodeSet = Mock(INodeSet) + def framework = getFramework(nodeSet) + + File folder = new File(path) + if(!folder.exists()){ + folder.mkdir() + } + + def is = new ByteArrayInputStream("a".getBytes()) + Properties configuration = [gitBaseDirectory:path,gitFormatFile:format,gitFile:fileName] + + def gitManager = Mock(GitManager) + def resource = new GitResourceModel(configuration,framework) + resource.setGitManager(gitManager) + + resource.setWritable() + + when: + def result = resource.writeData(is) + + then: + 1 * gitManager.gitCommitAndPush() + result == 1 + + where: + path | fileName | format + 'resources' |'resources.xml' | 'xml' + 'resources' |'resources.yaml' | 'yaml' + 'resources' |'resources.json' | 'json' + } + + def "has data"(){ + def nodeSet = Mock(INodeSet) + def framework = getFramework(nodeSet) + + File folder = new File(path) + if(!folder.exists()){ + folder.mkdir() + } + + def is = new ByteArrayInputStream("a".getBytes()) + Properties configuration = [gitBaseDirectory:path,gitFormatFile:format,gitFile:fileName] + + def gitManager = Mock(GitManager) + def resource = new GitResourceModel(configuration,framework) + resource.setGitManager(gitManager) + + def inputStream = GroovyMock(InputStream) + + when: + def result = resource.hasData() + + then: + 1 * gitManager.getFile(path) >> inputStream + result + + where: + path | fileName | format + 'resources' |'resources.xml' | 'xml' + 'resources' |'resources.yaml' | 'yaml' + 'resources' |'resources.json' | 'json' + } + + + private Framework getFramework(INodeSet nodeSet){ + def resourceFormatParser = Mock(ResourceFormatParser){ + parseDocument(_) >> nodeSet + } + def resourceFormatParserService = Mock(ResourceFormatParserService){ + getParserForMIMEType(_) >> resourceFormatParser + } + def framework = Mock(Framework){ + getResourceFormatParserService()>> resourceFormatParserService + } + return framework + } +}