From 2c3d419490b1cf6d16531fdcfc2d1d60165ebbc4 Mon Sep 17 00:00:00 2001 From: Elio Espinel <48806282+elioe@users.noreply.github.com> Date: Wed, 20 May 2026 15:15:07 -0400 Subject: [PATCH] feat: add Rundeck Runner support and repository-named subdirectories - Implement ProxyRunnerPlugin and ProxySecretBundleCreator for secure distributed execution. - Add gitUseRepoNameSubdirectory option to clone under base directory using the git repo name. - Clean up temporary SSH keys on factory close and fix hard reset behavior in GitManager. - Add Spock unit tests for secret bundling and repo name extraction. --- .../plugin/GitCloneWorkflowStep.groovy | 26 ++- .../plugin/GitCommitWorkflowStep.groovy | 26 ++- .../com/rundeck/plugin/GitManager.groovy | 35 +++- .../rundeck/plugin/GitPushWorkflowStep.groovy | 26 ++- .../rundeck/plugin/util/GitPluginUtil.groovy | 79 +++++++++ .../util/PluginSshSessionFactory.groovy | 33 +++- .../WorkflowStepSecretBundleSpec.groovy | 94 ++++++++++ .../GitPluginUtilExtractRepoNameSpec.groovy | 28 +++ .../util/GitPluginUtilSecretBundleSpec.groovy | 167 ++++++++++++++++++ .../util/PluginSshSessionFactorySpec.groovy | 75 +++++++- 10 files changed, 571 insertions(+), 18 deletions(-) create mode 100644 src/test/groovy/com/rundeck/plugin/WorkflowStepSecretBundleSpec.groovy create mode 100644 src/test/groovy/com/rundeck/plugin/util/GitPluginUtilExtractRepoNameSpec.groovy create mode 100644 src/test/groovy/com/rundeck/plugin/util/GitPluginUtilSecretBundleSpec.groovy diff --git a/src/main/groovy/com/rundeck/plugin/GitCloneWorkflowStep.groovy b/src/main/groovy/com/rundeck/plugin/GitCloneWorkflowStep.groovy index d33045a..4232899 100644 --- a/src/main/groovy/com/rundeck/plugin/GitCloneWorkflowStep.groovy +++ b/src/main/groovy/com/rundeck/plugin/GitCloneWorkflowStep.groovy @@ -1,6 +1,10 @@ package com.rundeck.plugin import com.dtolabs.rundeck.core.execution.ExecutionListener +import com.dtolabs.rundeck.core.execution.ExecutionContext +import com.dtolabs.rundeck.core.execution.proxy.ProxyRunnerPlugin +import com.dtolabs.rundeck.core.execution.proxy.ProxySecretBundleCreator +import com.dtolabs.rundeck.core.execution.proxy.SecretBundle import com.dtolabs.rundeck.core.execution.workflow.steps.StepException import com.dtolabs.rundeck.core.plugins.Plugin import com.dtolabs.rundeck.core.plugins.configuration.Describable @@ -21,7 +25,7 @@ import groovy.json.JsonOutput @Plugin(name = GitCloneWorkflowStep.PROVIDER_NAME, service = ServiceNameConstants.WorkflowStep) @PluginDescription(title = GitCloneWorkflowStep.PROVIDER_TITLE, description = GitCloneWorkflowStep.PROVIDER_DESCRIPTION) -class GitCloneWorkflowStep implements StepPlugin, Describable{ +class GitCloneWorkflowStep implements StepPlugin, Describable, ProxyRunnerPlugin, ProxySecretBundleCreator{ public static final String PROVIDER_NAME = "git-clone-step" public static final String PROVIDER_TITLE = "Git / Clone" public static final String PROVIDER_DESCRIPTION ="Clone a Git repository on Rundeck server" @@ -34,6 +38,7 @@ class GitCloneWorkflowStep implements StepPlugin, Describable{ public final static String GIT_KEY_STORAGE="gitKeyPath" public final static String GIT_PASSWORD_STORAGE="gitPasswordPath" public final static String GIT_PROJECT_BASED_SUBDIRECTORY="gitUseProjectBasedSubdirectory" + public final static String GIT_REPO_NAME_SUBDIRECTORY="gitUseRepoNameSubdirectory" final static Map renderingOptionsAuthentication = GitPluginUtil.getRenderOpt("Authentication", false) @@ -74,6 +79,8 @@ If `yes`, require remote host SSH key is defined in the `~/.ssh/known_hosts` fil null,null,null, renderingOptionsAuthenticationKey)) .property(PropertyUtil.bool(GIT_PROJECT_BASED_SUBDIRECTORY, "Use per-project subdirectories", "Check repositories out in project-based subdirectories of the Rundeck home directory.", false, "false", PropertyScope.Project, renderingOptionsConfig)) + .property(PropertyUtil.bool(GIT_REPO_NAME_SUBDIRECTORY, "Clone into repo name subdirectory", "Clone into a subdirectory named after the Git repository under the base directory.", + false, "false", null, renderingOptionsConfig)) .build() GitCloneWorkflowStep() { @@ -104,6 +111,13 @@ If `yes`, require remote host SSH key is defined in the `~/.ssh/known_hosts` fil localPath = configuration.get(GIT_BASE_DIRECTORY) } + if (Boolean.parseBoolean((String) configuration.get(GIT_REPO_NAME_SUBDIRECTORY))) { + String repoName = GitPluginUtil.extractRepoName((String) configuration.get(GIT_URL)) + if (repoName) { + localPath = new File(localPath, repoName).getPath() + } + } + if(configuration.get(GIT_PASSWORD_STORAGE)){ def password = GitPluginUtil.getFromKeyStorage(configuration.get(GIT_PASSWORD_STORAGE), context) gitManager.setGitPassword(password) @@ -146,4 +160,14 @@ If `yes`, require remote host SSH key is defined in the `~/.ssh/known_hosts` fil } + + @Override + List listSecretsPathWorkflowStep(ExecutionContext context, Map configuration) { + return GitPluginUtil.listSecretsPathForStep(context, configuration, GIT_PASSWORD_STORAGE, GIT_KEY_STORAGE) + } + + @Override + SecretBundle prepareSecretBundleWorkflowStep(ExecutionContext context, Map configuration) { + return GitPluginUtil.prepareSecretBundleForStep(context, configuration, GIT_PASSWORD_STORAGE, GIT_KEY_STORAGE) + } } diff --git a/src/main/groovy/com/rundeck/plugin/GitCommitWorkflowStep.groovy b/src/main/groovy/com/rundeck/plugin/GitCommitWorkflowStep.groovy index 7add372..8690942 100644 --- a/src/main/groovy/com/rundeck/plugin/GitCommitWorkflowStep.groovy +++ b/src/main/groovy/com/rundeck/plugin/GitCommitWorkflowStep.groovy @@ -1,6 +1,10 @@ package com.rundeck.plugin import com.dtolabs.rundeck.core.execution.ExecutionListener +import com.dtolabs.rundeck.core.execution.ExecutionContext +import com.dtolabs.rundeck.core.execution.proxy.ProxyRunnerPlugin +import com.dtolabs.rundeck.core.execution.proxy.ProxySecretBundleCreator +import com.dtolabs.rundeck.core.execution.proxy.SecretBundle import com.dtolabs.rundeck.core.execution.workflow.steps.StepException import com.dtolabs.rundeck.core.plugins.Plugin import com.dtolabs.rundeck.core.plugins.configuration.Describable @@ -21,7 +25,7 @@ import groovy.json.JsonOutput @Plugin(name = GitCommitWorkflowStep.PROVIDER_NAME, service = ServiceNameConstants.WorkflowStep) @PluginDescription(title = GitCommitWorkflowStep.PROVIDER_TITLE, description = GitCommitWorkflowStep.PROVIDER_DESCRIPTION) -class GitCommitWorkflowStep implements StepPlugin, Describable{ +class GitCommitWorkflowStep implements StepPlugin, Describable, ProxyRunnerPlugin, ProxySecretBundleCreator{ public static final String PROVIDER_NAME = "git-commit-step" public static final String PROVIDER_TITLE = "Git / Commit" public static final String PROVIDER_DESCRIPTION ="Commit a Git repository on Rundeck server" @@ -36,6 +40,7 @@ class GitCommitWorkflowStep implements StepPlugin, Describable{ public final static String GIT_KEY_STORAGE="gitKeyPath" public final static String GIT_PASSWORD_STORAGE="gitPasswordPath" public final static String GIT_PROJECT_BASED_SUBDIRECTORY="gitUseProjectBasedSubdirectory" + public final static String GIT_REPO_NAME_SUBDIRECTORY="gitUseRepoNameSubdirectory" final static Map renderingOptionsAuthentication = GitPluginUtil.getRenderOpt("Authentication", false) @@ -80,6 +85,8 @@ If `yes`, require remote host SSH key is defined in the `~/.ssh/known_hosts` fil null,null,null, renderingOptionsAuthenticationKey)) .property(PropertyUtil.bool(GIT_PROJECT_BASED_SUBDIRECTORY, "Use per-project subdirectories", "Check repositories out in project-based subdirectories of the Rundeck home directory.", false, "false", PropertyScope.Project, renderingOptionsConfig)) + .property(PropertyUtil.bool(GIT_REPO_NAME_SUBDIRECTORY, "Clone into repo name subdirectory", "Use a subdirectory named after the Git repository under the base directory.", + false, "false", null, renderingOptionsConfig)) .build() GitCommitWorkflowStep() { @@ -110,6 +117,13 @@ If `yes`, require remote host SSH key is defined in the `~/.ssh/known_hosts` fil localPath = configuration.get(GIT_BASE_DIRECTORY) } + if (Boolean.parseBoolean((String) configuration.get(GIT_REPO_NAME_SUBDIRECTORY))) { + String repoName = GitPluginUtil.extractRepoName((String) configuration.get(GIT_URL)) + if (repoName) { + localPath = new File(localPath, repoName).getPath() + } + } + if(configuration.get(GIT_PASSWORD_STORAGE)){ def password = GitPluginUtil.getFromKeyStorage(configuration.get(GIT_PASSWORD_STORAGE), context) gitManager.setGitPassword(password) @@ -156,4 +170,14 @@ If `yes`, require remote host SSH key is defined in the `~/.ssh/known_hosts` fil } + + @Override + List listSecretsPathWorkflowStep(ExecutionContext context, Map configuration) { + return GitPluginUtil.listSecretsPathForStep(context, configuration, GIT_PASSWORD_STORAGE, GIT_KEY_STORAGE) + } + + @Override + SecretBundle prepareSecretBundleWorkflowStep(ExecutionContext context, Map configuration) { + return GitPluginUtil.prepareSecretBundleForStep(context, configuration, GIT_PASSWORD_STORAGE, GIT_KEY_STORAGE) + } } diff --git a/src/main/groovy/com/rundeck/plugin/GitManager.groovy b/src/main/groovy/com/rundeck/plugin/GitManager.groovy index 7043f84..ec93cda 100644 --- a/src/main/groovy/com/rundeck/plugin/GitManager.groovy +++ b/src/main/groovy/com/rundeck/plugin/GitManager.groovy @@ -3,6 +3,7 @@ package com.rundeck.plugin import com.rundeck.plugin.util.PluginSshSessionFactory import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.PullResult +import org.eclipse.jgit.api.ResetCommand import org.eclipse.jgit.transport.PushResult import org.eclipse.jgit.api.TransportCommand import org.eclipse.jgit.lib.Repository @@ -115,6 +116,8 @@ class GitManager { logger.debug("branch differs, re-cloning") needsClone = true } else { + agit.reset().setMode(ResetCommand.ResetType.HARD).call() + agit.clean().setCleanDirectories(true).setForce(true).call() performPull(agit) } @@ -175,14 +178,16 @@ class GitManager { setURI(this.gitURL). setCloneSubmodules(true) - + PluginSshSessionFactory sshFactory = null try { - setupTransportAuthentication(sshConfig, cloneCommand, this.gitURL) + sshFactory = setupTransportAuthentication(sshConfig, cloneCommand, this.gitURL) git = withPluginClassLoader { cloneCommand.call() } } catch (Exception e) { e.printStackTrace() logger.debug("Failed cloning the repository from ${this.gitURL}: ${e.message}", e) throw new Exception("Failed cloning the repository from ${this.gitURL}: ${e.message}", e) + } finally { + sshFactory?.close() } repo = git.getRepository() } @@ -193,8 +198,9 @@ class GitManager { def pullCommand = git.pull() .setRebase(true) + PluginSshSessionFactory sshFactory = null try { - setupTransportAuthentication(sshConfig, pullCommand, this.gitURL) + sshFactory = setupTransportAuthentication(sshConfig, pullCommand, this.gitURL) PullResult result = withPluginClassLoader { pullCommand.call() } if (!result.isSuccessful()) { logger.info("Pull is not successful.") @@ -205,6 +211,8 @@ class GitManager { e.printStackTrace() logger.debug("Failed pulling the repository from ${this.gitURL}: ${e.message}", e) throw new Exception("Failed pulling the repository from ${this.gitURL}: ${e.message}", e) + } finally { + sshFactory?.close() } repo = git.getRepository() } @@ -213,14 +221,17 @@ class GitManager { def pushCommand = git.push() .setPushAll() + PluginSshSessionFactory sshFactory = null try { - setupTransportAuthentication(sshConfig, pushCommand, this.gitURL) + sshFactory = setupTransportAuthentication(sshConfig, pushCommand, this.gitURL) withPluginClassLoader { pushCommand.call() } logger.info("Push is not successful.") } catch (Exception e) { e.printStackTrace() logger.debug("Failed pushing the repository to ${this.gitURL}: ${e.message}", e) throw new Exception("Failed pushing the repository to ${this.gitURL}: ${e.message}", e) + } finally { + sshFactory?.close() } } @@ -254,7 +265,7 @@ class GitManager { } } - void setupTransportAuthentication( + PluginSshSessionFactory setupTransportAuthentication( Map sshConfig, TransportCommand command, String url = null) throws Exception { @@ -283,6 +294,7 @@ class GitManager { def factory = new PluginSshSessionFactory(keyData) factory.sshConfig = sshConfig command.setTransportConfigCallback(factory) + return factory } else if (u.user && gitPassword) { logger.debug("using password") @@ -290,12 +302,17 @@ class GitManager { command.setCredentialsProvider(new UsernamePasswordCredentialsProvider(u.user, gitPassword)) } } + return null } PullResult gitPull(Git git1 = null) { def pullCommand = (git1 ?: git).pull().setRemote(REMOTE_NAME).setRemoteBranchName(branch) - setupTransportAuthentication(sshConfig, pullCommand) - withPluginClassLoader { pullCommand.call() } + PluginSshSessionFactory sshFactory = setupTransportAuthentication(sshConfig, pullCommand) + try { + return withPluginClassLoader { pullCommand.call() } + } finally { + sshFactory?.close() + } } def gitCommitAndPush() { @@ -317,7 +334,7 @@ class GitManager { def pushb = git.push() pushb.setRemote(REMOTE_NAME) pushb.add(branch) - setupTransportAuthentication(sshConfig, pushb) + PluginSshSessionFactory sshFactory = setupTransportAuthentication(sshConfig, pushb) def push try { @@ -325,6 +342,8 @@ class GitManager { } catch (Exception e) { logger.debug("Failed push to remote: ${e.message}", e) throw new Exception("Failed push to remote: ${e.message}", e) + } finally { + sshFactory?.close() } def sb = new StringBuilder() def updates = (push*.remoteUpdates).flatten() diff --git a/src/main/groovy/com/rundeck/plugin/GitPushWorkflowStep.groovy b/src/main/groovy/com/rundeck/plugin/GitPushWorkflowStep.groovy index 5a17402..709f98f 100644 --- a/src/main/groovy/com/rundeck/plugin/GitPushWorkflowStep.groovy +++ b/src/main/groovy/com/rundeck/plugin/GitPushWorkflowStep.groovy @@ -1,6 +1,10 @@ package com.rundeck.plugin import com.dtolabs.rundeck.core.execution.ExecutionListener +import com.dtolabs.rundeck.core.execution.ExecutionContext +import com.dtolabs.rundeck.core.execution.proxy.ProxyRunnerPlugin +import com.dtolabs.rundeck.core.execution.proxy.ProxySecretBundleCreator +import com.dtolabs.rundeck.core.execution.proxy.SecretBundle import com.dtolabs.rundeck.core.execution.workflow.steps.StepException import com.dtolabs.rundeck.core.plugins.Plugin import com.dtolabs.rundeck.core.plugins.configuration.Describable @@ -21,7 +25,7 @@ import groovy.json.JsonOutput @Plugin(name = GitPushWorkflowStep.PROVIDER_NAME, service = ServiceNameConstants.WorkflowStep) @PluginDescription(title = GitPushWorkflowStep.PROVIDER_TITLE, description = GitPushWorkflowStep.PROVIDER_DESCRIPTION) -class GitPushWorkflowStep implements StepPlugin, Describable{ +class GitPushWorkflowStep implements StepPlugin, Describable, ProxyRunnerPlugin, ProxySecretBundleCreator{ public static final String PROVIDER_NAME = "git-push-step" public static final String PROVIDER_TITLE = "Git / Push" public static final String PROVIDER_DESCRIPTION ="Push a Git repository on Rundeck server" @@ -33,6 +37,7 @@ class GitPushWorkflowStep implements StepPlugin, Describable{ public final static String GIT_KEY_STORAGE="gitKeyPath" public final static String GIT_PASSWORD_STORAGE="gitPasswordPath" public final static String GIT_PROJECT_BASED_SUBDIRECTORY="gitUseProjectBasedSubdirectory" + public final static String GIT_REPO_NAME_SUBDIRECTORY="gitUseRepoNameSubdirectory" final static Map renderingOptionsAuthentication = GitPluginUtil.getRenderOpt("Authentication", false) @@ -71,6 +76,8 @@ If `yes`, require remote host SSH key is defined in the `~/.ssh/known_hosts` fil null,null,null, renderingOptionsAuthenticationKey)) .property(PropertyUtil.bool(GIT_PROJECT_BASED_SUBDIRECTORY, "Use per-project subdirectories", "Check repositories out in project-based subdirectories of the Rundeck home directory.", false, "false", PropertyScope.Project, renderingOptionsConfig)) + .property(PropertyUtil.bool(GIT_REPO_NAME_SUBDIRECTORY, "Clone into repo name subdirectory", "Use a subdirectory named after the Git repository under the base directory.", + false, "false", null, renderingOptionsConfig)) .build() GitPushWorkflowStep() { @@ -101,6 +108,13 @@ If `yes`, require remote host SSH key is defined in the `~/.ssh/known_hosts` fil localPath = configuration.get(GIT_BASE_DIRECTORY) } + if (Boolean.parseBoolean((String) configuration.get(GIT_REPO_NAME_SUBDIRECTORY))) { + String repoName = GitPluginUtil.extractRepoName((String) configuration.get(GIT_URL)) + if (repoName) { + localPath = new File(localPath, repoName).getPath() + } + } + if(configuration.get(GIT_PASSWORD_STORAGE)){ def password = GitPluginUtil.getFromKeyStorage(configuration.get(GIT_PASSWORD_STORAGE), context) gitManager.setGitPassword(password) @@ -139,4 +153,14 @@ If `yes`, require remote host SSH key is defined in the `~/.ssh/known_hosts` fil } + + @Override + List listSecretsPathWorkflowStep(ExecutionContext context, Map configuration) { + return GitPluginUtil.listSecretsPathForStep(context, configuration, GIT_PASSWORD_STORAGE, GIT_KEY_STORAGE) + } + + @Override + SecretBundle prepareSecretBundleWorkflowStep(ExecutionContext context, Map configuration) { + return GitPluginUtil.prepareSecretBundleForStep(context, configuration, GIT_PASSWORD_STORAGE, GIT_KEY_STORAGE) + } } diff --git a/src/main/groovy/com/rundeck/plugin/util/GitPluginUtil.groovy b/src/main/groovy/com/rundeck/plugin/util/GitPluginUtil.groovy index f5d7775..3a20468 100644 --- a/src/main/groovy/com/rundeck/plugin/util/GitPluginUtil.groovy +++ b/src/main/groovy/com/rundeck/plugin/util/GitPluginUtil.groovy @@ -1,5 +1,8 @@ package com.rundeck.plugin.util +import com.dtolabs.rundeck.core.dispatcher.DataContextUtils +import com.dtolabs.rundeck.core.execution.proxy.DefaultSecretBundle +import com.dtolabs.rundeck.core.execution.proxy.SecretBundle import com.dtolabs.rundeck.core.plugins.configuration.StringRenderingConstants import com.dtolabs.rundeck.core.storage.ResourceMeta import com.dtolabs.rundeck.plugins.step.PluginStepContext @@ -97,4 +100,80 @@ class GitPluginUtil { return null } } + + /** + * Extracts the repository name from a Git URL by taking the last path segment + * and stripping the {@code .git} suffix if present. + *

Handles standard URL formats as well as SCP-style notation + * ({@code git@host:user/repo.git}).

+ * + * @param gitUrl the Git remote URL + * @return the repository name, or {@code null} if it cannot be determined + */ + static String extractRepoName(String gitUrl) { + if (!gitUrl) return null + String cleaned = gitUrl.replaceAll('/+$', '') + int lastSlash = cleaned.lastIndexOf('/') + int lastColon = cleaned.lastIndexOf(':') + int lastSep = Math.max(lastSlash, lastColon) + String name = lastSep >= 0 ? cleaned.substring(lastSep + 1) : cleaned + if (name.endsWith('.git')) { + name = name.substring(0, name.length() - 4) + } + return name ?: null + } + + /** + * Collects key storage paths from the step configuration for the given property keys. + * Resolves data references (e.g. ${option.path}) using the execution context before + * extracting paths. Used by ProxyRunnerPlugin/ProxySecretBundleCreator to tell the + * Rundeck server which secrets a runner needs. + * + * @param context the Rundeck execution context + * @param configuration the step configuration map + * @param storageKeys configuration property names that hold key storage paths + * @return list of resolved key storage paths (never null) + */ + static List listSecretsPathForStep(ExecutionContext context, Map configuration, String... storageKeys) { + Map resolved = DataContextUtils.replaceDataReferences( + (Map) configuration, + context.getDataContext() + ) + List paths = [] + for (String key : storageKeys) { + def value = resolved.get(key) + if (value) { + paths.add((String) value) + } + } + return paths + } + + /** + * Creates a SecretBundle containing the secrets at the key storage paths found in the + * step configuration. The server calls this before dispatching work to a Rundeck Runner + * so the runner's AuthorizedSecretStorage can authorize access to these paths. + * + * @param context the Rundeck execution context (server-side, with full storage access) + * @param configuration the step configuration map + * @param storageKeys configuration property names that hold key storage paths + * @return a SecretBundle with the secret values keyed by their storage paths + */ + static SecretBundle prepareSecretBundleForStep(ExecutionContext context, Map configuration, String... storageKeys) { + List paths = listSecretsPathForStep(context, configuration, storageKeys) + DefaultSecretBundle bundle = new DefaultSecretBundle() + for (String path : paths) { + try { + def resource = context.getStorageTree().getResource(path) + if (resource != null) { + ByteArrayOutputStream baos = new ByteArrayOutputStream() + resource.getContents().writeContent(baos) + bundle.addSecret(path, baos.toByteArray()) + } + } catch (Exception e) { + logger.error("Failed to prepare secret bundle for key path '{}': {}", path, e.message) + } + } + return bundle + } } diff --git a/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy b/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy index 2c74fea..d4450eb 100644 --- a/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy +++ b/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy @@ -15,10 +15,14 @@ import java.security.PublicKey /** * SSH session factory using Apache MINA SSHD instead of JSch. * Provides support for modern SSH algorithms including RSA with SHA-2 signatures. + * + * Implements {@link Closeable} so callers can shut down the internal SSHD client + * and clean up temporary key files after a transport operation completes. */ -class PluginSshSessionFactory implements TransportConfigCallback { +class PluginSshSessionFactory implements TransportConfigCallback, Closeable { private byte[] privateKey Map sshConfig + private CustomSshdSessionFactory sessionFactory PluginSshSessionFactory(final byte[] privateKey) { this.privateKey = privateKey @@ -28,12 +32,23 @@ class PluginSshSessionFactory implements TransportConfigCallback { void configure(final Transport transport) { if (transport in SshTransport) { SshTransport sshTransport = (SshTransport) transport - sshTransport.setSshSessionFactory(buildSessionFactory()) + if (sessionFactory == null) { + sessionFactory = new CustomSshdSessionFactory(privateKey, sshConfig) + } + sshTransport.setSshSessionFactory(sessionFactory) } } - private SshdSessionFactory buildSessionFactory() { - return new CustomSshdSessionFactory(privateKey, sshConfig) + @Override + void close() { + if (sessionFactory != null) { + try { + sessionFactory.close() + } catch (Exception ignored) { + } + sessionFactory.deleteTempKey() + sessionFactory = null + } } private static class CustomSshdSessionFactory extends SshdSessionFactory { @@ -47,6 +62,16 @@ class PluginSshSessionFactory implements TransportConfigCallback { this.sshConfig = sshConfig } + void deleteTempKey() { + if (cachedKeyFile != null) { + try { + Files.deleteIfExists(cachedKeyFile) + } catch (Exception ignored) { + } + cachedKeyFile = null + } + } + @Override protected List getDefaultIdentities(File sshDir) { if (privateKey) { diff --git a/src/test/groovy/com/rundeck/plugin/WorkflowStepSecretBundleSpec.groovy b/src/test/groovy/com/rundeck/plugin/WorkflowStepSecretBundleSpec.groovy new file mode 100644 index 0000000..ea56787 --- /dev/null +++ b/src/test/groovy/com/rundeck/plugin/WorkflowStepSecretBundleSpec.groovy @@ -0,0 +1,94 @@ +package com.rundeck.plugin + +import com.dtolabs.rundeck.core.execution.ExecutionContext +import com.dtolabs.rundeck.core.execution.proxy.ProxyRunnerPlugin +import com.dtolabs.rundeck.core.execution.proxy.ProxySecretBundleCreator +import com.dtolabs.rundeck.core.storage.ResourceMeta +import com.dtolabs.rundeck.core.storage.StorageTree +import org.rundeck.storage.api.Resource +import spock.lang.Specification +import spock.lang.Unroll + +class WorkflowStepSecretBundleSpec extends Specification { + + @Unroll + def "#stepClass.simpleName implements ProxyRunnerPlugin and ProxySecretBundleCreator"() { + expect: + ProxyRunnerPlugin.isAssignableFrom(stepClass) + ProxySecretBundleCreator.isAssignableFrom(stepClass) + + where: + stepClass << [GitCloneWorkflowStep, GitPushWorkflowStep, GitCommitWorkflowStep] + } + + @Unroll + def "#stepClass.simpleName listSecretsPathWorkflowStep returns configured paths"() { + given: + def step = stepClass.getDeclaredConstructor().newInstance() + def context = Mock(ExecutionContext) + context.getDataContext() >> [:] + def configuration = [ + gitPasswordPath: "keys/project/Test/password", + gitKeyPath: "keys/project/Test/sshkey" + ] + + when: + def paths = step.listSecretsPathWorkflowStep(context, configuration) + + then: + paths.size() == 2 + paths.contains("keys/project/Test/password") + paths.contains("keys/project/Test/sshkey") + + where: + stepClass << [GitCloneWorkflowStep, GitPushWorkflowStep, GitCommitWorkflowStep] + } + + @Unroll + def "#stepClass.simpleName listSecretsPathWorkflowStep returns empty when no secrets configured"() { + given: + def step = stepClass.getDeclaredConstructor().newInstance() + def context = Mock(ExecutionContext) + context.getDataContext() >> [:] + def configuration = [gitUrl: "https://example.com/repo.git"] + + when: + def paths = step.listSecretsPathWorkflowStep(context, configuration) + + then: + paths.isEmpty() + + where: + stepClass << [GitCloneWorkflowStep, GitPushWorkflowStep, GitCommitWorkflowStep] + } + + @Unroll + def "#stepClass.simpleName prepareSecretBundleWorkflowStep bundles secrets"() { + given: + def step = stepClass.getDeclaredConstructor().newInstance() + + def secretBytes = "secret-value".bytes + def contents = Mock(ResourceMeta) + contents.writeContent(_) >> { OutputStream os -> os.write(secretBytes); return (long) secretBytes.length } + def resource = Mock(Resource) + resource.getContents() >> contents + def storageTree = Mock(StorageTree) + storageTree.getResource("keys/project/Test/password") >> resource + + def context = Mock(ExecutionContext) + context.getDataContext() >> [:] + context.getStorageTree() >> storageTree + + def configuration = [gitPasswordPath: "keys/project/Test/password"] + + when: + def bundle = step.prepareSecretBundleWorkflowStep(context, configuration) + + then: + bundle != null + bundle.getValue("keys/project/Test/password") == secretBytes + + where: + stepClass << [GitCloneWorkflowStep, GitPushWorkflowStep, GitCommitWorkflowStep] + } +} diff --git a/src/test/groovy/com/rundeck/plugin/util/GitPluginUtilExtractRepoNameSpec.groovy b/src/test/groovy/com/rundeck/plugin/util/GitPluginUtilExtractRepoNameSpec.groovy new file mode 100644 index 0000000..78c8cde --- /dev/null +++ b/src/test/groovy/com/rundeck/plugin/util/GitPluginUtilExtractRepoNameSpec.groovy @@ -0,0 +1,28 @@ +package com.rundeck.plugin.util + +import spock.lang.Specification +import spock.lang.Unroll + +class GitPluginUtilExtractRepoNameSpec extends Specification { + + @Unroll + def "extractRepoName from '#url' returns '#expected'"() { + expect: + GitPluginUtil.extractRepoName(url) == expected + + where: + url | expected + 'https://github.com/user/my-repo.git' | 'my-repo' + 'https://github.com/user/my-repo' | 'my-repo' + 'https://github.com/user/my-repo.git/' | 'my-repo' + 'ssh://git@github.com/user/my-repo.git' | 'my-repo' + 'ssh://git@host.xz:2222/path/to/repo.git' | 'repo' + 'git://host.xz/path/to/repo.git/' | 'repo' + 'git@github.com:user/my-repo.git' | 'my-repo' + 'git@github.com:org/sub-project.git' | 'sub-project' + 'https://example.com/repo' | 'repo' + 'ftp://host.xz/path/to/repo.git' | 'repo' + null | null + '' | null + } +} diff --git a/src/test/groovy/com/rundeck/plugin/util/GitPluginUtilSecretBundleSpec.groovy b/src/test/groovy/com/rundeck/plugin/util/GitPluginUtilSecretBundleSpec.groovy new file mode 100644 index 0000000..e5bbfc3 --- /dev/null +++ b/src/test/groovy/com/rundeck/plugin/util/GitPluginUtilSecretBundleSpec.groovy @@ -0,0 +1,167 @@ +package com.rundeck.plugin.util + +import com.dtolabs.rundeck.core.execution.ExecutionContext +import com.dtolabs.rundeck.core.execution.proxy.SecretBundle +import com.dtolabs.rundeck.core.storage.ResourceMeta +import com.dtolabs.rundeck.core.storage.StorageTree +import org.rundeck.storage.api.Resource +import spock.lang.Specification + +class GitPluginUtilSecretBundleSpec extends Specification { + + def "listSecretsPathForStep returns empty list when no storage keys configured"() { + given: + def context = Mock(ExecutionContext) + context.getDataContext() >> [:] + def configuration = [gitUrl: "https://example.com/repo.git"] + + when: + def paths = GitPluginUtil.listSecretsPathForStep(context, configuration, "gitPasswordPath", "gitKeyPath") + + then: + paths != null + paths.isEmpty() + } + + def "listSecretsPathForStep returns password path when configured"() { + given: + def context = Mock(ExecutionContext) + context.getDataContext() >> [:] + def configuration = [gitPasswordPath: "keys/project/MyProject/git_password"] + + when: + def paths = GitPluginUtil.listSecretsPathForStep(context, configuration, "gitPasswordPath", "gitKeyPath") + + then: + paths.size() == 1 + paths[0] == "keys/project/MyProject/git_password" + } + + def "listSecretsPathForStep returns SSH key path when configured"() { + given: + def context = Mock(ExecutionContext) + context.getDataContext() >> [:] + def configuration = [gitKeyPath: "keys/project/MyProject/ssh_key"] + + when: + def paths = GitPluginUtil.listSecretsPathForStep(context, configuration, "gitPasswordPath", "gitKeyPath") + + then: + paths.size() == 1 + paths[0] == "keys/project/MyProject/ssh_key" + } + + def "listSecretsPathForStep returns both paths when both configured"() { + given: + def context = Mock(ExecutionContext) + context.getDataContext() >> [:] + def configuration = [ + gitPasswordPath: "keys/project/MyProject/git_password", + gitKeyPath: "keys/project/MyProject/ssh_key" + ] + + when: + def paths = GitPluginUtil.listSecretsPathForStep(context, configuration, "gitPasswordPath", "gitKeyPath") + + then: + paths.size() == 2 + paths.contains("keys/project/MyProject/git_password") + paths.contains("keys/project/MyProject/ssh_key") + } + + def "listSecretsPathForStep resolves data references in paths"() { + given: + def context = Mock(ExecutionContext) + context.getDataContext() >> [option: [secretPath: "keys/project/MyProject/resolved_key"]] + def configuration = [gitKeyPath: '${option.secretPath}'] + + when: + def paths = GitPluginUtil.listSecretsPathForStep(context, configuration, "gitPasswordPath", "gitKeyPath") + + then: + paths.size() == 1 + paths[0] == "keys/project/MyProject/resolved_key" + } + + def "prepareSecretBundleForStep creates bundle with secret content"() { + given: + def secretBytes = "secret-password".bytes + def contents = Mock(ResourceMeta) + contents.writeContent(_) >> { OutputStream os -> os.write(secretBytes); return (long) secretBytes.length } + + def resource = Mock(Resource) + resource.getContents() >> contents + + def storageTree = Mock(StorageTree) + storageTree.getResource("keys/project/MyProject/git_password") >> resource + + def context = Mock(ExecutionContext) + context.getDataContext() >> [:] + context.getStorageTree() >> storageTree + + def configuration = [gitPasswordPath: "keys/project/MyProject/git_password"] + + when: + SecretBundle bundle = GitPluginUtil.prepareSecretBundleForStep(context, configuration, "gitPasswordPath", "gitKeyPath") + + then: + bundle != null + bundle.getValue("keys/project/MyProject/git_password") == secretBytes + } + + def "prepareSecretBundleForStep handles missing storage path gracefully"() { + given: + def storageTree = Mock(StorageTree) + storageTree.getResource(_) >> { throw new RuntimeException("Not found") } + + def context = Mock(ExecutionContext) + context.getDataContext() >> [:] + context.getStorageTree() >> storageTree + + def configuration = [gitPasswordPath: "keys/project/MyProject/missing"] + + when: + SecretBundle bundle = GitPluginUtil.prepareSecretBundleForStep(context, configuration, "gitPasswordPath") + + then: + noExceptionThrown() + bundle != null + bundle.getValue("keys/project/MyProject/missing") == null + } + + def "prepareSecretBundleForStep bundles multiple secrets"() { + given: + def passwordBytes = "my-password".bytes + def keyBytes = "ssh-private-key-content".bytes + + def passwordContents = Mock(ResourceMeta) + passwordContents.writeContent(_) >> { OutputStream os -> os.write(passwordBytes); return (long) passwordBytes.length } + def passwordResource = Mock(Resource) + passwordResource.getContents() >> passwordContents + + def keyContents = Mock(ResourceMeta) + keyContents.writeContent(_) >> { OutputStream os -> os.write(keyBytes); return (long) keyBytes.length } + def keyResource = Mock(Resource) + keyResource.getContents() >> keyContents + + def storageTree = Mock(StorageTree) + storageTree.getResource("keys/project/P/password") >> passwordResource + storageTree.getResource("keys/project/P/sshkey") >> keyResource + + def context = Mock(ExecutionContext) + context.getDataContext() >> [:] + context.getStorageTree() >> storageTree + + def configuration = [ + gitPasswordPath: "keys/project/P/password", + gitKeyPath: "keys/project/P/sshkey" + ] + + when: + SecretBundle bundle = GitPluginUtil.prepareSecretBundleForStep(context, configuration, "gitPasswordPath", "gitKeyPath") + + then: + bundle.getValue("keys/project/P/password") == passwordBytes + bundle.getValue("keys/project/P/sshkey") == keyBytes + } +} diff --git a/src/test/groovy/com/rundeck/plugin/util/PluginSshSessionFactorySpec.groovy b/src/test/groovy/com/rundeck/plugin/util/PluginSshSessionFactorySpec.groovy index 26e9139..7db921b 100644 --- a/src/test/groovy/com/rundeck/plugin/util/PluginSshSessionFactorySpec.groovy +++ b/src/test/groovy/com/rundeck/plugin/util/PluginSshSessionFactorySpec.groovy @@ -224,9 +224,10 @@ class PluginSshSessionFactorySpec extends Specification { db != null } - def "each call to configure creates a fresh session factory with current sshConfig"() { + def "multiple configure calls reuse same session factory"() { given: def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = [StrictHostKeyChecking: 'no'] SshdSessionFactory first = null SshdSessionFactory second = null @@ -238,9 +239,77 @@ class PluginSshSessionFactorySpec extends Specification { } when: - factory.sshConfig = [StrictHostKeyChecking: 'yes'] factory.configure(sshTransport) - factory.sshConfig = [StrictHostKeyChecking: 'no'] + factory.configure(sshTransport) + + then: + first != null + second != null + first.is(second) + } + + def "close shuts down session factory and is idempotent"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = [:] + + SshdSessionFactory captured = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> captured = args[0] } + } + factory.configure(sshTransport) + + when: + factory.close() + factory.close() + + then: + notThrown(Exception) + } + + def "close deletes temp key file"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = [:] + + SshdSessionFactory captured = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> captured = args[0] } + } + factory.configure(sshTransport) + + def identities = captured.getDefaultIdentities(new File(System.getProperty("java.io.tmpdir"))) + def keyFile = identities[0] + + expect: + Files.exists(keyFile) + + when: + factory.close() + + then: + !Files.exists(keyFile) + } + + def "configure after close creates new session factory"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = [:] + + SshdSessionFactory first = null + SshdSessionFactory second = null + def callCount = 0 + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> + if (callCount == 0) first = args[0] + else second = args[0] + callCount++ + } + } + + when: + factory.configure(sshTransport) + factory.close() factory.configure(sshTransport) then: