From 9be7c29c54c1cfb484a698298982e36ed7480bba Mon Sep 17 00:00:00 2001 From: Tobias Richter Date: Wed, 29 Nov 2017 16:05:55 +0100 Subject: [PATCH] Initial commit --- .gitattributes | 51 ++ .gitignore | 4 + LICENSE.txt | 201 +++++++ README.md | 165 ++++++ .../checkout-scm/checkout-to-local-branch.png | Bin 0 -> 8360 bytes docs/assets/checkout-scm/mode-2.png | Bin 0 -> 57667 bytes .../exec-managed-shell-script/demo-script.png | Bin 0 -> 14783 bytes .../tutorial-setup/shared-library-001.png | Bin 0 -> 7424 bytes .../tutorial-setup/shared-library-002.png | Bin 0 -> 82386 bytes .../tutorial-setup/shared-library-003.png | Bin 0 -> 110021 bytes docs/config-structure.md | 86 +++ docs/credentials.md | 126 ++++ docs/logging.md | 188 ++++++ docs/managed-files.md | 114 ++++ docs/pattern-matching.md | 85 +++ docs/requirements.md | 21 + docs/tutorial-setup.md | 364 ++++++++++++ docs/usage-examples.md | 45 ++ pom.xml | 352 ++++++++++++ .../pipeline/credentials/Credential.groovy | 53 ++ .../credentials/CredentialAware.groovy | 44 ++ .../credentials/CredentialConstants.groovy | 33 ++ .../credentials/CredentialParser.groovy | 81 +++ .../environment/EnvironmentConstants.groovy | 35 ++ .../pipeline/managedfiles/ManagedFile.groovy | 47 ++ .../managedfiles/ManagedFileConstants.groovy | 53 ++ .../managedfiles/ManagedFileParser.groovy | 83 +++ .../pipeline/model/PatternMatchable.groovy | 46 ++ .../jenkins/pipeline/model/Result.groovy | 94 +++ .../jenkins/pipeline/model/Tool.groovy | 43 ++ .../pipeline/shell/CommandBuilder.groovy | 114 ++++ .../pipeline/shell/CommandBuilderImpl.groovy | 195 +++++++ .../shell/ConfigAwareCommandBuilder.groovy | 35 ++ .../shell/GitCommandBuilderImpl.groovy | 31 + .../shell/MavenCommandBuilderImpl.groovy | 390 +++++++++++++ .../shell/ScpCommandBuilderImpl.groovy | 237 ++++++++ .../jenkins/pipeline/shell/ShellUtils.groovy | 122 ++++ .../jenkins/pipeline/ssh/SSHTarget.groovy | 68 +++ .../pipeline/tools/ansible/Role.groovy | 66 +++ .../tools/ansible/RoleRequirements.groovy | 118 ++++ .../pipeline/utils/ConfigConstants.groovy | 112 ++++ .../utils/IntegrationTestHelper.groovy | 88 +++ .../jenkins/pipeline/utils/ListUtils.groovy | 71 +++ .../utils/NotificationTriggerHelper.groovy | 162 ++++++ .../pipeline/utils/PatternMatcher.groovy | 74 +++ .../jenkins/pipeline/utils/TypeUtils.groovy | 98 ++++ .../pipeline/utils/logging/LogLevel.groovy | 75 +++ .../pipeline/utils/logging/Logger.groovy | 433 ++++++++++++++ .../pipeline/utils/maps/MapUtils.groovy | 93 +++ .../resources/JsonLibraryResource.groovy | 72 +++ .../utils/resources/LibraryResource.groovy | 68 +++ .../versioning/ComparableVersion.groovy | 167 ++++++ .../pipeline/versioning/IntegerItem.groovy | 84 +++ .../jenkins/pipeline/versioning/Item.groovy | 38 ++ .../pipeline/versioning/ListItem.groovy | 347 +++++++++++ .../pipeline/versioning/StringItem.groovy | 131 +++++ src/pipeline-library.gdsl | 24 + src/pipeline.gdsl | 137 +++++ .../jenkins/pipeline/CpsScriptMock.groovy | 86 +++ .../jenkins/pipeline/CpsScriptTestBase.groovy | 43 ++ .../testing/jenkins/pipeline/DSLMock.groovy | 316 ++++++++++ .../jenkins/pipeline/DSLTestBase.groovy | 40 ++ .../jenkins/pipeline/EnvActionImplMock.groovy | 51 ++ .../LibraryIntegrationTestBase.groovy | 538 ++++++++++++++++++ .../jenkins/pipeline/RunWrapperMock.groovy | 72 +++ .../jenkins/pipeline/StepConstants.groovy | 92 +++ .../global/lib/SelfSourceRetriever.groovy | 57 ++ .../lib/SubmoduleSourceRetriever.groovy | 50 ++ .../pipeline/recorder/StepRecorder.groovy | 76 +++ .../recorder/StepRecorderAssert.groovy | 95 ++++ .../credentials/CredentialParserTest.groovy | 53 ++ .../credentials/CredentialTest.groovy | 36 ++ .../managedfiles/ManagedFileParserTest.groovy | 52 ++ .../managedfiles/MangedFileTest.groovy | 63 ++ .../jenkins/pipeline/model/ResultTest.groovy | 134 +++++ .../shell/CommandBuilderImplTest.groovy | 134 +++++ .../shell/MavenCommandBuilderImplTest.groovy | 249 ++++++++ .../shell/ScpCommandBuilderImplTest.groovy | 122 ++++ .../pipeline/shell/ShellUtilsTest.groovy | 79 +++ .../tools/ansible/RoleRequirementsTest.groovy | 115 ++++ .../pipeline/tools/ansible/RoleTest.groovy | 78 +++ .../utils/IntegrationTestUtilTest.groovy | 61 ++ .../pipeline/utils/ListUtilsTest.groovy | 58 ++ .../NotificationTriggerHelperTest.groovy | 172 ++++++ ...tternMatcherGlobalMavenSettingsTest.groovy | 92 +++ .../PatternMatcherMavenSettingsTest.groovy | 93 +++ .../PatternMatcherSCMCredentialTest.groovy | 61 ++ .../pipeline/utils/PatternMatcherTest.groovy | 74 +++ .../pipeline/utils/TypeUtilsTest.groovy | 66 +++ .../utils/logging/LogLevelTest.groovy | 42 ++ .../utils/logging/LoggerCpsScriptTest.groovy | 239 ++++++++ .../pipeline/utils/logging/LoggerTest.groovy | 205 +++++++ .../pipeline/utils/maps/MapUtilsTest.groovy | 228 ++++++++ .../resources/JsonLibraryResourceTest.groovy | 55 ++ .../resources/LibraryResourceTest.groovy | 68 +++ .../versioning/ComparableVersionTest.groovy | 214 +++++++ test/resources/credentials/parser-test.json | 11 + .../credentials/scm/credentials.json | 12 + .../credentials/ssh/credentials.json | 32 ++ test/resources/example-resource.json | 3 + test/resources/example-resource.txt | 1 + test/resources/invalid.json | 2 + .../managedfiles/maven/global-settings.json | 20 + .../managedfiles/maven/parser-test.json | 12 + .../managedfiles/maven/settings.json | 32 ++ .../npm/npm-config-userconfig.json | 14 + test/resources/managedfiles/npm/npmrc.json | 14 + .../managedfiles/ruby/bundle-config.json | 14 + .../invalid-maven-release-version-pom.xml | 172 ++++++ .../invalid-maven-scm-provider-gitexe-pom.xml | 172 ++++++ .../mavenRelease/valid-effective-pom.xml | 87 +++ test/resources/tools/ansible/requirements.yml | 10 + .../resources/tools/ansible/tecris.maven.json | 110 ++++ .../tools/ansible/williamyeh.oracle-java.json | 271 +++++++++ test/vars/ansible/AnsibleIT.groovy | 228 ++++++++ .../ansibleCheckoutRequirementsTestJob.groovy | 34 ++ ...cPlaybookCustomConfigurationTestJob.groovy | 60 ++ ...ibleExecPlaybookInjectParamsTestJob.groovy | 45 ++ .../ansibleExecPlaybookMinimalTestJob.groovy | 43 ++ .../ansibleGetGalaxyRoleInfoTestJob.groovy | 38 ++ ...eGetGalaxyRoleInfoWithErrorsTestJob.groovy | 38 ++ test/vars/checkoutScm/CheckoutScmIT.groovy | 144 +++++ .../jobs/checkoutScmCustomVariant1Job.groovy | 41 ++ .../jobs/checkoutScmCustomVariant2Job.groovy | 39 ++ .../jobs/checkoutScmCustomVariant3Job.groovy | 38 ++ .../jobs/checkoutScmDefaultsJob.groovy | 39 ++ .../checkoutScmEmptyCredentialsJob.groovy | 37 ++ .../ExecManagedShellScriptIT.groovy | 55 ++ .../execMangedShellScriptVariant1Test.groovy | 32 ++ .../execMangedShellScriptVariant2Test.groovy | 32 ++ .../execMangedShellScriptVariant3Test.groovy | 32 ++ test/vars/execMaven/ExecMavenIT.groovy | 123 ++++ .../jobs/execMavenCustomCommandTestJob.groovy | 41 ++ .../jobs/execMavenCustomVariant1Job.groovy | 44 ++ .../jobs/execMavenCustomVariant2Job.groovy | 44 ++ .../execMaven/jobs/execMavenDefaultJob.groovy | 36 ++ .../execMavenGlobalAndLocalSettingsJob.groovy | 35 ++ .../jobs/execMavenGlobalSettingsJob.groovy | 35 ++ .../jobs/execMavenLocalSettingsJob.groovy | 35 ++ ...execMavenWithBuildParametersTestJob.groovy | 46 ++ .../execMavenWithNPMAndRubyTestJob.groovy | 38 ++ ...avenWithSettingsViaScmUrlFromEnvJob.groovy | 32 ++ .../ExecMavenReleaseIT.groovy | 130 +++++ ...ExecMavenReleaseWithKeyAgentTestJob.groovy | 37 ++ ...uldFailWhenScmUrlIsNotGitSSHTestJob.groovy | 36 ++ .../shouldFailWhenScmUrlIsNullTestJob.groovy | 38 ++ ...shouldFailWithNoBranchEnvVarTestJob.groovy | 39 ++ ...FailWithNotAllowedBranchNameTestJob.groovy | 40 ++ test/vars/execNpm/ExecNpmIT.groovy | 48 ++ .../execNpmCustomAndAutoLookupTestJob.groovy | 41 ++ .../execNpm/jobs/execNpmDefaultTestJob.groovy | 35 ++ test/vars/getScmUrl/GetScmUrlIT.groovy | 39 ++ .../jobs/getScmUrlFromConfigTestJob.groovy | 39 ++ .../jobs/getScmUrlFromEnvVarTestJob.groovy | 37 ++ test/vars/logging/LoggingIT.groovy | 32 ++ ...nitializeWithinITEnvironmentTestJob.groovy | 40 ++ .../vars/notifyMail/NotifyMailCustomIT.groovy | 115 ++++ .../notifyMail/NotifyMailDefaultsIT.groovy | 116 ++++ .../jobs/notifyMailCustomJob.groovy | 54 ++ .../jobs/notifyMailDefaultsJob.groovy | 32 ++ test/vars/setBuildName/SetBuildNameIT.groovy | 47 ++ .../setBuildName/jobs/setBuildNameJob.groovy | 32 ++ test/vars/setGitBranch/SetGitBranchIT.groovy | 75 +++ .../jobs/setGitBranchTestJob.groovy | 32 ++ test/vars/setScmUrl/SetScmUrlIT.groovy | 42 ++ .../jobs/setScmUrlFromShellJob.groovy | 32 ++ .../jobs/setScmUrlWithConfigJob.groovy | 32 ++ test/vars/setupTools/SetupToolsIT.groovy | 71 +++ .../jobs/shouldFailWhenToolNotFound.groovy | 38 ++ .../jobs/shouldUseCustomEnvVarsTestJob.groovy | 40 ++ .../shouldUseDefaultEnvVarsTestJob.groovy | 40 ++ .../sshAgentWrapper/SSHAgentWrapperIT.groovy | 78 +++ ...shouldWrapWithCommandBuilderTestJob.groovy | 44 ++ ...ldWrapWithMultipleSSHTargetsTestJob.groovy | 50 ++ .../jobs/shouldWrapWithStringSSHTarget.groovy | 36 ++ test/vars/transferScp/TransferScpIT.groovy | 60 ++ .../jobs/transferScpRecursiveTestJob.groovy | 46 ++ .../jobs/transferScpSingleTestJob.groovy | 46 ++ ...transferScpWithMinimalConfiguration.groovy | 40 ++ test/vars/wrap/WrapColorIT.groovy | 91 +++ ...ouldWrapColorMultiWithConfigTestJob.groovy | 51 ++ ...lorOnlyOnceWithSameColorModeTestJob.groovy | 50 ++ .../wrap/jobs/shouldWrapColorTestJob.groovy | 43 ++ vars/ansible.groovy | 218 +++++++ vars/ansible.md | 412 ++++++++++++++ vars/checkoutScm.groovy | 170 ++++++ vars/checkoutScm.md | 324 +++++++++++ vars/execManagedShellScript.groovy | 73 +++ vars/execManagedShellScript.md | 65 +++ vars/execMaven.groovy | 166 ++++++ vars/execMaven.md | 498 ++++++++++++++++ vars/execMavenRelease.groovy | 137 +++++ vars/execMavenRelease.md | 85 +++ vars/execNpm.groovy | 121 ++++ vars/execNpm.md | 137 +++++ vars/getScmUrl.groovy | 43 ++ vars/getScmUrl.md | 14 + vars/integrationTestUtils.groovy | 154 +++++ vars/integrationTestUtils.md | 18 + vars/notifyMail.groovy | 139 +++++ vars/notifyMail.md | 280 +++++++++ vars/setBuildName.groovy | 40 ++ vars/setBuildName.md | 19 + vars/setGitBranch.groovy | 81 +++ vars/setGitBranch.md | 32 ++ vars/setScmUrl.groovy | 47 ++ vars/setScmUrl.md | 30 + vars/setupTools.groovy | 79 +++ vars/setupTools.md | 160 ++++++ vars/sshAgentWrapper.groovy | 115 ++++ vars/sshAgentWrapper.md | 104 ++++ vars/transferScp.groovy | 63 ++ vars/transferScp.md | 204 +++++++ vars/wrap.groovy | 51 ++ vars/wrap.md | 57 ++ 215 files changed, 19096 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 docs/assets/checkout-scm/checkout-to-local-branch.png create mode 100644 docs/assets/checkout-scm/mode-2.png create mode 100644 docs/assets/exec-managed-shell-script/demo-script.png create mode 100644 docs/assets/tutorial-setup/shared-library-001.png create mode 100644 docs/assets/tutorial-setup/shared-library-002.png create mode 100644 docs/assets/tutorial-setup/shared-library-003.png create mode 100644 docs/config-structure.md create mode 100644 docs/credentials.md create mode 100644 docs/logging.md create mode 100644 docs/managed-files.md create mode 100644 docs/pattern-matching.md create mode 100644 docs/requirements.md create mode 100644 docs/tutorial-setup.md create mode 100644 docs/usage-examples.md create mode 100644 pom.xml create mode 100644 src/io/wcm/tooling/jenkins/pipeline/credentials/Credential.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialAware.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialConstants.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialParser.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/environment/EnvironmentConstants.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFile.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFileConstants.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFileParser.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/model/PatternMatchable.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/model/Result.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/model/Tool.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/shell/CommandBuilder.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/shell/CommandBuilderImpl.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/shell/ConfigAwareCommandBuilder.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/shell/GitCommandBuilderImpl.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/shell/MavenCommandBuilderImpl.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/shell/ScpCommandBuilderImpl.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/shell/ShellUtils.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/ssh/SSHTarget.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/tools/ansible/Role.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/tools/ansible/RoleRequirements.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/utils/IntegrationTestHelper.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/utils/ListUtils.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/utils/NotificationTriggerHelper.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcher.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/utils/TypeUtils.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/utils/logging/LogLevel.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/utils/logging/Logger.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/utils/maps/MapUtils.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/utils/resources/JsonLibraryResource.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/utils/resources/LibraryResource.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/versioning/ComparableVersion.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/versioning/IntegerItem.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/versioning/Item.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/versioning/ListItem.groovy create mode 100644 src/io/wcm/tooling/jenkins/pipeline/versioning/StringItem.groovy create mode 100644 src/pipeline-library.gdsl create mode 100644 src/pipeline.gdsl create mode 100644 test/io/wcm/testing/jenkins/pipeline/CpsScriptMock.groovy create mode 100644 test/io/wcm/testing/jenkins/pipeline/CpsScriptTestBase.groovy create mode 100644 test/io/wcm/testing/jenkins/pipeline/DSLMock.groovy create mode 100644 test/io/wcm/testing/jenkins/pipeline/DSLTestBase.groovy create mode 100644 test/io/wcm/testing/jenkins/pipeline/EnvActionImplMock.groovy create mode 100644 test/io/wcm/testing/jenkins/pipeline/LibraryIntegrationTestBase.groovy create mode 100644 test/io/wcm/testing/jenkins/pipeline/RunWrapperMock.groovy create mode 100644 test/io/wcm/testing/jenkins/pipeline/StepConstants.groovy create mode 100644 test/io/wcm/testing/jenkins/pipeline/global/lib/SelfSourceRetriever.groovy create mode 100644 test/io/wcm/testing/jenkins/pipeline/global/lib/SubmoduleSourceRetriever.groovy create mode 100644 test/io/wcm/testing/jenkins/pipeline/recorder/StepRecorder.groovy create mode 100644 test/io/wcm/testing/jenkins/pipeline/recorder/StepRecorderAssert.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/credentials/CredentialParserTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/credentials/CredentialTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFileParserTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/managedfiles/MangedFileTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/model/ResultTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/shell/CommandBuilderImplTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/shell/MavenCommandBuilderImplTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/shell/ScpCommandBuilderImplTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/shell/ShellUtilsTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/tools/ansible/RoleRequirementsTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/tools/ansible/RoleTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/utils/IntegrationTestUtilTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/utils/ListUtilsTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/utils/NotificationTriggerHelperTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcherGlobalMavenSettingsTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcherMavenSettingsTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcherSCMCredentialTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcherTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/utils/TypeUtilsTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/utils/logging/LogLevelTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/utils/logging/LoggerCpsScriptTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/utils/logging/LoggerTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/utils/maps/MapUtilsTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/utils/resources/JsonLibraryResourceTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/utils/resources/LibraryResourceTest.groovy create mode 100644 test/io/wcm/tooling/jenkins/pipeline/versioning/ComparableVersionTest.groovy create mode 100644 test/resources/credentials/parser-test.json create mode 100644 test/resources/credentials/scm/credentials.json create mode 100644 test/resources/credentials/ssh/credentials.json create mode 100644 test/resources/example-resource.json create mode 100644 test/resources/example-resource.txt create mode 100644 test/resources/invalid.json create mode 100644 test/resources/managedfiles/maven/global-settings.json create mode 100644 test/resources/managedfiles/maven/parser-test.json create mode 100644 test/resources/managedfiles/maven/settings.json create mode 100644 test/resources/managedfiles/npm/npm-config-userconfig.json create mode 100644 test/resources/managedfiles/npm/npmrc.json create mode 100644 test/resources/managedfiles/ruby/bundle-config.json create mode 100644 test/resources/mavenRelease/invalid-maven-release-version-pom.xml create mode 100644 test/resources/mavenRelease/invalid-maven-scm-provider-gitexe-pom.xml create mode 100644 test/resources/mavenRelease/valid-effective-pom.xml create mode 100644 test/resources/tools/ansible/requirements.yml create mode 100644 test/resources/tools/ansible/tecris.maven.json create mode 100644 test/resources/tools/ansible/williamyeh.oracle-java.json create mode 100644 test/vars/ansible/AnsibleIT.groovy create mode 100644 test/vars/ansible/jobs/ansibleCheckoutRequirementsTestJob.groovy create mode 100644 test/vars/ansible/jobs/ansibleExecPlaybookCustomConfigurationTestJob.groovy create mode 100644 test/vars/ansible/jobs/ansibleExecPlaybookInjectParamsTestJob.groovy create mode 100644 test/vars/ansible/jobs/ansibleExecPlaybookMinimalTestJob.groovy create mode 100644 test/vars/ansible/jobs/ansibleGetGalaxyRoleInfoTestJob.groovy create mode 100644 test/vars/ansible/jobs/ansibleGetGalaxyRoleInfoWithErrorsTestJob.groovy create mode 100644 test/vars/checkoutScm/CheckoutScmIT.groovy create mode 100644 test/vars/checkoutScm/jobs/checkoutScmCustomVariant1Job.groovy create mode 100644 test/vars/checkoutScm/jobs/checkoutScmCustomVariant2Job.groovy create mode 100644 test/vars/checkoutScm/jobs/checkoutScmCustomVariant3Job.groovy create mode 100644 test/vars/checkoutScm/jobs/checkoutScmDefaultsJob.groovy create mode 100644 test/vars/checkoutScm/jobs/checkoutScmEmptyCredentialsJob.groovy create mode 100644 test/vars/execManagedShellScript/ExecManagedShellScriptIT.groovy create mode 100644 test/vars/execManagedShellScript/jobs/execMangedShellScriptVariant1Test.groovy create mode 100644 test/vars/execManagedShellScript/jobs/execMangedShellScriptVariant2Test.groovy create mode 100644 test/vars/execManagedShellScript/jobs/execMangedShellScriptVariant3Test.groovy create mode 100644 test/vars/execMaven/ExecMavenIT.groovy create mode 100644 test/vars/execMaven/jobs/execMavenCustomCommandTestJob.groovy create mode 100644 test/vars/execMaven/jobs/execMavenCustomVariant1Job.groovy create mode 100644 test/vars/execMaven/jobs/execMavenCustomVariant2Job.groovy create mode 100644 test/vars/execMaven/jobs/execMavenDefaultJob.groovy create mode 100644 test/vars/execMaven/jobs/execMavenGlobalAndLocalSettingsJob.groovy create mode 100644 test/vars/execMaven/jobs/execMavenGlobalSettingsJob.groovy create mode 100644 test/vars/execMaven/jobs/execMavenLocalSettingsJob.groovy create mode 100644 test/vars/execMaven/jobs/execMavenWithBuildParametersTestJob.groovy create mode 100644 test/vars/execMaven/jobs/execMavenWithNPMAndRubyTestJob.groovy create mode 100644 test/vars/execMaven/jobs/execMavenWithSettingsViaScmUrlFromEnvJob.groovy create mode 100644 test/vars/execMavenRelease/ExecMavenReleaseIT.groovy create mode 100644 test/vars/execMavenRelease/jobs/shouldExecMavenReleaseWithKeyAgentTestJob.groovy create mode 100644 test/vars/execMavenRelease/jobs/shouldFailWhenScmUrlIsNotGitSSHTestJob.groovy create mode 100644 test/vars/execMavenRelease/jobs/shouldFailWhenScmUrlIsNullTestJob.groovy create mode 100644 test/vars/execMavenRelease/jobs/shouldFailWithNoBranchEnvVarTestJob.groovy create mode 100644 test/vars/execMavenRelease/jobs/shouldFailWithNotAllowedBranchNameTestJob.groovy create mode 100644 test/vars/execNpm/ExecNpmIT.groovy create mode 100644 test/vars/execNpm/jobs/execNpmCustomAndAutoLookupTestJob.groovy create mode 100644 test/vars/execNpm/jobs/execNpmDefaultTestJob.groovy create mode 100644 test/vars/getScmUrl/GetScmUrlIT.groovy create mode 100644 test/vars/getScmUrl/jobs/getScmUrlFromConfigTestJob.groovy create mode 100644 test/vars/getScmUrl/jobs/getScmUrlFromEnvVarTestJob.groovy create mode 100644 test/vars/logging/LoggingIT.groovy create mode 100644 test/vars/logging/jobs/shouldInitializeWithinITEnvironmentTestJob.groovy create mode 100644 test/vars/notifyMail/NotifyMailCustomIT.groovy create mode 100644 test/vars/notifyMail/NotifyMailDefaultsIT.groovy create mode 100644 test/vars/notifyMail/jobs/notifyMailCustomJob.groovy create mode 100644 test/vars/notifyMail/jobs/notifyMailDefaultsJob.groovy create mode 100644 test/vars/setBuildName/SetBuildNameIT.groovy create mode 100644 test/vars/setBuildName/jobs/setBuildNameJob.groovy create mode 100644 test/vars/setGitBranch/SetGitBranchIT.groovy create mode 100644 test/vars/setGitBranch/jobs/setGitBranchTestJob.groovy create mode 100644 test/vars/setScmUrl/SetScmUrlIT.groovy create mode 100644 test/vars/setScmUrl/jobs/setScmUrlFromShellJob.groovy create mode 100644 test/vars/setScmUrl/jobs/setScmUrlWithConfigJob.groovy create mode 100644 test/vars/setupTools/SetupToolsIT.groovy create mode 100644 test/vars/setupTools/jobs/shouldFailWhenToolNotFound.groovy create mode 100644 test/vars/setupTools/jobs/shouldUseCustomEnvVarsTestJob.groovy create mode 100644 test/vars/setupTools/jobs/shouldUseDefaultEnvVarsTestJob.groovy create mode 100644 test/vars/sshAgentWrapper/SSHAgentWrapperIT.groovy create mode 100644 test/vars/sshAgentWrapper/jobs/shouldWrapWithCommandBuilderTestJob.groovy create mode 100644 test/vars/sshAgentWrapper/jobs/shouldWrapWithMultipleSSHTargetsTestJob.groovy create mode 100644 test/vars/sshAgentWrapper/jobs/shouldWrapWithStringSSHTarget.groovy create mode 100644 test/vars/transferScp/TransferScpIT.groovy create mode 100644 test/vars/transferScp/jobs/transferScpRecursiveTestJob.groovy create mode 100644 test/vars/transferScp/jobs/transferScpSingleTestJob.groovy create mode 100644 test/vars/transferScp/jobs/transferScpWithMinimalConfiguration.groovy create mode 100644 test/vars/wrap/WrapColorIT.groovy create mode 100644 test/vars/wrap/jobs/shouldWrapColorMultiWithConfigTestJob.groovy create mode 100644 test/vars/wrap/jobs/shouldWrapColorOnlyOnceWithSameColorModeTestJob.groovy create mode 100644 test/vars/wrap/jobs/shouldWrapColorTestJob.groovy create mode 100644 vars/ansible.groovy create mode 100644 vars/ansible.md create mode 100644 vars/checkoutScm.groovy create mode 100644 vars/checkoutScm.md create mode 100644 vars/execManagedShellScript.groovy create mode 100644 vars/execManagedShellScript.md create mode 100644 vars/execMaven.groovy create mode 100644 vars/execMaven.md create mode 100644 vars/execMavenRelease.groovy create mode 100644 vars/execMavenRelease.md create mode 100644 vars/execNpm.groovy create mode 100644 vars/execNpm.md create mode 100644 vars/getScmUrl.groovy create mode 100644 vars/getScmUrl.md create mode 100644 vars/integrationTestUtils.groovy create mode 100644 vars/integrationTestUtils.md create mode 100644 vars/notifyMail.groovy create mode 100644 vars/notifyMail.md create mode 100644 vars/setBuildName.groovy create mode 100644 vars/setBuildName.md create mode 100644 vars/setGitBranch.groovy create mode 100644 vars/setGitBranch.md create mode 100644 vars/setScmUrl.groovy create mode 100644 vars/setScmUrl.md create mode 100644 vars/setupTools.groovy create mode 100644 vars/setupTools.md create mode 100644 vars/sshAgentWrapper.groovy create mode 100644 vars/sshAgentWrapper.md create mode 100644 vars/transferScp.groovy create mode 100644 vars/transferScp.md create mode 100644 vars/wrap.groovy create mode 100644 vars/wrap.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2f458ed --- /dev/null +++ b/.gitattributes @@ -0,0 +1,51 @@ +# Declare text files with unix file ending +*.conf text eol=lf +*.config text eol=lf +*.css text eol=lf +*.dtd text eol=lf +*.esp text eol=lf +*.ecma text eol=lf +*.groovy text eol=lf +*.hbrs text eol=lf +*.htm text eol=lf +*.html text eol=lf +*.java text eol=lf +*.jpage text eol=lf +*.js text eol=lf +*.json text eol=lf +*.jsp text eol=lf +*.mustache text eol=lf +*.tld text eol=lf +*.launch text eol=lf +*.log text eol=lf +*.php text eol=lf +*.pl text eol=lf +*.project text eol=lf +*.properties text eol=lf +*.props text eol=lf +*.sass text eol=lf +*.scss text eol=lf +*.sh text eol=lf +*.shtm text eol=lf +*.shtml text eol=lf +*.sql text eol=lf +*.svg text eol=lf +*.txt text eol=lf +*.vm text eol=lf +*.xml text eol=lf +*.xsd text eol=lf +*.xsl text eol=lf +*.xslt text eol=lf + + +# Declare windows-specific text files with windows file ending +*.asp text eol=crlf +*.asax text eol=crlf +*.asa text eol=crlf +*.aspx text eol=crlf +*.bat text eol=crlf +*.cmd text eol=crlf +*.cs text eol=crlf +*.csproj text eol=crlf +*.reg text eol=crlf +*.sln text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..32a3177 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +*.iml +target +out \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ae6ea9 --- /dev/null +++ b/README.md @@ -0,0 +1,165 @@ +# Pipeline Library + +Since Jenkins Pipeline has reached a certain state of production scripted +pipelines are the way to go. + +But: Not everything known from the UI is available in Pipeline and +configuring and writing scripts is not so easy for the normal developer. + +The target of this library is to take out some complexity (and yes +adding some too) of the pipeline creation and to bring back some known +functionality (for example `GIT_BRANCH` and `SCM_URL` environment +variables, mail notification on still unstable etc.) + +Want to see an example? Have look at +[Usage examples](docs/usage-examples.md) + +# Table of contents +* [Key concepts](#key-concepts) +* [Requirements](#requirements) +* [Steps](#steps) +* [Utilities](#utilities) +* [Credential and managed file auto lookup](#credential-and-managed-file-auto-lookup) +* [Support for command line execution](#support-for-command-line-execution) +* [Setup your environment to use the pipeline library](#setup-your-environment-to-use-the-pipeline-library) +* [Building/Testing](#buildingtesting) + * [Building with maven](#building-with-maven) + +## Key concepts + +The pipeline library was developed with a focus to ease Java and Maven +build processes within companies which have a more or less similiar +project structure e.g. +* Maven/Java +* local Artifact Server (like Sonatype Nexus or Artifactory) +* GIT + +The assumption is that in these environments + +* Jenkins has a dedicated user account to checkout code (or one per project) +* the artifact server caches public artifacts and acts as a internal + artifact server + +:question: So why configure maven repositories and scm credentials in +every pipeline? + +So the key concepts of the pipeline enable you to +* Auto provide credentials (no worries, only Jenkins credential ids, not + the credential itself) (see [Credentials](docs/credentials.md)) +* Auto provide maven settings (see [ManagedFiles](docs/managed-files.md)) +* configure each job the same way (see [ConfigStructure](docs/config-structure.md)) +* log and see the things you are interested in (see [Logging](docs/logging.md)) + +to builds. + +Running this pipeline library will result in more structured and easier +to maintain pipeline scripts. + +Configured properly this library enables you to checkout scm +with these lines of code: + +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +checkoutScm( (SCM) : [ + (SCM_URL) : "git@domain.tld/group/project.git", + ] +) +``` + +Or running maven with local and global maven settings with these lines +of code: + +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +execMaven( + (SCM) : [ + (SCM_URL) : "git@domain.tld/group/project.git", + ], + (MAVEN): [ + (MAVEN_GOALS) : [ "clean", "install" ] + ] +) +``` + +:question: Looking for an example on how a pipeline script looks like +when using Pipeline? Have a look at +[Usage examples](docs/usage-examples.md) + +:bulb: Have a look at the [setup tutorial](docs/tutorial-setup.md) to +start using Pipeline Library. + +## Requirements + +Have a look at [Requirements](docs/requirements.md) to get the library running. + +## Steps + +The pipeline library comes with the following steps: + +* [ansible](vars/ansible.md) + * [`ansible.checkoutRequirements`](vars/ansible.md#checkoutrequirementsstring-requirementsymlpath) + * [`ansible.execPlaybook`](vars/ansible.md#execplaybookmap-config) + * [`ansible.getGalaxyRoleInfo`](vars/ansible.md#getgalaxyroleinforole-role) +* [`checkoutScm`](vars/checkoutScm.md) +* [`execManagedShellScript`](vars/execManagedShellScript.md) +* [`execMaven`](vars/execMaven.md) +* [`execMavenRelease`](vars/execMavenRelease.md) +* [`execNpm`](vars/execNpm.md) +* [`getScmUrl`](vars/getScmUrl.md) +* [`notifyMail`](vars/notifyMail.md) +* [`setBuildName`](vars/setBuildName.md) +* [`setGitBranch`](vars/setGitBranch.md) +* [`setScmUrl`](vars/setScmUrl.md) +* [`setupTools`](vars/setupTools.md) +* [`sshAgentWrapper`](vars/sshAgentWrapper.md) +* [`transferScp`](vars/transferScp.md) +* [wrap](vars/wrap.md) + * [`wrap.color`](vars/wrap.md#colormap-config-closure-body) + +## Utilities +* [Integration Testing](vars/integrationTestUtils.md) +* [Logging](docs/logging.md) + * [`Logger`](src/io/wcm/tooling/jenkins/pipeline/utils/logging/Logger.groovy) + * [`LogLevel`](src/io/wcm/tooling/jenkins/pipeline/utils/logging/LogLevel.groovy) +* [`MapUtils`](src/io/wcm/tooling/jenkins/pipeline/utils/maps/MapUtils.groovy) + +## Credential and managed file auto lookup + +* [Credentials](docs/credentials.md) + * [`Credential`](src/io/wcm/tooling/jenkins/pipeline/credentials/Credential.groovy) + * [`CredentialParser`](src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialParser.groovy) + * [`CredentialConstants`](src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialConstants.groovy) +* [ManagedFiles](docs/managed-files.md) + * [`ManagedFile`](src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFile.groovy) + * [`ManagedFileParser`](src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFileParser.groovy) + * [`ManagedFileConstants`](src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFileConstants.groovy) +* [PatternMatching](docs/pattern-matching.md) + * [`PatternMatchable`](src/io/wcm/tooling/jenkins/pipeline/model/PatternMatchable.groovy) + * [`PatternMatcher`](src/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcher.groovy) + +## Support for command line execution + +* [`CommandBuilder`](src/io/wcm/tooling/jenkins/pipeline/shell/CommandBuilderImpl.groovy) +* [`MavenCommandBuilder`](src/io/wcm/tooling/jenkins/pipeline/shell/MavenCommandBuilderImpl.groovy) + +## Setup your environment to use the pipeline library + +Please refer to [SetupTutorial](docs/tutorial-setup.md) for detailed +instruction on how to setup the library in your environment. + +## Building/Testing + +The library uses two approaches for testing. + +The class parts are tested by unit testing using JUnit/Surefire. All +unit tests have the naming format `*Test.groovy` and are located below +`test/io`. + +The step parts are tested by using +[Jenkins Pipeline Unit](https://github.com/lesfurets/JenkinsPipelineUnit) +with jUnit/Failsafe. All integration tests have the naming format +`*IT.groovy` and are located below `test/vars`. + +### Building with maven + + mvn clean install \ No newline at end of file diff --git a/docs/assets/checkout-scm/checkout-to-local-branch.png b/docs/assets/checkout-scm/checkout-to-local-branch.png new file mode 100644 index 0000000000000000000000000000000000000000..c7a65d435d3a3d933774b1cdabc7b04e38d7eb46 GIT binary patch literal 8360 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 literal 0 HcmV?d00001 diff --git a/docs/assets/checkout-scm/mode-2.png b/docs/assets/checkout-scm/mode-2.png new file mode 100644 index 0000000000000000000000000000000000000000..760af30d6bef1a765cd178d37d9cca2e656f2299 GIT binary patch literal 57667 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 literal 0 HcmV?d00001 diff --git a/docs/assets/exec-managed-shell-script/demo-script.png b/docs/assets/exec-managed-shell-script/demo-script.png new file mode 100644 index 0000000000000000000000000000000000000000..8a239a75a7a60567c567845e4c0d80edf3cbe472 GIT binary patch literal 14783 zcmeHubyQp3w(ka7+**pZXbZGZ65QQdoZ=Fk-~`uT6-o=Gloq!@DelD`ihFT)clQ7< zeCIp&o-^+G&V6@`_tzUYVGBdRt42#a|-nwZ%_UFl4rmew#4hTZCV20CkV5e97@C3Yo8DX5k8 zOK)eWy0@~1nYXQ(fH{MhD4npUAgTd-sH+K`r@bA_MbJ}(;g5C&QRUm)Uxe#K9xL&&mCqj+32(7tAgI=HO;y=MZEU5M<|| z`}4zq>do2QLQqXw_RqdhS0W5nuC9)PV6caW2grj90a{Xo9TXC>=MD;8Ub+&egn#(#n*wg*-+Je@9Jr?%AZ12yy=6^jFj=!u6Mhymh zJGuWb*?+%%C!G`}We5j`cOi`&W-GOg^$nnLYY|mZFTy|GmcL{S7CQizU@ZEQgimbgQ;c^wRj$ zp*L1wjGKoC2LRYB2uyC5{%h2i82jHA#Az`koW6IyX*OMt^t65~TIEO#t;P2=unDDN}B9EgM1aMlj1-<)q! zjfnI7L<|6)0prsdi*+MUui84SD<0`FxK*`Rduh^nbyRU5Yxz47o!kWiG!5z3Wb9uZ zU>cRmr=5iKg7`{n5Z5oTY%^F zI52=c0PD^D)V5vcQUufU8XvnCrb#u5BP;eswW-GPcGq$S7X|q(1)tk%#wtH%@t+5} zJu~$kkxPW#$cLBe>Up$;4h#*`7m=^lxNBr|sl=#PnYC;T4LrhNqIqVkGW)iRc(kFyKAd7EGsBVwv!5k1v|&RV%CO-n{xTQ) zpJ#4S${BEvJX@26^pb)!-_mK2+@AjrshsOjN+1A>pE#4-r4@(6=WBF{@Mb4SZa3D= zt8L?*?BcdL>l9+3o2+GA&o_f9?XLB*8_$v-Sd4re$6Jw46v{$I!%w!K3R}V}@#sU_ zyd-i@_Ach{b`-#767*Q!&D$81hwhBUpaF&GVmn-SPJX4>S1TLhDY+fE9hUH9my{js z3QqH6jzr5JLktoH$*6LcNC*8Yd=&~-LKQSyuL(0Q9t!w8oi^z9e>gukRc_Hs_&AOa zL>q$r0pu-wmPY>AjJCE6IsA);YB~!wYmPTqZ(tXA%IiLi^%&%7LA6hZ9%i?2N0&)5 zk8-TAn|ysNyV)AkuY5IK+2xsLwTs*+)e9-DVkI9&_^awLzcKoBgkZwIFoYpzZ<{kF z*x7sg5i)d*_dcYihGCJw#1kx;rg#4ax*9p`vJ;2%eQ&Joz!E1kut+6qsDKn__{|p_ zhQquP(^RBfZAm$2%R8QHmP^0g85j4ty{O*Msx1$NiD(B875dI&sGB4*bx%i*Z&c`g zS68J(Cy852?7B=|OTK(0acOJLK!Ag5rcXvIKG5)%9Q$3d#pHFKORxP&Y!;_%BGiE!rhGBUDe0Lk z$Ui~-?Q3UIxzwpNGa3LNq_zL(4Qkb{Sdxs3EpS+_uRPAFtuzU!wzB)(n4U~yYHcs- zaE0a=IA`d%uQC)%tNwI9c&K!67G{gQ(3bo+dRfWysiCl9 zLxKIbNYkd(g3OE;d=4IY&vFEda%@c=9+DpaIQ<$W!Zf@GIm|coH(zA%g736G&NISd z>l?3--jT>ITCr8nn4cQ=+)L73PRR$Oap~O{Nk^BYpFIesb6F1V zR!#XJFg=jR!14m72T4B8u>! zMja}51XH1u_5gPMY7ljU!4o#dfj0v#)aZ@A6xg62R?&3x^SAC}_ckkOpS3&^G_a_F`aMri0AVpYJe_ELwym)rG&qfk9`d$`f6I`$ zP+c3k-uE%N8`p0F4d0G#PTOPI{b4D+=gI_KODdgj!ZlX$6LJP2%T+FtSxO~m2iNok(F+ZKSR zy)tsytBhM4ma~Ly*K1z{XOH9^20d_Zs!AkNc)NZAIr77tv=rwKHbK%jqc+E*$<(;y z!0N9jh-mVN36g?tXZZeylB~Y5M~v>fa&l9pTd9RHZ{EXfI|Jn|XE?~UmSt45nSI?J zzRk?KYMff1gcHj7^PXf_1fdO)nQvWYUcU8qeYJM*BSsAI#dhO_al5v3bArgw6>o~! zKguzt*!j&sPtUmS2S1v**rQJgow5ST+g`RKt$~ASb?=o(cv?ZUN5%XUU8~>0mW95U zCpG1+{m<`;jRM$r*1*s;w7iRA=r?;iyPDJ^2?3{G08%ut~LM;~j8RPlGi!>)7t zPv`oWWH#>4T@S~H3q?e>Rs>PEFHD*!qk72h;lfo&j>T9?Kw#tty+dlx_a6mIA-F60m?<0UO);bZh0Y!HDVHX06WZ+3K301@P)r+}8&nN2 z4shyf$R>EBj|l`+e@6WR$tu`)03bjdrS<-ypw%}rQdU$H?(hbsIO0n_yj1vDl1z-f zW0oo#nw^dsd>BU$-iQ^qVPHQG2bq9u>9*cQf& zg75)`j$No;{*C?chi-jD#DoqYDTHIz(Bv>eLqi|G_`G#YIRpSYPioV0v$Beci#G*G z90MD&4Jw_X3(X%12??d%JGx*2jzX}RnrF2+&skZEe~GI;D!_dP`4(3<(p8uCLr`c+ zoA!H1LhHwBo)I}~cN}>{k}cubrLwO)@^U#LYleW5^Bp$|E{OE7 zzBB@H5$Vvy3Q+Yw>v~Z8+YhV9V=nwtiF1D6Nday1Jh%90CPUCvZ3D_(RX|a$9!~oX2<-dh-rq zu+wGsv%KDDYKlep$IHkVxLSs)dUr^IE=s3|h||5hir|Syu#KI2hiBDxi#XQ1 zAKGCpLa%1!31sQ>OQ+QLbn2QXJGA(f4)*d7N4gKclo?D-%OqA>dr6#BHZ%a0s-F&t z{L1+Cb5@7Yf!|@L>ljrY@ZB#j3mA*6Z-g4t(wa5m?iIP8&O$_LYb_^)N=IGYC}w>5 zO5dx6Q>OIVfpwuvsG5%c5c)3ywtlBS;hnUD^6>@tHC7pS>!>b9ksd_NFuz{7w2_e0 zDe-F@J%egTu<|xW#$SU8kKfe%AmGlM*NY360}9Hqu~lX0i^GESFp7|$1Htd)j+Srg znY#B8Ut?o6ZEEOCLBT|-FKAbB(O{G3PePreKQE$-(^a(DO%fM%XcaHuKf~WhwW+@RO~87ZdB?teZ87o5pc5U&nuF^iYLSj-QHQAm_W*Z z#%o9(yu+G4xSnrR51S8+8-ynza)~AP50fkUY<|4CAWD6p(rOiY5OU0HYN0eaP%~Yc zz6H03jQ-+9sq5f0IN`SLMW%+Cvz3MJ`215bFMSfXirlo%*H%NOl~`GlstfBEBU<|I ze4}=3B|62Gjwuh&BmLqR%7mGtEHqq&hN9wddv*nf?6my_IofYQNaN3obQqB;jiF9A zEjMn;nDa{-n~Ro_$2}juwIMT&G-Ty`)nzUYqWq~in5z`4TN+)d-Hn(Eo3Kw4vUY!F zz~K;p#&tlAf$gctC_V91*z4ry&=j3G2m=`V;)pKLsPnED&iP-%5L-H8HElH~CFKao z6l_lI{C0fO;Fv+SX_oKK-4aXPG@;mnI{jIZQ+Y8o9_jIM)R?N@%Ar+OhPrS{n;uJ! zV(Dmc)d#6D^%>GhDRHUs5Aq811jP^ckY5!h$hJlT)t_pmM;kcIth*l8SerdBh$F)? zvmWz}Yald`dyK5Jp8Jdkybcg|L}0$bdq#Ed*lDv`c3UgOYY8 zhl1a-R^NHU_fkoBvndF%ixgf?OYR)p$NQb zSwWADp9hIhwn#fe0eW(NA;L|QI|P;|pm_eFL5Jb|x3j#T?7+JdooYSuS1BiG{DOj~ zPMNsnZB_VgdK${_jasfrZJ6Uf`hj;Yf2Ne?=o6{B!c}dcn@S$bOKFjz1k=hPX^pOM zkatppvVj1ukke++&F+B{T7afsCh^sB_S#v#Mwwd_#4zbzv9I~s#$bQ73ipw!Iw&_Y zYkMed;rwkLPg(bd8xServ}v2#8*MG7_z`ZnJvAGbh^cC1G})FiGNPeFk^PI=u5bQp z@I~C)zG3MCWkDC)o7w?wjZ#>m*pI@8Biki9REp)R-%q*5L@~aUm{)niSD&&?Sl+^2}K z>NcQV&ha5`g0?%cu6Fgyk)@fxjXQ<7y+mtWSe01j3-UmaO7Qt1PGvgGXkbRaI{#`; zro6$|dtE0;fR*{_^i)D(;Myu$l3ZVVmR-c+P*S;tJ%rz2!*-1OmD$!o)Wm}Eb3{m8 zLgNYL*x}58Oz{H@_uIi%I6RXgvKmIP#oL*gEmEfub$-;}@>x+~{r-MC*vs%4O&sZQ zoKdfXnKIJ2(xOe04`DzsIoRYk(|5&+B=P*&Y&R11$;DKB^5tzlcg{jKq0E{pCtT(n#{sTtXL89Ad4a?^DnIJhK=!w30_{_10`aV4gHPmp7pbEvIi)G z!fq(@6)7ocPd!@fH~>t^QhoW`Gn5)Xc!X|y+QT;ssdQd$4SlG17X?zrCMM*qjge>j zw>JN8JQyXaLv%C%phJO@|5^wjb60#Fx!5QT1kk?4A`zjpPA)2PT51ok6@5zu03koh zBY1dt+}+(pEYb6cbxK*a^A!{L`x_fg+KFMPFbJ%zC!R0OBN7UQj!0Z;LI0=B4v3DsUX5qj>R@A;5S&wq6bgDj|XebH-lbi zp;D)IBQ4h_amO5%t1|wXd?92r*JR_!niZieYALho-2R3B2O7|@ZEYxV;T-EFj{Z99 zsc(j#X7>mqjq<=lCw+L@timR3&(!J1M%?sXo#WCD+&)IJK@|3@`iw74$4A*)V)_K$E8`=8$c_Fj2kU_o$@hmwF%b=hiIN@coq&JLWl0bljIUTx${V?Oa zh($@uD1Pk2(L_zgm879%n6^d1bdvTY#4yHS<6eADS6FQPsZB#~*ELRM)l_twxr2Yb z&QaPMdM@D~?NfOt1U=&f@tk1ZauycuinRLQ>Jy`&>yKHDl6NxJ7&7*Y(NNQh zA93(z%LNZf-c^1-LF08}Qrpkc6_3MTX6N@EDjmNDc_fxwBtaTmrVWNl4Q6(W-b~pW z8wZ>dw(#G~S%wssUVP4Lu8@;AsnBcdL%Hg{P37?^BeoPI1xE5k-P-{bw z_G@@JtFm2U46lEzW_yFZW>p!UOXm^)C+gYKFGtCD;2fwZQSeesdM2ZVTC7Gpjfj=Th=+bS2>m0r8w9#lDX47h_eLa z;wtX0<|1ps%1jM=Se>=O{>va{LlJ2%T+z#H9Z~Iy0 zT*y9hnofu;6t8CJMV^=qzu#yRDDuVK*A9kB>+5k*=%vd9bfPA9=o-tv)W# z#B#p?+w7RIYayg|S5jTwPsiTVY9Kn%CTVsvw$9N^Ig+@_!0*Kn)-v?WH3oa~%rGr5 zF`tNZvxIbjIqSX(zaSx%6%R2Qjt-Y>3)>;i{WY^l}8D>j%VvUvsqa#wQeLr zPyJFUd$!-v?WjCuoji`^eq^=ZH>dPTfpGoht3t)>aaV?H2~i&zRSSjou>*6X&s0DK z6v#O~t@%Xs{g;-CdsG?oo!Ya_gik)TmF&-t&R2f@eoG2RCdAv?N+J-94$9t=Y`xsv zA3pOWuHLETXMHA8uQo>DA{6bi)=qgv)azG7iiOa2t&P1Or+$ymU&ns=oNM)n$M*ta z=%K`|u&NSu^IR-^koP{~cj39n>4-C3uX>WIb~ZU%l>W2UXEJG4YcOR&n(I4#%yC5Z zsWV|zD3h{KPczcXbQIFI67*^!)8j);r@TwbBRE zi~Iv8IxN1=hFsR!tqrI&#nyH)5gs4umu}9L6Fk1;{=E}dLYSrIpV{>CQ6hXNuj}=Z zAaiZm##JU7%7V>V&i?%&GfZe^Z$_{SqVr09vTenlXHzDX!r~g1@n5irLQZEw#c}DY z)vglS^k$I)_nj_&xcz|FljBkkZ{>^R=u}LUcE}z5Pe;h_KOG@2^D5b`XN6XNW!J2& zZ~Q3mwwh5ZE~7K+gDa5ts15n;eb=88tvw?2`ny|W;H#1PV=C;gNIV%FZu{LI#N3IXXP*L6_XO`##-&=V@8;n zb~>aoz(?+{YfqUve=ntWJ`RzcP=tgR=!_hk-{?v>$nqdfzK6zJ9ufKVclmcG=y2BfVPh#iD@#Vj^1sL9^k}^dv+7W-*CX|H~dU zXX1@9U;Ajqz4EQ&gs{3mVoS1aSSHx^26CL)m5rJm*|aodxU8lV+lYW4TeLI%^~ zK*gg^>dHEg-|l_k&N`rqoZ9AASd7x=A!^X@HdgnPH$j0Q?FI#XzUQhF+Losij{wXM z5ttAIgV-Q=YIe2_`=gb_3aBwlrzpD|*PtAB*xek6`e!mXPyIv#VFp>a+Cza_7MHFS zg+rr0u~@{M3y;dEOIYA|8ck7U6*8vMs{nJWW()Bpr_0w%@#N$*WLcXacAj1)yLsxH z;P%6~;)lFYLpzwJhSgEH&BlO~%x##{07;_$HV}KK%h?YEJ$Bk{M&YfcdDSA3Yn@W! zD{Dej)Vslz>Bd*gKeV`JThyiT$K!L$Ns`xX=%s37xcdgLt}E|ehlnKD1r93S%o&1e zJ)avbu#F}x%^R%d#WI&WJ9&`1t@w4}{p`DoiP;`#n&7`zd>bfj(=wfUlEx0n+w9}u z$9G}tyFByEnvWaYiY~=bfSh#z06vS@b<}H)-CD)LeML7dcz^ZzW{q!RcYHo!Q;)yl z2szHETMyLq-CLJF-?FlwnIoz!oR5cMw;6Lj)s}CJFVbHc72gc%&!g#QQ>Q=IDYw>7 z&JYoMgaW-1h+(o7Ym8GHK6p7Oxpk^X^gK{@_O3E@jsOaoEU;M5M=2B-=3L!_M^xiN z3Z{jt7r*-I-aHO#f?7CscN?2zgM1w>H=+mAY08qqWKr&&(W^0i%=e42cSC&B-F4y< zEo`{kxWp>PDrepaT&sVE(mvyvA(k~bsb=g6DO3}vVedmKwQNjvcZ=?*?XYlhDeTBo zge+hT^|ysiYq(L&gB0Cr{nZ;{>ZPcYvZ7fgsw=|OtEz;Th7hQQgnuBO4CY6cTO#5n z)(@%efHxeWKttahjAS!LV&h@q} zcuv>R=G+qm-GU3KXSg&qHUF@M8mUcLK4}L2h(#Ql)j^Z@7u4Y>-z7mcyoC00ysz8E?55ZluzaQ5)x@v3fYxcS# z#yIYtid!J+)p3%+=olEW?}Q5Vl2|fnFP0LRl}R5YvKeujNNx%>Z+tJG?G|)vf&Chs zc|5_Q|GZ{-csKuJ%46XxzuE!IgwzT(;(C!Br(1xh&*-jA4kDLeL7|8_6p0zK@SezM;G{;Nho*Jx2$DEpF_IDJQsxaNmS~|wJ$3O zA|H1LraB-`nU`V?nlA5JbEyxuTyq>XP8Dww7TlU0W zUjZ5tXkXXm&!sMW=)Y*mZ`eK&7jZR{MLF*hr`}XPi#$3NT6_@?f5~&Y_aa2Z)#>{3 z-lT^e_r18+YdA#Y^F8p6jeuWV09(wW^)3iEP&n zXz0pj(3Z>Dqj~u{&3I+xJlrHQT<&gh(?YaZE6y${Dx7T0US;TAc2>2RKpFP2O~b{{ zx%O=8d~@aCVqgL9*K)KAjmx8jwdG~TDzF{j#OkTO?;{mzU%1Lfhd-W^I>f=Z1sXTH zMt`7Yw$teJ-h)rXm3}zfMmdIG>jMr8n4H5rV!`A*T6hHKl>ySrOJUxsz}l<;0e)6L zGGMc278XX$(5js9eE5Rg1GhpF0M>Yj5^fHLjuB^yiFA1*BvdsA$4evhZAa&+Z3%BL_j z39mZ1Dj)DKE_+dSjZ0mfa|0WvS`-XcS3s%Nqw)F>F)_BQaN2tM`qH(b)fP0vna@qs zL3k+zQ8R{N_eVE3IOK`Dd?5UiDlo zEG$HwZbsow&i05ssoe+Hd+H8F`<>`&^6j2Y!ZStBt!;_KDCZfP(+(8W%E^19kDT^E z-z-0ok&6mxvca@g+g`}-bl>C$O-Kb=)qr5my*PJ(0FYPm($QTG*Oj3%l?egGBtiSk zry`mRjBoC#>P2TwG$ zI`3la8(tfs$hpPR2#mHgfEVMS8Xq&UyIZji3hWVW5LVszMN4Stg zCr);L>ld8sAC6(Q?Q+g>q28dn@3;e){W_$wz~IvI8}TWrXrWFH<1H4-8RJOAudG8+ z=1YHQTziK9-(#WxE#Vk!U`Oop=g+8}u)9el-X7&+X`u4*_U6Q?C`MT-{}5ohgP^al z97Us=a-{sW~?4})uS?pXk|3)Z((lm;{sNbBly^8`J zbD*Wy$ok;BTq1%G1lT+^ka5K_J0|LP1teKvq%l@@8| zRHiixXGJBBe0kl_V(={ZsNBamS<46ldw_};&2MZBL6+#Y0=5CaqsUN^XJY~2uhA;>(C zu13~c%@a-D^IJRg+1YX0E>21@7bnSgDm9JYXpMrVY}pY*@uQ%dMa9OT7h#Xi_go_* zLwh_yT;8Dt-fr0(jMAh@(>_-jg3P9aa$(UkgX;&yZ)`nwy7>;kBmkxa9r4KwtD&03 z_Lx_Nh9B6o$^49)_aeY~vY@8IN0PjNKsgJsadOh`1 zYbP`Y5Vw^klG*BB?-iU;!ay=w1G{#;@|Uph5n3^M{uJPiguiB&#kx>gHi zcS*J;ZM0}V?}g^zj$d7PM@seY1e|f;tly+?kLKz{|I|JF!0wkwRx7*f@0!!<{Df=0 z_`B5okasZ;F1+muGay-3r-DrdWrmaY*{(O;maA3B+4wnE^2#GWh8rgM0Tohlv&W?l6LW|cp#O-E-!C5tIuesx4R4}iR1l+Em97LYeMTp!L(dcnY*?d(&*@qUAmJPJgC*wp2xLNXsDZeq14Hp?Z6*RBFIetD{qLt$t}} zJINJj+R#*9@NxWl?Sc-Rl4)bladvdYoQ!bzt^(}V7IWiAC6ZFRMdAX`{QYSUJaE@! z>$l4jKQjL6)!8CleS3=4jlIMf)f~I?;H)P0(JtA=(JD#${7CZUu=wvwCAT@hVrs*& zL}93OwuMRCg^4th0jB~vj-`Xc*1WtSj?I&cBoj$A1_>1qzTIhJm0n(8ip?jJcw|kj z(oZ^w`K5P{{@Jx{R?CW!-YZ{Dfp132r~t%kQ~;uf73tW%NPWFSnXm`Z^a(&dc&qf{ zg(6iOK3W4Xr;kbDc+$xjuO4k@!2krX{)=RY|ADUgf2Fhjx1E243;(}xL-D_dQUB~w z`qvSv|E2ktfxG`@%m0`yIc9ASbOeZ0RHS^bsJ9B!)8Fgf1*YDx-Wf^4{BgrUgt8Dq zQ5U}6`3BS-&jG-Xdpfp{*@o;sHZT=Eqc{$?6hijIxplLM(*ceMlPgTrN^KxTpPMQA z1Jmb@yzruBN1<*ZQ@=0-{U6d?=Z-o*@^2Qj-toFzV7SAh?A$98)t-=F=P768Q3r>& zVZD-X0}VvO(OAPZ8V9MU6C{fN*Y?81*mSXoQsT|l1vBKm0?v>Aqdu_M$LqE4%ii@m zXRQ#A{XVc^JO60d%%px~&T20X#bMKX41|+A>UhCV@noNin@q@it%S)hS50w|#lo8P zI*0u&5kg}(bAmto9uN5-K6`xE4 z{ot4ESp1FiwE5gSG{Dga3tfQ3O6=NLs`KOGY{%|qkcXequC;@0*2mgf?gl241!*+Y ziJ3d-ZsNZu>xrgo&n$^XeY9Eq;~F$BYl&y$ui2U%S6`%mMvmW5Z4VV)?{S0`9JN=SZS0-}tM$Klnc=FYm!uBbXWnYHJ&Gk6oVRE*i(Mc}3R?nYb zKrWV8xX-u2k$&;{c=Hn!sS$B?SsYE~jZCeNfqhC^yT$6ZC&t1IE9LHWe5H52YgC+8O?kS_wiO xS@1ks_dum81YkY}8jfg}(*C{3C)=>!%8X;P$@(4zkMuz&4UhMnDZd4QDx! zJy_Wj4$||~)VKC@v=+5tl9pnSa2LZnZ~`H%7~Gv8P=uJfB-2m7V)*y#ZeAvapDIX4 zNv3~+daj|(AP0kk7=S#&+}8Yp&lp5RdH8{%&jf_H7zFtEg?afzdHIF7`S`{7M8){{ z8UFk+;YWkp*ox`OEB-kaz9q?Ik3>3)@$#ZjC?1p`4-9U{%P%S_%F8FfDK zT0V;xRNM^}ykF#fEEAp@>e+2i znvJRbet&q2**t`_E|ZM#4ge5+T3NRCTQzl!x9W)j&Yc!8vmU&o{D>F=cHZ8OcSzXzWcop~#*%JeAlJ7snD)0tZCv@vq- zqs^=0pha_@d7>BoHvj+w4=FoGh41C&1(OK%(HPPYAVwisyRMt*S7XK*-TbF;;MmC49AOM#d1%Amk!7(cRG#3-flvZ%7&s?!J+V9hrhYO!QHVc9bFgX6U{gL&4i zJ1GSzX6p{-SH9M`$^4nlM^R(RPH{q2z$w~@fGy3$MQ=`AMoso7oK2*}-C;5SpiDPD zXiQ7s{+YE;@QpyZ~tTqeEk8a~drFU|1!Ai5ZoZarZ_2QW7(84mRS%Bn9K! zoLH)~3ofMDe$(nQpXM>)y^{t1L-Vc2HF%pJrtm-pJ<^_C9f| z5BE^^hNZHpIyTa%SB`%~ex|faH#eg~RAzL}q#mFCV)NCh;hU;pRg|Zj-n$=8idFe# zD_&w%v^~tHNj3n$j(bTw`^?$Uhds~n5wV$=VRDjRO_znZ8?39UKGwU$ zu`NT5NY?|s_Um1bcwKw6LxhE84-0zmX2&Bb-5+KZLFyt4d%;I$vnvTCqqOb)O~agZ zMa4S2*mn+);J12|EBy{@&*ThygbwY@{S;oS;Ai!VBv4WkZG0Ch2*PJA4xcE1h1O%s zn*hKc9C*?EB6+UmI*DI!{?!F25Wu&83NB2|9MfKi^!W`eKFGcl!+iHsiPPKdO7Sgf z(&=l%f6n&*JQtpi8F+kk6o=-qy??%JpdpF0v^_OT4Hdv{*;?Z;X2jcmRiKT4jo^a` zqKC@C9@dg%w;xMg&e!(ozbhkYbxB1D%v9Jp=H|w%1tlq*7v6#>b_?B8cFM4okbM0< z;ldOvIRdexemXLdJfyPw9m#vLTT{``_{)1AGS*MMS`6$5(L*C9d18kkeNcz*$H}sn zux(HZaK!kBw8)^09`}Ix~ z5c2$8XBVv*VG(IVUYEI%nhyi9R{doZwG8_U#?giXn5467Z=SYI>=79XE4#5Pbk-{r zVS^J2x(pzbvk~|a=gRYfVl}!G>FZ9$8r|+fV)9Obv858;h`i~75&7OAvR{oHPtHp$ zZV`V=zeR6k9P|TY_q1Nh3)e_;|9)k3{SFL>GAd+?PrkD!M1%rDgiFnwfE9P$SGUOPNK(nJN5M&V;(~vj`PPsjy%uJ-E;Hbv_r2J+cK+m#M3i~^!M13jfoq-J zwv3S8?e)!DG~u-dvC9|&h+;s{y&kDbrJ{VWQ;@GL}sM%aWtYwB$k*#%Qc zF*4zK*JTLRXeT5}ObeXRL7y3Qru9%_%0QtoZas`dYqD)v&N6TRZOh0G+9~jHs@lfG z&l7j`Hv4ykTq^X2qEa_ANSfliQNH(l83Jb^3o8@@x6;M23*_Eagz38->PEB8?zx8N zK6W%^fvhU~^-^=o&dsAmi9VqpUi~4M>Mz)OD-Jg=lHV=bTw@b`Me3R~K*` z0a4^NK8U>q+Qx=VpEn=8j``4p+>c#cjPHuXO-iw9h}R#Vh}UljzQRryPNt2cBU3M2 zXluB}K~67>>cpi5TxTL?BL*CVYV!Is*DLl}Q%ni(^i!2HyNj#SDrUD>6suPp#+17z z#-m$>e0(f79! z(y!*qFmHEIC%Nb{?;?E=@KD`@kghqXAu#o3RwPKg=I_aVPu?l zDkovU@AJ?hXV-KlaR%{zmMWOKT}?r~AWkbFkdeh0t>He5ZrY-6+}bEkavlV4=*>P6 zV(J>h76-r0ds7EuJK&JmJy2#J!CGs-TL6b`L!KN=ynwIX+wGt@G>A&{dbU;jUN`TIQ_r~_L*Y3Y8qD_dOLFh%rRf4CgCZdecHHFqU z>BSBrHtm~InI{6H|J;?2LxeeJVA7-sq2ZMx*Mns%T9UNUp@+}%o3#m#N zmW+d5pFDjkXMh>UX7^idd3H)j9gRo;_ca?ax-3sPYo_8|XqAsg*Rvr3znJxDW*kmV zya#lYOe!9nan!o67+3fGxX;Hni_Z!EDTetmL}Q9HvEb~? zs<>*HzW81(xZc<3WTwtNhUhzAxW^)`(VClcWc$0u5B7sAWJIJ40)!-EJ+g@3D{8%S z_hZpsIU6n*bjfV5#k<<@($b>CDj@fxN`8gpHwTr7kc7`~R+QpD*SYBZ8n?DI%wVM) zYLr3Qk=_9M@rd-3lD)iY3u`)keh@9<4e@iNzU3W*TqF&OiB=Y*QX-1dvIHQM$a zZrFDa6r3y&k2C{xPSba&f^({7>k80BNRtPq zV`FVvS83D9E9H&!D*U%Ol{BL%l69w=FIT;$vYkFbX!rs(vra`l_IbS}qyX3xJ4q*R4#t)H@(oCP2 zwRAL^v%8yPxYqFodSNVnG~S8eoG!vM^idT2NU|?EGl!4K3dfH>ezV0+g_M8OT^L;r zWQ?KyfZ}SS&-`4K{`Ekad*p4onne}X-yYY0yGU}ELpAWkHu>3J7>E`=DCQWpEzmm~ z^Jst^1poSVQAs4E#wjD$|?|zs)GJ4S_V-9|oBhB6L$}8|=C@t;wn=KNr99{`J z7rmCaHRfT4Wck7Y;(%R0MP;kWPT4h6EwCriU4w7^6v`(*mxFVRvwF&m{?RTw@+C3* z?qnK|w9b{KtLo#uFdA9ITy5c1I+*36wS*pLJ>^qAw)5VnCm4QySJ?cPC8toZN_nlw zp4*;e)FH;eu$pC_wUlNHe#yAFR$epFdQAF?Tq41uCMhIvy2|(rgcH&+W#bxkDmB4L zOqWOWyvT8A%MLZ_xBTEU7@*+Squ=Dvte!jP#4&xS{S%#?`+WT|^!G?fx&g(R$~ zE4xs)YqX#1lcwI@jf!h4d;lFPyghILH?>imEhEcm)Dpzj4W`*pICNZJCVLN=bhT-5 z9^qd5o=0r4l7&b!_c)a^_Q-5GZrU4%b4nCX8r)v`ZHjC?w{qC#i*DtS6 zF=I4eJ{lWH|D>u~5g#GpROAf}DFgjURfugsfw?6AB^;*ZbiRHg^uxS=?-6azOHt*0;N&P;Rft}ZfoYqq4mUxP^Sb2=F;XCb-Vz-Q{YEWTxxy9={1Z~OA z>kDNa&@ku%JgZ=hSeSlTEoeoi4wTqiZ&+Q#(5_6)3=>f0+!hJWU3uRzxhQ1@ONDAo z(pX!p^o*MD$5rb!@Wg@hN6u+{ocjmArV{7(XSwGmWn3RPnKp%L$WBk>V&8q1f&z1$ zeBnr+l&&#?73NLtT3Fy4Z*2*u&ry9W?su$?V`SM8V%Z9riB<3*h!E>+u8=N`Y?=N? z?eC@s>eUGxh0ppRj@m4BlKs-y6VEq-=9hZf`j!|y&dsjY>d<%nkWg{yvCCes!)2D` zr@6>fj^Tpyk%I%F8(-*c^YYJ*W?%X235_>73OZFg&Tb}MEm+P=Typ>NyQe*3yt^Y< zBV1Ujq|)u!y`fws{t|cVgdXyxAVdl$ZY^%GmqR7kdJ?M6(b9kO`Ns77Le+3;WA*7P zRcL8oBNYph(DXf9_xB<%ebARR1v`~P2G_C=_FaJwf8*2f^Mj7Qa-uV`yt={V7G@G4 zTk+*3MZTjZ`-@0kJ^d52^e6GjK4f&Le^tZ(wSdkxi+_AOU>>&K$a%8b8d$APek>hj zce4MSMg{x=yu?ktNV5T%YyckI=fQtk-pqvM7=LE+a?Z3R0yGPhbes32C)O)QXX%5U zz~Z0?GqgcP{i0dQVPUyf*QrCR$K6;`Kq-dhq>ruRN@vYNx=L|eW<9|wUH3O56OYs$ z#HUdX7+pce>AY}^{tU80#?%pEAi}PYQ~>~JO;^I3ZH1o8N(a%MejWPuNNM1*h|0jw zN^CosT|8n6{)cS)%uC?mVCGa$`7vuPVbRK#!~GnAVML*R^Qa)KsFsxbeaw-RmzSAn z{l~MUKJh7~4aN5gu;Yddr5P^Q)cnEcHMenn4V%HD*2JFusT|#GVH_e4A0KoWj*2dZ znMp2U`nbE_RyT-Su%q`MRl-}F54Ba&6Q9pw+PMzWMhoyCO1CCc*#i#U23>VWg{V$x zr&4X>ho~u4(<(}FpvU7^?`7K6NVn;25CV`D6DYo-m8b4(dx)$mEa=@D+@8G!OF=pI z6{|cf1(Z%}bh@r1U3P!mmAxyyE<*jl$o1TFA^gXWxh7*H%wk%Indj;PDm?N*M@^95 zhDf76YBy>=q2qGE)-b<*=Z6q(LKN^#00k57yd7{XF<9GAE#qBONc-l2^G?u3|r(jE2uX zs|RVmBv|6c?4VZNFvv4QdnI)Ys^;WS4KkKnjCSWg@CS~S2iT3AgRO9WyKPN~b&ZRy zKBMr+#Pi*+pQ{PwxGm0QuF%Qz+s`gq^wWs>+MyK%i(@0JMp_vC`wq7OrM<*gWL4hP zhV14tU&=`cc3ql$fxe@{S>r}swLt`RDQIXaVpc1!o6|txvlQKhS@5(;eBGn@%7aJS zkB8o@a#87>rDVyZ3i6@m@s&q;A?vJt+UDNd9LokSiG;M0m8hwb7e^-_+ScUy?-a>v zbJ!XUz-Nh$hp{o@qg$E|hD&GS0?kt@`_#tIvuA53H4!j%!kG%QrtqFRd&H^cDhqCA zXVgP#AnNN*u0p}Kt8hLdseYC}Dym^D-SanAZj2Y-NNb}f8fkkXwO+}8nG~w*B$2=2 zSTuziG;qzfqTHIf*}PY+>)dmmhN&gpI+bX1&{en~vEz=jiBGQF+ixePUb%O4fNRGD zQGhd>BRjCrh1=_74Xg^eehweS9)7`U*IrqMOAU>Ovu;Y`%V-2 z+$)jZZ9$J&Celz0#spHg#sBe8e0J1`o-9%``{PFebUpPTJJihB3~P9gQryqvb$Ukf znpWiU1NL|pCYRWljt!Z&>J+Ex6R;u!!p_BZb>(zVR0Gyib@-B!z(D~24bEC&!ohlW z`>25Z%-ds}(~`S7gIjLFoitaOk7_(EDB?;`mShnkM90hmbYcNA|F(niso07JE&5w! z`W~^J-dl1gs@Oso;tXhANV=B#Ol=rVi4E$`#~*GnH5D3;Q`Lt5!8TkkR$TKY0P~fN zGAY5oVSS`+HoO%$K(SE1xm`(0%3DS#dYS))JXp(b``+?qn3%mynYW#Lq)^6Mr}BfIBQVuDzw`n2Z3crh#1ST%Y1Vu+X1)d2UE3pgEZSJVBUf-RU*($4>|0%MJHb1 z`8u-m;Ol{2N+f2PotF0!?w4Lv8-~l?@9}yo*5>6o_haZm+a?DG3`1JGZ0gp9ade%^ z5;tx!PYh4Spx0bq4>Ju3J&eYhztx1Rm(g*pvNI7Q zTR*WrN)tgpKRsJ7QN}`q9)6vOato3Q=<}gdS-Nlq_%GjXS~>xFEk^R9a4lN1xYH|% ztGfeg(If!>cJCrGEq`zaF2>L2)vaUz;5nHg6Mx_9fRk8^g-YDZ#8}jJC3~p$ol5Ah>9j87J;i+%bp5m%FaF3dLpmP4P9Ev0{zYIHfg9Oiu|<+oU{K2}pz%D4BD z>zn7k1rB;X-u*c-zR;F#pXb&!DJCbZUR!c;)-vp8@Z!+BYdFK=B5F=5$ymx|bB<@a zMytWC;xOaVd!bdH^qPjHDO-58nt+pmf_D*1q6G(zcc-5`4({3WiNaYkdE_6|PM0ik zP?1phF<$d;a&BRJAtEq|lSxuKQfz}+jZQMI*>$~@eJivJ7Z>;jfvD-!T3rb?y$0jo zd2^eS7nM<8auIxCH{oshtn7OS5EU<*oSK-b^hShF=1WF2;qi7z#-bS!0Fc)mO+eYy zAgr3sGvimLN`L0nePN3jAU_)m+zT>*wSpGTYQ6FJzwI_Z+5nlq zQ}WyA_o%a3M8pt?&dt{R058U47t30{{vUg(-#h>S literal 0 HcmV?d00001 diff --git a/docs/assets/tutorial-setup/shared-library-002.png b/docs/assets/tutorial-setup/shared-library-002.png new file mode 100644 index 0000000000000000000000000000000000000000..022cee5a0b3004c047e06076fb05001f960cbd75 GIT binary patch literal 82386 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

4QXUVFIluCBfw@g??K{vmJnt<}|!Yf{A_ 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 RwLKA$oV3!5GRZf7{{bz74oCn1 literal 0 HcmV?d00001 diff --git a/docs/assets/tutorial-setup/shared-library-003.png b/docs/assets/tutorial-setup/shared-library-003.png new file mode 100644 index 0000000000000000000000000000000000000000..2ed81d8250c5faeca2c9d8461ead444debd27ed3 GIT binary patch literal 110021 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 credentials = parser.parse(credentialJson) + // try to find matching credential and return the credential + PatternMatcher matcher = new PatternMatcher() + return (Credential) matcher.getBestMatch(scmUrl, credentials) +} +``` + +:bulb: Refer to [PatternMatching](pattern-matching.md) for more +information on how the `getBestMatch` algorithm works + +### Step examples + +If you have retrieved a +[Credential](../src/io/wcm/tooling/jenkins/pipeline/credentials/Credential.groovy) +object stored in the variable `foundCredential` you can use this for example in the +following ways: + +#### withCredentials +```groovy +withCredentials([usernamePassword(credentialsId: foundCredential.id, passwordVariable: 'passwordVar', usernameVariable: 'usernameVar')]) { + // some block +} +``` + +#### GIT checkout +```groovy +checkout( + [$class: 'GitSCM', + branches: [[name: '*/master']], + doGenerateSubmoduleConfigurations: false, + extensions: [], + submoduleCfg: [], + userRemoteConfigs: [[credentialsId: foundCredential.id, url: 'git@domain.tld:group/project.git']]]) + +``` + +#### SSH Agent +```groovy +sshagent([foundCredential.id]) { + ssh "${foundCredential.host}@localhost" 'pwd' +} +``` + +## Related classes +* [Credential](../src/io/wcm/tooling/jenkins/pipeline/credentials/Credential.groovy) +* [CredentialConstants](../src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialConstants.groovy) +* [CredentialParser](../src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialParser.groovy) +* [PatternMatchable](../src/io/wcm/tooling/jenkins/pipeline/model/PatternMatchable.groovy) +* [PatternMatcher](../src/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcher.groovy) \ No newline at end of file diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 0000000..64434f2 --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,188 @@ +# Logging + +The pipeline library provides an own [`Logger`](../src/io/wcm/tooling/jenkins/pipeline/utils/logging/Logger.groovy) +since pipeline provides only `echo` step at the moment and is quite communicative. + +At the end the +[`Logger`](../src/io/wcm/tooling/jenkins/pipeline/utils/logging/Logger.groovy) +also uses the echo step but it filters out messages you don't want to +see the whole time. + +## Table of contents + +* [Initialization](#initialization) +* [Features](#features) + *[Colorized output](#colorized-output) +* [LogLevels](#loglevels) +* [Examples](#examples) + * [Example 1: Do a trace logging](#example-1-do-a-trace-logging) + * [Example 2: Do a warning logging and hide loglevel below](#example-2-do-a-warning-logging-and-hide-loglevel-below) + * [Example 3: Log an object](#example-3-log-an-object) +* [Configuration options](#configuration-options) + * [`logLevel` (optional)](#loglevel-optional) +* [Related classes](#related-classes) + +## Initialization + +In order to work properly the Logger has to be initialized once at the +beginning of your pipeline script: + +```groovy +// do the import +import io.wcm.tooling.jenkins.pipeline.utils.logging.LogLevel +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger + +// initialize the logger with WorkflowScript reference (this +Logger.init(this, [ logLevel: [LogLevel.INFO] ]) +``` + +The logger needs a reference to the `DSL` instance which is the `steps` +object in a pipeline script + +## Features + +### Colorized output + +Since version 0.11 of the pipeline library the Logger supports xterm color output. +The text remains black but the log level part like `[WARN]` will echoed using colors. + +The colors used are from the 88/256 colors table. +:bulb: For more information have a look at [https://misc.flogisoft.com/bash/tip_colors_and_formatting](https://misc.flogisoft.com/bash/tip_colors_and_formatting#colors1) + +The colorized output is enabled automatically when the ansiColor wrapper is used. +:bulb: You can use logs within and without the ansiColor plugin in the same project. +The logger detects the wrapper by checking for the `TERM` environment variable. + +## LogLevels + +The Logger supports the following LogLevels (from priority low to high, log level / color code) + +|Name|Level (`int`)|Color| +|---|---|---| +|`ALL`|`0`|`0`| +|`TRACE`|`2`|`8`| +|`DEBUG`|`3`|`12`| +|`INFO`|`4`|`0`| +|`WARN`|`5`|`202`| +|`ERROR`|`6`|`5`| +|`FATAL`|`7`|`9`| +|`NONE`|`Integer.MAX_VALUE`|`0`| + +If you want to show only `INFO` and above (e.g. `WARN`, `ERROR` or +`FATAL` set the log level to `LogLevel.INFO`. + +If you want the logger to be as communicative as the pipeline is set the +`LogLevel` to `ALL`. + +When you don't want to see any log message at all either do not +initialize the `Logger` or set the `LogLevel` to `NONE` + +## Examples + +### Example 1: Do a trace logging + +```groovy +// do the import +import io.wcm.tooling.jenkins.pipeline.utils.logging.LogLevel +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger + +// initialize the logger +Logger.init(this, [ logLevel: [LogLevel.TRACE] ]) +Logger log = new Logger(this) + +log.trace("I am a trace log message") +``` + +Output: + + [INFO] [ScriptName] : I am a trace log message + + +### Example 2: Do a warning logging and hide loglevel below + +```groovy +// do the import +import io.wcm.tooling.jenkins.pipeline.utils.logging.LogLevel +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +// initialize the logger +Logger.init(this, [ (LOGLEVEL) : LogLevel.WARN] ) +Logger log = new Logger(this) + +log.trace("I am a trace log message") +log.warn("I am a warn log message") +``` + +Output: + + [WARN] [ScriptName] : I am a warn log message + +### Example 3: Log an object + +:bulb: Logging an object is limited when running in untrusted mode. The +`Logger` may fail to the the class name of the object to be logged, but +the `String` representation (`toString()`) should always work. + +```groovy +// do the import +import io.wcm.tooling.jenkins.pipeline.utils.logging.LogLevel +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +Map config = [ (LOGLEVEL) : LogLevel.DEBUG ] + +// initialize the logger +Logger.init(this, config) +Logger log = new Logger(this) + +log.debug("This is the config: ", config) +``` + +Output: + + [DEBUG] [ScriptName] : This is the config -> (LinkedHashMap) [logLevel:LogLevel.DEBUG] + +### Example 4: Colorized log output + +This example will output all log levels with theis colors. + +```groovy +// do the import +import io.wcm.tooling.jenkins.pipeline.utils.logging.LogLevel +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +Map config = [ (LOGLEVEL) : LogLevel.TRACE ] + +// initialize the logger +Logger.init(this, config) +Logger log = new Logger(this) + +ansiColor('xterm') { + log.trace("trace logging") + log.debug("debug logging") + log.info("info logging") + log.warn("warn logging") + log.error("error logging") + log.fatal("fatal logging") +} + +``` + +## Configuration options + +The logger has currently only one configuration option which must be at +the root level of the config to be evaluated. + +### `logLevel` (optional) +||| +|---|---| +|Type|`String` or `LogLevel`| +|Default|`LogLevel.info`| + +The log level for the logger + +## Related classes +* [Logger](../src/io/wcm/tooling/jenkins/pipeline/utils/logging/Logger.groovy) +* [LogLevel](../src/io/wcm/tooling/jenkins/pipeline/utils/logging/LogLevel.groovy) diff --git a/docs/managed-files.md b/docs/managed-files.md new file mode 100644 index 0000000..edc2801 --- /dev/null +++ b/docs/managed-files.md @@ -0,0 +1,114 @@ +# ManagedFiles + +The pipeline library supports the loading of Jenkins managed files +references from json files by using the [Pipeline Utility Steps Plugin](https://wiki.jenkins-ci.org/display/JENKINS/Pipeline+Utility+Steps+Plugin) + +These references can be used to auto lookup ManageFile ids based on +patterns. This can be useful to provide maven settings based on the `scm` url. + +Based on the rules for writing libraries these json files must be places +below the resources folder. + +:bulb: The library only works with references/ids so your config files +remain safe in the Jenkins instance. + +:bulb: See also [`execMaven`](../vars/execMaven.md) step + +:bulb: See +[Extending with Shared Libraries](https://jenkins.io/doc/book/pipeline/shared-libraries/) +for more information + +# Table of contents +* [JSON Format](#json-format) +* [Using Credentials](#using-credentials) +* [Step examples](#step-examples) + +* [Related classes](credentials.md#related-classes) + +## JSON Format + +In order to parse the files correctly they must be in the following format: + +```json +[ + { + "pattern": "git\.yourcompany\.tld\/group1\/project1", + "id": "group1-project1-managed-file", + "name": "Local maven settings group1/project1", + "comment": "Deploy maven setttings for project1 from group1 for nexus.yourcompany.tld" + }, + { + "pattern": "github\.com\/wcm-io", + "id": "wcm-io-maven-global-settings", + "name": "global maven settings wcm-io", + "comment": "Global maven settings to build wcm-io projects" + } +] +``` + +The properties `pattern` and `id` are mandatory, the `comment` and `name` properties +are optional and can be omitted. + +## Using managed files + +In order to use managed files inside your pipeline script you have to +* load +* parse and +* search for a managed file based on a pattern + +:bulb: The pattern is treated as regular expression + +The Example is based on the `execMaven` step. +This step loads a json and matches the incoming scm url against the entries to find matching settings ids to provide for the maven `shell` call. + +```groovy +import io.wcm.tooling.jenkins.pipeline.managedfiles.ManagedFile + +ManagedFile autoLookupMavenSettings(String jsonPath, String scmUrl) { + // load and parse the json + JsonLibraryResource jsonLibraryResource = new JsonLibraryResource(steps, jsonPath) + JSON managedFilesJson = jsonLibraryResource.load() + ManagedFileParser parser = new ManagedFileParser() + List managedFiles = parser.parse(managedFilesJson) + + // match the scmUrl against the parsed mangedFiles and get the best match + PatternMatcher matcher = new PatternMatcher() + return (ManagedFile) matcher.getBestMatch(scmUrl, managedFiles) +} + +void getGlobalMavenSettings() { + ManagedFile managedFile = autoLookupMavenSettings('resources/managedfiles/maven/global-settings.json', 'git@git.yourcompany.tld:group1/project1') + echo "Managed file id: '${managedFile.id}'" +} +``` +The result in this example would be a output in the log like: + + Managed file id: 'group1-project1-managed-file' + +:bulb: Refer to [PatternMatching](https://github.com/wcm-io-devops/jenkins-pipeline-library/blob/master/docs/pattern-matching.md) for more +information on how the `getBestMatch` algorithm works + +### Step examples + +#### configFileProvider +```groovy +// load and parse the json +JsonLibraryResource jsonLibraryResource = new JsonLibraryResource(steps, 'resources/path/to/config.json') +JSON managedFilesJson = jsonLibraryResource.load() + +ManagedFileParser parser = new ManagedFileParser() +List managedFiles = parser.parse(managedFilesJson) + +// match the scmUrl against the parsed mangedFiles and get the best match +PatternMatcher matcher = new PatternMatcher() +ManagedFile managedFile = matcher.getBestMatch('git@git.yourcompany.tld:group1/project1', managedFiles) + +List configFiles = [] +if (managedFile) { + configFiles.push(configFile(fileId: managedFile.getId(), targetLocation: "", variable: 'MY_VARIABLE')) +} + +configFileProvider(configFiles) { + // some block +} +``` diff --git a/docs/pattern-matching.md b/docs/pattern-matching.md new file mode 100644 index 0000000..f97a549 --- /dev/null +++ b/docs/pattern-matching.md @@ -0,0 +1,85 @@ +# PatternMatching + +[Credentials](credentials.md) and [ManagedFiles](managed-files.md) +supports pattern matching. During pattern matching the `pattern` defined +in +[`PatternMatchable`](../src/io/wcm/tooling/jenkins/pipeline/model/PatternMatchable.groovy) +objects is used as regular expression and evaluated against a search +value. + +This mechanism is used in [`execMaven`](../vars/execMaven.groovy) and +[`checkoutScm`](../vars/checkoutScm.groovy) step in order to auto lookup +maven settins, npm repository settings, ruby bundler settings or +credentials for repository urls. + +The class +[`PatternMatcher`](../src/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcher.groovy) +can be used to get items from a PatternMatchable list. + +# Table of contents +* [`getBestMatch` mechanism](#getbestmatch-mechanism) + * [Example for basic matching](#example-for-basic-matching) + * [Example for specific project matching](#example-for-specific-project-matching) +* [Related classes](#related-classes) + +## `getBestMatch` mechanism + +Given a JSON at `resources/credentials/scm/credentials-example.json` with this content + +```json +[ + { + "pattern": "domain.tld[:/]group", + "id": "group-credentials-id" + }, + { + "pattern": "domain.tld[:/]group/specific-project", + "id": "specific-project-credentials-id" + } +] +``` + +And you loaded and parsed this json by using the `JsonLibraryResource` +and the `CredentialParser` by using this snippet (without import statements) + +```groovy +// load the json +JsonLibraryResource jsonRes = new JsonLibraryResource((DSL) this.steps, CredentialConstants.SCM_CREDENTIALS_PATH) +JSON credentialJson = jsonRes.load() +// parse the credentials +CredentialParser parser = new CredentialParser() +List credentials = parser.parse(credentialJson) +// try to find matching credential and return the credential +PatternMatcher matcher = new PatternMatcher() +``` + +### Example for basic matching + +When you call `matcher.getBestMatch` with "git@domain.tld:group/project.git" +```groovy +Credential result = matcher.getBestMatch("git@domain.tld:group/project.git", credentials) +``` +The resulting Credential will be the `group-credentials-id` object from +the json. + +### Example for specific project matching + +When you call `matcher.getBestMatch` with +"git@domain.tld:group/specific-project.git" +```groovy +Credential result = matcher.getBestMatch("git@domain.tld:group/specific-project.git", credentials) +``` +The result Credential will be the `specific-project-credentials-id` +object from the json. + +Even when the first entry matched, the more specific pattern from the +second entry had a better match, so this Credential will be returned. + +## Related classes +* [Credential](../src/io/wcm/tooling/jenkins/pipeline/credentials/Credential.groovy) +* [CredentialParser](../src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialParser.groovy) +* [ManagedFile](../src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFile.groovy) +* [ManagedFileConstants](../src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFileConstants.groovy) +* [ManagedFileParser](../src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFileParser.groovy) +* [PatternMatchable](../src/io/wcm/tooling/jenkins/pipeline/model/PatternMatchable.groovy) +* [PatternMatcher](../src/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcher.groovy) diff --git a/docs/requirements.md b/docs/requirements.md new file mode 100644 index 0000000..517dd20 --- /dev/null +++ b/docs/requirements.md @@ -0,0 +1,21 @@ +# Requirements + +In order to use the pipeline library you have to fulfil these requirements: + +* Jenkins 2.46.3+ + * Config File Provider Plugin (Version 2.16.0+) + * Email Extension Plugin (Version 2.57.2+) + * Folders Plugin (Version 6.0.3+) + * Git client plugin 2.4.2 Git plugin (Version 3.3.0+) + * Pipeline (Version 2.5+) + * Pipeline Aggregator (Version 1.7+) + * Pipeline Utility Steps (Version 1.3.0+) + * Pipeline: API (Version 2.17+) + * Pipeline: Basic Steps (Version 2.5+) + * Pipeline: Groovy (2.34+) + * Pipeline: Job (2.11+)) + * Pipeline: SCM Step (2.3+) + * Pipeline: SCM Shared Groovy Libraries (2.8+) + * Pipeline: Step API (2.11+) + * Pipeline: Supporting APIs (2.14+) + * Version Number Plug-In (1.8.1+) \ No newline at end of file diff --git a/docs/tutorial-setup.md b/docs/tutorial-setup.md new file mode 100644 index 0000000..da0cd34 --- /dev/null +++ b/docs/tutorial-setup.md @@ -0,0 +1,364 @@ +# Setup your Environment for Pipeline library + +There are two ways to start using the Pipeline library. + +**Variant 1** (not recommended) + +Fork the repo and add the json configurations for managed files, +credentials etc. + +**Variant 2** (recommended) + +Create a separate repository containing your pipeline library and add +the configurations there. This will keep your setup clean and ensures +maintainability. + +This tutorial only covers Variant 2. + +:bulb: Before you start, it is recommended to read +[Extending with Shared Libraries](https://jenkins.io/doc/book/pipeline/shared-libraries/) +from Jenkins documentation. + +# Table of contents +* [Requirements](#requirements) +* [Tutorial Background](#tutorial-background) +* [Step 1: Include the Pipeline in your Jenkins instance.](#step-1-include-the-pipeline-in-your-jenkins-instance) +* [Step 2: Use the library in a job](#step-2-use-the-library-in-a-job) +* [Step 3: Setup managed file and credential auto lookup](#step-3-setup-managed-file-and-credential-auto-lookup) + * [SCM credentials](#scm-credentials) + * [Maven global settings](#maven-global-settings) + * [Maven local settings](#maven-local-settings) + * [npmrc and npm user config](#npmrc-and-npm-user-config) + * [Bundler config auto lookup](#bundler-config-auto-lookup) +* [Step 4: Add your library to jenkins and test auto lookup](#step-4-add-your-library-to-jenkins-and-test-it) + +## Requirements + +Have a look at [Requirements](requirements.md) to get the library running. + +## Tutorial Background + +In this tutorial we assume the following background. + +You are DevOps Engineer working in a company named "Company" and you +have the following eco system: +* jenkins.company.com - Jenkins Instance +* git.company.com - Git Server +* nexus.company.com - Sonatype Nexus artifact server +* npm.company.com - NPM repository +* bundler.company.com - Ruby bundler repository + +and all services are reachable over https. + +## Step 1: Include the Pipeline in your Jenkins instance. + +There are several ways to include the library in your Jenkins instance: + +* Global Shared Pipeline Library +* Folder based Shared Pipeline Library +* Project based Shared Pipeline Library +* Dynamic loading from SCM within your pipeline script. + +It is recommended to use folder based shared library! + +* Goto https://jenkins.company.com and create a new folder named +"tutorial". +* In the configure screen click on "Add" in the Pipeline Libraries + section. + ![sharedLibrary001](assets/tutorial-setup/shared-library-001.png) and + configure the pipeline as in the following screenshot: + ![sharedLibrary002](assets/tutorial-setup/shared-library-002.png) +* Explanation: + * Name = `pipeline-library` (you can of course use your own name here) + * Default Version = `master` (use either master or a tag) + * Retrival Method is `Modern SCM` and we are checking out from a git + repository + * Make sure to check `Ignore on push notifications`, enable `Don't + trigger a build on commit notifications` and add `Advanced clone + behaviors` with `Shallow clone` enabled + * The GIT extensions ensure that a new master version of the pipeline + library will not + * make your Jenkins going crazy by building every using job with an SCM + trigger + * show the changelog of the pipeline library in your using jobs + +## Step 2: Use the library in a job + +* Goto https://jenkins.company.com/job/tutorial/ and create a new + Pipeline job named 'Demo' +* Add the following script in the Pipeline script field: + ```groovy + @Library ('pipeline-library') pipelineLibrary + + import io.wcm.tooling.jenkins.pipeline.utils.logging.* + + Logger.init(steps, LogLevel.INFO) + Logger log = new Logger(this) + + log.info("This is an info log from the pipeline library") + ``` +* run the job + +You should see some checkout logs and at the end of the job the follwing +output: + +```text +[Pipeline] echo +[INFO] WorkflowScript : This is an info log from the pipeline library +[Pipeline] End of Pipeline +Finished: SUCCESS +``` + +Congratulations, you are now ready to use the pipeline library + +## Step 3: Setup managed file and credential auto lookup + +To use the auto lookup functionalities you need your own library in a +SCM repository (GIT is preferred) + +You can use +[Pipeline Library Example](https://github.com/wcm-io-devops/jenkins-pipeline-library/pipeline-library-example) +as starting point for your own pipeline libray. + +:bulb: Pipeline libraries are only valid if they contain at least one +class below `src` or one step/var below `vars`. + +The example lib consists out of +* Configuration for SCM credential auto lookup +* Configuration for global and local Maven settings auto lookup +* Configuration for npmrd and npm user config auto lookup +* Configuration for Bundler config auto lookup +* A sample class ([`src/com/company/jenkins/pipeline/Demo.groovy`](https://github.com/wcm-io-devops/jenkins-pipeline-library/pipeline-library-example/blob/master/vars/customStep.groovy)) +* A sample step + ([`vars/demo.groovy`](https://github.com/wcm-io-devops/jenkins-pipeline-library/pipeline-library-example/blob/master/vars/demo.groovy)) + +### SCM credentials + +The file +[`credentials.json`](https://github.com/wcm-io-devops/jenkins-pipeline-library/pipeline-library-example/blob/master/resources/credentials/scm/credentials.json) +contains the user data for checking out from your company git server. + +Since GIT checkout can be done via SSH and HTTPS there are two +credentials configured. + +### Maven global settings + +The +[`global-settings.json`](https://github.com/wcm-io-devops/jenkins-pipeline-library/pipeline-library-example/blob/master/resources/mangedfiles/maven/global-settings.json) +specifies the global maven settings `maven-global-settings-for-company` +(stored in Jenkins via Config File Provider plugin), which is valid for +all maven builds. + +This would be used by all projects checked out from git.company.com. + +The global settings xml for the example looks like this: +```xml + + + + + + default + + + + central + https://nexus.company.com/content/groups/default + default + + + + + + central + https://nexus.company.com/content/groups/default + default + + + + + + + default + + + + + + + +``` + +### Maven local settings + +The +[`settings.json`](https://github.com/wcm-io-devops/jenkins-pipeline-library/pipeline-library-example/blob/master/resources/mangedfiles/maven/settings.json) +specifies client/project specific maven settings +`maven-local-settings-for-client` (stored in Jenkins via Config File +Provider plugin), which is valid for all maven builds where the scm url +is matching `git\.company\.com[:/]client/project.git`. + +This credentials (stored in jenkins) would have the credentials +configured for deploying into the artifact manager. + +The local settings xml for the example looks like this: +```xml + + + + + + default + + + + central + https://nexus.company.com/content/groups/default + default + + + + + + central + https://nexus.company.com/content/groups/default + default + + + + + + + default + + + + + + + +``` + +This looks strange because this is the same content, but the idea is to +configure the deploy credentials here: +![sharedLibrary003](assets/tutorial-setup/shared-library-003.png) + +### npmrc and npm user config + +[`npm-config-userconfig.json`](https://github.com/wcm-io-devops/jenkins-pipeline-library/pipeline-library-example/blob/master/resources/mangedfiles/npm/npm-config-userconfig.json) +and +[`npmrc`](https://github.com/wcm-io-devops/jenkins-pipeline-library/pipeline-library-example/blob/master/resources/mangedfiles/npm/npmrc.json) +are used to specify the NPM settings for your npm.company.com repository. + +Example for the managed file with id: +`npm-settings-for-company-repository` + +```text +registry = https://npm.company.com/repository/npm-default/ +always-auth = true +_auth = dGhpc2lzYXBpcGVsaW5lZGVtaQ== +``` + +## Bundler config auto lookup + +[`bundle-config.json`](https://github.com/wcm-io-devops/jenkins-pipeline-library/pipeline-library-example/blob/master/resources/mangedfiles/ruby/bundle-config.json) +is used to define your settings for using bundler.company.com repository. + +Example for the managed file with id: +`bundler-settings-for-company-repository` + +```yaml +--- +BUNDLE_FROZEN: '1' +BUNDLE_BIN: .rubygems/bin +BUNDLE_MIRROR__HTTPS://RUBYGEMS__ORG/: https://bundler.company.com/content/groups/rubygems-default +BUNDLE_BUNDLER__COMPANY__COM: company-jenkins-user-name:company-jenkins-user-password +``` + +## Step 4: Add your library to jenkins and test it + +Add your library with the name `pipeline-library-example` and version +`master` to the https://jenkins.company.com/job/tutorial/ folder as +described in [step 2](#step-2-use-the-library-in-a-job) + +Go to the configuration page of the test job +https://jenkins.company.com/job/tutorial/job/Test/configure and change +the pipeline script to + +```groovy +@Library('pipeline-library@feature') pipelineLibrary +@Library('pipeline-library-example') pipelineLibraryExample + +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import io.wcm.tooling.jenkins.pipeline.utils.logging.LogLevel + +Logger.init(steps, LogLevel.INFO) +Logger log = new Logger(this) + +log.info("This is an info log from the pipeline library") + +// allocate a node cause some step need them +node() { + // call the demo step from pipeline-library-example + demo() + // call the test step for auto lookup + testAutoLookup() +} +``` + +`testAutolookup` will try to lookup +* Credentials +* Maven Settings +* NPM Settings and +* Bundler Settings + +for the scm url `git@git.company.com/client/project.git` + +Now run the job and the output should look like this: + +```text +[INFO] demo : I am a custom step +[Pipeline] echo +[INFO] com.company.jenkins.pipeline.Demo : Hello! +[Pipeline] libraryResource +[Pipeline] readJSON +[Pipeline] echo +[INFO] testAutoLookup : Found credential for scm url 'git@git.company.com/client/project.git': 'GIT-SSH-company-credentials' +[Pipeline] echo +[INFO] testAutoLookup : Got correct credential/managed file with id: GIT-SSH-company-credentials +[Pipeline] libraryResource +[Pipeline] readJSON +[Pipeline] echo +[INFO] testAutoLookup : Found managed file for scm url 'git@git.company.com/client/project.git': 'maven-global-settings-for-company' +[Pipeline] echo +[INFO] testAutoLookup : Got correct credential/managed file with id: maven-global-settings-for-company +[Pipeline] libraryResource +[Pipeline] readJSON +[Pipeline] echo +[INFO] testAutoLookup : Found managed file for scm url 'git@git.company.com/client/project.git': 'maven-local-settings-for-client' +[Pipeline] echo +[INFO] testAutoLookup : Got correct credential/managed file with id: maven-local-settings-for-client +[Pipeline] libraryResource +[Pipeline] readJSON +[Pipeline] echo +[INFO] testAutoLookup : Found managed file for scm url 'git@git.company.com/client/project.git': 'npm-settings-for-company-repository' +[Pipeline] echo +[INFO] testAutoLookup : Got correct credential/managed file with id: npm-settings-for-company-repository +[Pipeline] libraryResource +[Pipeline] readJSON +[Pipeline] echo +[INFO] testAutoLookup : Found managed file for scm url 'git@git.company.com/client/project.git': 'npmrc-settings-for-company-repository' +[Pipeline] echo +[INFO] testAutoLookup : Got correct credential/managed file with id: npmrc-settings-for-company-repository +[Pipeline] libraryResource +[Pipeline] readJSON +[Pipeline] echo +[INFO] testAutoLookup : Found managed file for scm url 'git@git.company.com/client/project.git': 'bundler-settings-for-company-repository' +[Pipeline] echo +[INFO] testAutoLookup : Got correct credential/managed file with id: bundler-settings-for-company-repository +``` + diff --git a/docs/usage-examples.md b/docs/usage-examples.md new file mode 100644 index 0000000..42da69e --- /dev/null +++ b/docs/usage-examples.md @@ -0,0 +1,45 @@ +# Examples + +## Example 1: Building a maven project with notifications + +```groovy +import io.wcm.tooling.jenkins.pipeline.model.Tool +import io.wcm.tooling.jenkins.pipeline.utils.logging.LogLevel +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +Map config = [ + (SCM): [ + (SCM_URL): 'git@git.yourcompany.tld:group/project.git' + ], + (TOOLS): [ + [ (TOOL_NAME): 'apache-maven3', (TOOL_TYPE): Tool.MAVEN ], + [ (TOOL_NAME): 'jdk8', (TOOL_TYPE): Tool.JDK ] + ], + (MAVEN): [ + (MAVEN_GOALS): [ "clean", "install" ] + ], + (LOGLEVEL): LogLevel.INFO +] + +// surround by try and catch +try { + // initialize the logger + Logger.init(steps, config) + node() { + // setup the tools + setupTools(config) + // to the checkout + checkoutScm(config) + // execute maven + execMaven(config) + } + currentBuild.result = "SUCCESS" +} catch (Exception ex) { + currentBuild.result = "FAILED" + throw ex +} finally { + notifyMail(config) +} +``` \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..aa684bd --- /dev/null +++ b/pom.xml @@ -0,0 +1,352 @@ + + + + + 4.0.0 + + + org.jenkins-ci.plugins + plugin + 2.30 + + + + io.wcm.devops.ci + io.wcm.devops.ci.jenkins-pipeline-library + 1.0.0-SNAPSHOT + wcm.io Jenkins Pipeline Library + + + scm:git:git@github.com:wcm-io-devops/jenkins-pipeline-library.git + scm:git:git@github.com:wcm-io-devops/jenkins-pipeline-library.git + https://github.com/wcm-io-devops/jenkins-pipeline-library + HEAD + + + + 0 + 0 + false + + + 2017 + + + wcm.io DevOps + http://devops.wcm.io + + + + JIRA + https://wcm-io.atlassian.net/browse/ + + + + Travis CI + https://travis-ci.org/wcm-io-devops + + + + + wcm.io Community + wcm.io + http://wcm.io + + + + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + + + + + + ossrh + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + + + + + com.lesfurets + jenkins-pipeline-unit + 1.0 + test + + + org.assertj + assertj-core + 3.8.0 + test + + + org.mockito + mockito-core + 2.8.47 + test + + + org.jenkins-ci.plugins + git + 3.4.1 + + + org.jenkins-ci.plugins + git + 3.4.1 + tests + + + org.jvnet.hudson.tools + versionnumber + 1.8.1 + + + org.jenkins-ci.plugins + config-file-provider + 2.16.0 + + + org.jenkins-ci.plugins + scm-api + 2.2.0 + + + org.jenkins-ci.plugins.workflow + workflow-cps + 2.36.1 + + + org.jenkins-ci.plugins.workflow + workflow-api + 2.18 + + + org.jenkins-ci.plugins.workflow + workflow-scm-step + 2.5 + + + org.jenkins-ci.plugins.workflow + workflow-multibranch + 2.16 + + + org.jenkins-ci.plugins + pipeline-utility-steps + 1.3.0 + + + org.jenkins-ci.plugins + junit + 1.20 + + + com.cloudbees + groovy-cps + 1.17 + + + + + ${project.basedir}/src + ${project.basedir}/test + + + + org.codehaus.mojo + license-maven-plugin + + + validate + + check-file-header + + + + + + **/*.groovy + + + + + + org.codehaus.gmavenplus + gmavenplus-plugin + 1.5 + + + org.codehaus.groovy + groovy-all + 2.4.8 + runtime + + + + + compile + + compile + + + + + ${pom.basedir}/src + + **/*.groovy + + + + + + + test + + testCompile + + + + + ${pom.basedir}/test + + **/*.groovy + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.20 + + + org.apache.maven.plugins + maven-failsafe-plugin + 2.20 + + + + integration-test + verify + + + + + + org.jacoco + jacoco-maven-plugin + 0.7.9 + + + jacoco-initialize + + prepare-agent + + + + jacoco-site + package + + report + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.0.2 + + + + test-jar + + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar + test-jar + + + + + + com.bluetrainsoftware.maven + groovydoc-maven-plugin + 1.3 + + + attach-docs + package + + attach-docs + + + + + + + + + + + org.codehaus.mojo + license-maven-plugin + 1.11 + + apache_v2 + false + wcm.io + + src + test + vars + + + **/*.json + **/*.html + + + **/pom.xml + + + + + + + + diff --git a/src/io/wcm/tooling/jenkins/pipeline/credentials/Credential.groovy b/src/io/wcm/tooling/jenkins/pipeline/credentials/Credential.groovy new file mode 100644 index 0000000..469acfb --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/credentials/Credential.groovy @@ -0,0 +1,53 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.credentials + +import com.cloudbees.groovy.cps.NonCPS +import io.wcm.tooling.jenkins.pipeline.model.PatternMatchable + +/** + * Model for Jenkins credentials + */ +class Credential extends PatternMatchable implements Serializable { + + private static final long serialVersionUID = 1L + + String comment + + String userName + + /** + * @param pattern The pattern which will be matched against the scm url + * @param id The id of the credential stored in the Jenkins instance + * @param comment Additional comment for debug purposes + * @param user user name to use, used during sshagent steps + */ + Credential(String pattern, String id, String comment = null, String userName = null) { + super(pattern, id) + this.comment = comment + this.userName = userName + } + + @NonCPS + boolean isValid() { + return (pattern != null && id != null) + } + +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialAware.groovy b/src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialAware.groovy new file mode 100644 index 0000000..8fcb0fc --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialAware.groovy @@ -0,0 +1,44 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.credentials + +import com.cloudbees.groovy.cps.NonCPS + +/** + * Interface for command builders that support credentials + */ +interface CredentialAware { + + /** + * Used to set the username based on a Credential found by auto lookup + * + * @param credential The credential object to use the username from (if set) + */ + @NonCPS + void setCredential(Credential credential) + + /** + * Getter function for credentials + * + * @return The stored credentials + */ + @NonCPS + Credential getCredential() +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialConstants.groovy b/src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialConstants.groovy new file mode 100644 index 0000000..15e5fa1 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialConstants.groovy @@ -0,0 +1,33 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.credentials + +/** + * Constants for credentials + */ +class CredentialConstants implements Serializable { + + private static final long serialVersionUID = 1L + + final static SCM_CREDENTIALS_PATH = "credentials/scm/credentials.json" + + final static SSH_CREDENTIALS_PATH = "credentials/ssh/credentials.json" + +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialParser.groovy b/src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialParser.groovy new file mode 100644 index 0000000..7540b62 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialParser.groovy @@ -0,0 +1,81 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.credentials + +import com.cloudbees.groovy.cps.NonCPS +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import net.sf.json.JSON +import net.sf.json.JSONObject + +// @formatter:off +/** + * Parses an incoming json object into Credential objects + * + * Expected json file format: + * [ + * { + * "pattern": "subdomain\.domain\.tld[:/]group1", + * "id": "Id of the credential in the jenkins instance", + * "comment": "Comment for the credential" + * }, + * { .. } + * ] + * + * @see Credential + */ +// @formatter:on +class CredentialParser implements Serializable { + + private static final long serialVersionUID = 1L + + Logger log = new Logger(this) + + /** + * Parses a json object containing a list of credential objects into a list of Credential + * Only valid Credential objects are added to the returned list + * + * @param jsonContent The json content loaded via JsonLibraryResource + * @return The parsed list of valid Credential objects + */ + @NonCPS + @SuppressFBWarnings('SE_NO_SERIALVERSIONID') + List parse(JSON jsonContent) { + Credential credential = null + List parsedCredentials = [] + // Walk through entries, try to parse them as Credential object and add it to the returned list + jsonContent.each { JSONObject entry -> + String comment = entry.comment ?: null + String id = entry.id ?: null + String pattern = entry.pattern ?: null + String username = entry.username ?: null + credential = new Credential(pattern, id, comment, username) + log.trace("parsed credential file: ", credential) + if (credential.isValid()) { + parsedCredentials.push(credential) + } else { + log.debug("credential is invalid because id and/or pattern is missing") + } + log.trace("entry: ", entry) + } + + return parsedCredentials + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/environment/EnvironmentConstants.groovy b/src/io/wcm/tooling/jenkins/pipeline/environment/EnvironmentConstants.groovy new file mode 100644 index 0000000..260be82 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/environment/EnvironmentConstants.groovy @@ -0,0 +1,35 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.environment + +/** + * Constants for environment variables used by Pipeline scripts and by Jenkins + */ +class EnvironmentConstants implements Serializable { + + private static final long serialVersionUID = 1L + + static final public String GIT_BRANCH = "GIT_BRANCH" + static final public String BRANCH_NAME = "BRANCH_NAME" + static final public String SCM_URL = "SCM_URL" + static final public String TERM = "TERM" + + +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFile.groovy b/src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFile.groovy new file mode 100644 index 0000000..c012b2d --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFile.groovy @@ -0,0 +1,47 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.managedfiles + +import com.cloudbees.groovy.cps.NonCPS +import io.wcm.tooling.jenkins.pipeline.model.PatternMatchable + +/** + * Model for Jenkins managed files + */ +class ManagedFile extends PatternMatchable implements Serializable { + + private static final long serialVersionUID = 1L + + String name = "" + + String comment = "" + + ManagedFile(String pattern, String id, String name = null, String comment = null) { + super(pattern, id) + this.id = id + this.name = name + this.comment = comment + } + + @NonCPS + boolean isValid() { + return (pattern != null && id != null) + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFileConstants.groovy b/src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFileConstants.groovy new file mode 100644 index 0000000..bbf9484 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFileConstants.groovy @@ -0,0 +1,53 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.managedfiles + +/** + * Constants for managed files used by the pipeline library + */ +class ManagedFileConstants implements Serializable { + + private static final long serialVersionUID = 1L + + static final String GLOBAL_MAVEN_SETTINGS_PATH = "managedfiles/maven/global-settings.json" + static final String GLOBAL_MAVEN__SETTINGS_ENV = "MVN_GLOBAL_SETTINGS" + + static final String MAVEN_SETTINS_PATH = "managedfiles/maven/settings.json" + static final String MAVEN_SETTING_ENV = "MVN_SETTINGS" + + static final String NPM_CONFIG_USERCONFIG_PATH = "managedfiles/npm/npm-config-userconfig.json" + // wrong named in existing jenkins/maven projects + // TODO: remove + static final String NPM_CONFIG_USERCONFIG_ENV = "NPM_CONFIG_USERCONFIG" + // correct name based on NPM config + static final String NPM_CONF_USERCONFIG_ENV = "NPM_CONF_USERCONFIG" + + static final String NPMRC_PATH = "managedfiles/npm/npmrc.json" + // wrong name in existing jenkins/maven projects + // TODO: remove + static final String NPMRC_ENV = "NPMRC" + // correct name based on NPM config + static final String NPM_CONF_GLOBALCONFIG_ENV = "NPM_CONF_GLOBALCONFIG" + + + static final String BUNDLE_CONFIG_ENV = "BUNDLE_CONFIG" + static final String BUNDLE_CONFIG_PATH = "managedfiles/ruby/bundle-config.json" + +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFileParser.groovy b/src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFileParser.groovy new file mode 100644 index 0000000..3e50ada --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFileParser.groovy @@ -0,0 +1,83 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.managedfiles + +import com.cloudbees.groovy.cps.NonCPS +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import net.sf.json.JSON +import net.sf.json.JSONObject + +// @formatter:off +/** + * Parses an incoming json object into ManagedFile objects + * + * Expected json file format: + * [ + * { + * "pattern": "subdomain\.domain\.tld[:/]group1", + * "id": "Id of the file in the jenkins instance", + * "name": "The name of the managed file", + * "comment": "The comment of the managed file" + * }, + * { .. } + * ] + * + * @see ManagedFile + */ +// @formatter:on +class ManagedFileParser implements Serializable { + + private static final long serialVersionUID = 1L + + Logger log = new Logger(this) + + /** + * Parses a json object containing a list of ManagedFile objects into a list of ManagedFile + * Only valid ManagedFile objects are added to the returned list + * + * @param jsonContent The json content loaded via JsonLibraryResource + * @return The parsed list of valid ManagedFile objects + */ + @NonCPS + @SuppressFBWarnings('SE_NO_SERIALVERSIONID') + List parse(JSON jsonContent) { + ManagedFile managedFile = null + List parsedFiles = [] + + jsonContent.each { JSONObject entry -> + String name = entry.name ?: null + String comment = entry.comment ?: null + String id = entry.id ?: null + String pattern = entry.pattern ?: null + managedFile = new ManagedFile(pattern, id, name, comment) + log.trace("parsed managed file: ", managedFile) + if (managedFile.isValid()) { + parsedFiles.push(managedFile) + } else { + log.debug("managed file is invalid because id and/or pattern is missing") + } + log.trace("entry: ", entry) + } + + return parsedFiles + } + +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/model/PatternMatchable.groovy b/src/io/wcm/tooling/jenkins/pipeline/model/PatternMatchable.groovy new file mode 100644 index 0000000..d4d0d12 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/model/PatternMatchable.groovy @@ -0,0 +1,46 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.model + +/** + * Base model for Credentials and MangedFiles + * + * @see io.wcm.tooling.jenkins.pipeline.credentials.Credential + * @see io.wcm.tooling.jenkins.pipeline.managedfiles.ManagedFile + */ +abstract class PatternMatchable implements Serializable { + + private static final long serialVersionUID = 1L + + String pattern = "" + + String id = "" + + /** + * Constructor + * + * @param pattern The pattern to match against + * @param id The id of the matchable item + */ + PatternMatchable(String pattern, String id) { + this.pattern = pattern + this.id = id + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/model/Result.groovy b/src/io/wcm/tooling/jenkins/pipeline/model/Result.groovy new file mode 100644 index 0000000..4c6eccb --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/model/Result.groovy @@ -0,0 +1,94 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.model + +import com.cloudbees.groovy.cps.NonCPS +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import hudson.model.Result as HudsonResult + +/** + * Enumeration for pipeline build results + * Extends the existing Jenkins Results with still failing, still unstable and fixed to bring back the + * extmail functionality known from the graphical Jenkins job configuration interface + */ +@SuppressFBWarnings('ME_ENUM_FIELD_SETTER') +enum Result { + + NOT_BUILD(HudsonResult.NOT_BUILT, 3), + ABORTED(HudsonResult.ABORTED, 4), + FAILURE(HudsonResult.FAILURE, 2), + UNSTABLE(HudsonResult.UNSTABLE, 1), + SUCCESS(HudsonResult.SUCCESS, 0), + STILL_FAILING(HudsonResult.FAILURE, "STILL FAILING", 2), + STILL_UNSTABLE(HudsonResult.UNSTABLE, "STILL UNSTABLE", 1), + FIXED(HudsonResult.SUCCESS, "FIXED", 0) + + HudsonResult hudsonResult + + String name + + Integer ordinal + + Result(HudsonResult r, String name, Integer ordinal) { + this.hudsonResult = r + this.name = name + this.ordinal = ordinal + } + + Result(HudsonResult r, Integer ordinal) { + this(r, r.toString(), ordinal) + } + + @NonCPS + static Result fromString(String s) { + if (s == null) return null + for (r in values()) { + if (s.equalsIgnoreCase(r.toString())) return r + } + + return FAILURE + } + + @NonCPS + @Override + String toString() { + return name + } + + @NonCPS + boolean isWorseThan(Result that) { + return this.ordinal > that.ordinal + } + + @NonCPS + boolean isWorseOrEqualTo(Result that) { + return this.ordinal >= that.ordinal + } + + @NonCPS + boolean isBetterThan(Result that) { + return this.ordinal < that.ordinal + } + + @NonCPS + boolean isBetterOrEqualTo(that) { + return this.ordinal <= that.ordinal + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/model/Tool.groovy b/src/io/wcm/tooling/jenkins/pipeline/model/Tool.groovy new file mode 100644 index 0000000..bd601ae --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/model/Tool.groovy @@ -0,0 +1,43 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.model + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings + +@SuppressFBWarnings('ME_ENUM_FIELD_SETTER') +enum Tool { + + MAVEN("MAVEN_HOME"), + JDK("JAVA_HOME"), + ANSIBLE("ANSIBLE_HOME"), + GIT("GIT_HOME"), + GROOVY("GROOVY_HOME"), + MSBUILD("MSBUILD_HOME"), + ANT("ANT_HOME"), + PYTHON("PYTHON_HOME"), + DOCKER("DOCKER_HOME"), + NODEJS("NPM_HOME") + + String envVar + + Tool(String envVar) { + this.envVar = envVar + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/shell/CommandBuilder.groovy b/src/io/wcm/tooling/jenkins/pipeline/shell/CommandBuilder.groovy new file mode 100644 index 0000000..722e18f --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/shell/CommandBuilder.groovy @@ -0,0 +1,114 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +package io.wcm.tooling.jenkins.pipeline.shell + +import com.cloudbees.groovy.cps.NonCPS +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings + +/** + * Interface all command builders have to implement + */ +interface CommandBuilder { + + /** + * Adds an argument to the list + * + * @param argument The argument to add + * @return The current instance + */ + @NonCPS + CommandBuilder addArgument(String argument) + + /** + * Adds a path argument. + * The provided argument will be escaped for shell usage before adding to arguments list. + * + * @param argument The path argument to add + * @return The current instance + */ + @NonCPS + CommandBuilder addPathArgument(String argument) + + /** + * Adds a path argument with argument name and value e.g. --path /some/path + * The provided argument will be escaped for shell usage before adding to arguments list. + * + * @param argumentName The name of the argument + * @param value The value of the argument + * @return The current instance + */ + @NonCPS + CommandBuilder addPathArgument(String argumentName, String value) + + /** + * Adds a path argument with argument name and value e.g. --prop value + * + * @param argumentName The name of the argument + * @param argumentValue The value of the argument + * @return The current instance + */ + @NonCPS + CommandBuilder addArgument(String argumentName, String argumentValue) + + /** + * Builds the command line by joining all provided arguments using space + * + * @return The command line that can be called by the 'sh' step + */ + @NonCPS + String build() + + /** + * Adapter function for arguments provided as string + * + * @param arguments The argument String to be added + * @return The current instance + */ + @NonCPS + CommandBuilder addArguments(String arguments) + + /** + * Adds a list of arguments + * + * @param arguments A List of String containing 0-n arguments to be added + * @return The current instance + */ + @NonCPS + @SuppressFBWarnings('SE_NO_SERIALVERSIONID') + CommandBuilder addArguments(List arguments) + + /** + * Resets all arguments in the command builder + * + * @return The current instance + */ + @NonCPS + CommandBuilder reset() + + /** + * Sets the executable + * @param executable + * @return The current instance + */ + @NonCPS + CommandBuilder setExecutable(String executable) + +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/shell/CommandBuilderImpl.groovy b/src/io/wcm/tooling/jenkins/pipeline/shell/CommandBuilderImpl.groovy new file mode 100644 index 0000000..1a02071 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/shell/CommandBuilderImpl.groovy @@ -0,0 +1,195 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.shell + +import com.cloudbees.groovy.cps.NonCPS +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import org.jenkinsci.plugins.workflow.cps.DSL + +/** + * Utility for building commands executed via the sh pipeline step + */ +class CommandBuilderImpl implements CommandBuilder, Serializable { + + private static final long serialVersionUID = 1L + + /** + * The path of the executable + */ + String _executable = null + + /** + * Reference to the DSL object of the current pipeline script + */ + DSL dsl + + /** + * Used for storing the added arguments + */ + List arguments + + /** + * @param dsl The DSL object of the current pipeline script (available via this.steps in pipeline scripts) + */ + CommandBuilderImpl(DSL dsl) { + this.dsl = dsl + this.reset() + } + + /** + * @param dsl The DSL object of the current pipeline script (available via this.steps in pipeline scripts) + * @param executable The executable to use + */ + CommandBuilderImpl(DSL dsl, String executable) { + this(dsl) + if (executable == null) { + dsl.error("provided executable is null, please make sure to provide a String") + } + this._executable = executable + } + + /** + * Adds an argument to the list + * + * @param argument The argument to add + * @return The current instance + */ + @NonCPS + CommandBuilder addArgument(String argument) { + if (argument == null || argument == "") return this + this.arguments.push(argument) + return this + } + + /** + * Adds a path argument. + * The provided argument will be escaped for shell usage before adding to arguments list. + * + * @param argument The path argument to add + * @return The current instance + */ + @NonCPS + CommandBuilder addPathArgument(String argument) { + if (argument == null) return + argument = ShellUtils.escapePath(argument) + this.arguments.push(argument) + return this + } + + /** + * Adds a path argument with argument name and value e.g. --path /some/path + * The provided argument will be escaped for shell usage before adding to arguments list. + * + * @param argumentName The name of the argument + * @param value The value of the argument + * @return The current instance + */ + @NonCPS + CommandBuilder addPathArgument(String argumentName, String value) { + if (value == null || argumentName == null) return this + value = ShellUtils.escapePath(value) + this.addArgument(argumentName, value) + return this + } + + /** + * Adds a path argument with argument name and value e.g. --prop value + * + * @param argumentName The name of the argument + * @param argumentValue The value of the argument + * @return The current instance + */ + @NonCPS + CommandBuilder addArgument(String argumentName, String argumentValue) { + if (argumentName == null || argumentValue == null) return this + this.arguments.push("$argumentName $argumentValue") + return this + } + + /** + * Builds the command line by joining all provided arguments using space + * + * @return The command line that can be called by the 'sh' step + */ + @NonCPS + String build() { + // add the executable, add etc. is blocked by sandbox + List tmpArgs = [] + if (_executable != null) { + tmpArgs.push(_executable) + } + for (String argument : arguments) { + tmpArgs.push(argument) + } + + return tmpArgs.join(" ") + } + + /** + * Adapter function for arguments provided as string + * + * @param arguments The argument String to be added + * @return The current instance + */ + @NonCPS + CommandBuilder addArguments(String arguments) { + this.addArgument(arguments) + return this + } + + /** + * Adds a list of arguments + * + * @param arguments A List of String containing 0-n arguments to be added + * @return The current instance + */ + @NonCPS + @SuppressFBWarnings('SE_NO_SERIALVERSIONID') + CommandBuilder addArguments(List arguments) { + arguments?.each { String argument -> + this.addArgument(argument) + } + return this + } + + /** + * Resets the command builder + * + * @return The current instance + */ + @Override + @NonCPS + CommandBuilder reset() { + arguments = [] + return this + } + + /** + * Sets the executable + * @param executable The executable to be used + * @return The current instance + */ + @Override + @NonCPS + CommandBuilder setExecutable(String executable) { + this._executable = executable + return this + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/shell/ConfigAwareCommandBuilder.groovy b/src/io/wcm/tooling/jenkins/pipeline/shell/ConfigAwareCommandBuilder.groovy new file mode 100644 index 0000000..ca3e8a2 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/shell/ConfigAwareCommandBuilder.groovy @@ -0,0 +1,35 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.shell + +/** + * Interface for command builders that support the initialization with configuration + */ +interface ConfigAwareCommandBuilder { + + /** + * Initializes the command builder with the provided configuration + * + * @param config The configuration for the commandbuilder + * @return The instance of the command builder + */ + ConfigAwareCommandBuilder applyConfig(Map config) + +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/shell/GitCommandBuilderImpl.groovy b/src/io/wcm/tooling/jenkins/pipeline/shell/GitCommandBuilderImpl.groovy new file mode 100644 index 0000000..7f4b34b --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/shell/GitCommandBuilderImpl.groovy @@ -0,0 +1,31 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.shell + +import org.jenkinsci.plugins.workflow.cps.DSL + +class GitCommandBuilderImpl extends CommandBuilderImpl implements Serializable { + + private static final long serialVersionUID = 1L + + GitCommandBuilderImpl(DSL dsl, String executable = null) { + super(dsl, executable ?: "git") + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/shell/MavenCommandBuilderImpl.groovy b/src/io/wcm/tooling/jenkins/pipeline/shell/MavenCommandBuilderImpl.groovy new file mode 100644 index 0000000..d67b7dd --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/shell/MavenCommandBuilderImpl.groovy @@ -0,0 +1,390 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.shell + +import com.cloudbees.groovy.cps.NonCPS +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import org.jenkinsci.plugins.workflow.cps.DSL + +/** + * Utility for building maven commands executed via the sh pipeline step + */ +class MavenCommandBuilderImpl implements Serializable, CommandBuilder, ConfigAwareCommandBuilder { + + private static final long serialVersionUID = 1L + + static final String EXECUTABLE = "mvn" + static final String ARG_GLOBAL_SETTINGS = "--global-settings" + static final String ARG_SETTINGS = "--settings" + static final String ARG_FILE = "-f" + + public String _globalSettingsId = null + public String _settingsId = null + + public CommandBuilder commandBuilder + + public DSL dsl + + public Map _params + + public Logger log = new Logger(this) + + /** + * @param dsl The DSL object of the current pipeline script (available via this.steps in pipeline scripts) + * @param executable The executable, default: 'maven' + * + * @deprecated + */ + MavenCommandBuilderImpl(DSL dsl, String executable = null) { + this(dsl, [:], executable) + log.warn("Calling MavenCommandBuilderImpl Constructor without params is deprecated and is subject to remove in the upcoming versions") + } + + /** + * @param dsl The DSL object of the current pipeline script (available via this.steps in pipeline scripts) + * @param executable The executable, default: 'maven' + * + * @deprecated + */ + MavenCommandBuilderImpl(DSL dsl, Map params, String executable = null) { + commandBuilder = new CommandBuilderImpl(dsl, executable ?: EXECUTABLE) + this.dsl = dsl + this._params = params + this.reset() + } + + /** + * Adds the global settings argument with the given path to the command line + * @param path Path to the global maven settings + * + * @return The current command builder instance + */ + @NonCPS + MavenCommandBuilderImpl setGlobalSettings(String path) { + this.addPathArgument(ARG_GLOBAL_SETTINGS, path) + return this + } + + /** + * Sets maven profiles based upon the provided string + * + * @param profiles The maven profiles as string, comma separated + * + * @return MavenCommandBuilderImpl + */ + @NonCPS + MavenCommandBuilderImpl addProfiles(String profiles) { + if (profiles && profiles != "") { + this.addArgument("-P${profiles}") + } + return this + } + + /** + * Sets the maven profiles based upon the list + * @param profiles The maven profiles as list + * + * @return The current command builder instance + */ + @NonCPS + MavenCommandBuilderImpl addProfiles(List profiles) { + if (profiles.size() > 0) { + this.addProfiles(profiles.join(",")) + } + return this + + } + + /** + * Sets the goals to be executed + * + * @param goals The Maven goals as string + * @return The current command builder instance + */ + @NonCPS + MavenCommandBuilderImpl setGoals(String goals) { + this.addArgument(goals) + return this + } + + /** + * Sets the goals to be executed. + * Adapter function for goals that are provided as list. + * + * @param goals The maven goals as list + * @return The current command builder instance + */ + @NonCPS + MavenCommandBuilderImpl setGoals(List goals) { + if (goals.size() > 0) { + this.setGoals(goals.join(" ")) + } + return this + } + + /** + * Adds the settings argument with the given path to the command line + * + * @param path Path to the maven settings + * @return The current command builder instance + */ + @NonCPS + MavenCommandBuilderImpl setSettings(String path) { + this.addPathArgument(ARG_SETTINGS, path) + return this + } + + /** + * Sets the path to the pom + * + * @param path The path to the pom file for maven + * @return The current command builder instance + */ + @NonCPS + MavenCommandBuilderImpl setPom(String path) { + this.addPathArgument(ARG_FILE, path) + return this + } + + /** + * Adds a flag define to the maven command line + * + * @param defineName The define to be added (-D is automatically added) + * @return The current command builder instance + */ + @NonCPS + MavenCommandBuilderImpl addDefine(String defineName) { + if (defineName == null) return this + this.addArgument("-D".concat(defineName)) + return this + } + + /** + * Adds defined based on map input + * @param defines the defines as map + * @return The current command builder instance + */ + @NonCPS + @SuppressFBWarnings('SE_NO_SERIALVERSIONID') + MavenCommandBuilderImpl addDefines(Map defines) { + defines.each { + String k, v -> + if (v != null) { + this.addArgument("-D$k=$v".toString()) + } else { + this.addArgument("-D$k".toString()) + } + } + return this + } + + /** + * Adds defines as string + * + * @param defines The defines to be added (each define must be prefixed with -D) + * @return The current command builder instance + */ + @NonCPS + MavenCommandBuilderImpl addDefines(String defines) { + this.addArguments(defines) + return this + } + + /** + * Adds a value define to the maven command line + * + * @param defineName The name of the define (will be automatically prefixed with -D) + * @param defineValue The value of the define + * @return The current command builder instance + */ + @NonCPS + MavenCommandBuilderImpl addDefine(String defineName, Object defineValue) { + if (defineName == null) return this + if (defineValue == null) { + this.addDefine(defineName) + } else { + this.addArgument("-D".concat(defineName).concat("=").concat(defineValue.toString())) + } + return this + } + + /** + * {@inheritDoc} + */ + @Override + @NonCPS + CommandBuilder reset() { + this._globalSettingsId = null + this._settingsId = null + commandBuilder.reset() + return this + } + + /** + * + * @return id of maven global settings managed file + */ + @NonCPS + String getGlobalSettingsId() { + return _globalSettingsId + } + + /** + * @return id of the maven settings managed file + */ + @NonCPS + String getSettingsId() { + return _settingsId + } + + /** + * @param globalSettingsId The id of the global maven settings managed file + */ + @NonCPS + void setGlobalSettingsId(String globalSettingsId) { + this._globalSettingsId = globalSettingsId + } + + /** + * @param settingsId The id of the maven settings managed file + */ + @NonCPS + void setSettingsId(String settingsId) { + this._settingsId = settingsId + } + + /** + * {@inheritDoc} + */ + @Override + @NonCPS + ConfigAwareCommandBuilder applyConfig(Map config) { + // parse config + Map mavenConfig = (Map) config[ConfigConstants.MAVEN] ?: [:] + String mavenExecutable = mavenConfig[ConfigConstants.MAVEN_EXECUTABLE] ?: null + String pom = mavenConfig[ConfigConstants.MAVEN_POM] ?: null + Object goals = mavenConfig[ConfigConstants.MAVEN_GOALS] ?: [] + Object arguments = mavenConfig[ConfigConstants.MAVEN_ARGUMENTS] ?: [] + globalSettingsId = mavenConfig[ConfigConstants.MAVEN_GLOBAL_SETTINGS] ?: null + settingsId = mavenConfig[ConfigConstants.MAVEN_SETTINGS] ?: null + Object defines = mavenConfig[ConfigConstants.MAVEN_DEFINES] ?: [:] + Boolean injectParameters = mavenConfig[ConfigConstants.MAVEN_INJECT_PARAMS] ?: false + Object profiles = mavenConfig[ConfigConstants.MAVEN_PROFILES] ?: [] + + if (mavenExecutable != null) { + commandBuilder.setExecutable(mavenExecutable) + } + + // set pom + this.setPom(pom) + // set goals + this.setGoals(goals) + // set arguments + this.addArguments(arguments) + // add defines to command builder + this.addDefines(defines) + if (injectParameters) { + this.addDefines(this._params) + } + // add profiles + this.addProfiles(profiles) + } + + /** + * {@inheritDoc} + */ + @Override + @NonCPS + CommandBuilder setExecutable(String executable) { + commandBuilder.setExecutable(executable) + return this + } + + /** + * {@inheritDoc} + */ + @Override + @NonCPS + CommandBuilder addArgument(String argument) { + commandBuilder.addArgument(argument) + return this + } + + /** + * {@inheritDoc} + */ + @Override + @NonCPS + CommandBuilder addPathArgument(String argument) { + commandBuilder.addPathArgument(argument) + return this + } + + /** + * {@inheritDoc} + */ + @Override + @NonCPS + CommandBuilder addPathArgument(String argumentName, String value) { + commandBuilder.addPathArgument(argumentName, value) + return this + } + + /** + * {@inheritDoc} + */ + @Override + @NonCPS + CommandBuilder addArgument(String argumentName, String argumentValue) { + commandBuilder.addArgument(argumentName, argumentValue) + return this + } + + /** + * {@inheritDoc} + */ + @Override + @NonCPS + String build() { + return commandBuilder.build() + } + + /** + * {@inheritDoc} + */ + @Override + @NonCPS + CommandBuilder addArguments(String arguments) { + commandBuilder.addArguments(arguments) + return this + } + + /** + * {@inheritDoc} + */ + @Override + @NonCPS + CommandBuilder addArguments(List arguments) { + commandBuilder.addArguments(arguments) + return this + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/shell/ScpCommandBuilderImpl.groovy b/src/io/wcm/tooling/jenkins/pipeline/shell/ScpCommandBuilderImpl.groovy new file mode 100644 index 0000000..dcd38f8 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/shell/ScpCommandBuilderImpl.groovy @@ -0,0 +1,237 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.shell + +import com.cloudbees.groovy.cps.NonCPS +import io.wcm.tooling.jenkins.pipeline.credentials.Credential +import io.wcm.tooling.jenkins.pipeline.credentials.CredentialAware +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import org.jenkinsci.plugins.workflow.cps.DSL + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Utility for building scp commands + */ +class ScpCommandBuilderImpl implements CommandBuilder, CredentialAware, ConfigAwareCommandBuilder, Serializable { + + private static final long serialVersionUID = 1L + + /** + * Default executable + */ + public static final String EXECUTABLE = "scp" + + /** + * The host to connect to + */ + String host = null + + /** + * The user to use + */ + String user = null + + /** + * The scp destination path + */ + String destinationPath = null + + /** + * The source path on the local machine + */ + String sourcePath = null + + /** + * Logger instance + */ + Logger log = new Logger(this) + + /** + * Wrapped command builder since inheritance causes problems in Groovy Sandbox + */ + CommandBuilderImpl commandBuilder + + /** + * Credentials for SSH + */ + Credential credentials + + /** + * @param dsl The DSL object of the current pipeline script (available via this.steps in pipeline scripts) + * @param executable The executable, default: 'scp' + */ + ScpCommandBuilderImpl(DSL dsl, String executable = null) { + commandBuilder = new CommandBuilderImpl(dsl, executable ?: EXECUTABLE) + this.reset() + } + + /** + * Applies a given map configuration to the command builder + * + * @param config Map with configration values + */ + @NonCPS + ConfigAwareCommandBuilder applyConfig(Map config) { + commandBuilder.executable = config[SCP_EXECUTABLE] ?: "scp" + this.user = config[SCP_USER] ?: null + this.host = config[SCP_HOST] ?: null + this.sourcePath = config[SCP_SOURCE] ?: null + this.destinationPath = config[SCP_DESTINATION] ?: null + + Integer port = (Integer) config[SCP_PORT] ?: 22 + Boolean recursive = config[SCP_RECURSIVE] ?: false + List arguments = (List) config[SCP_ARGUMENTS] ?: [] + Boolean scpHostKeyCheck = config[SCP_HOST_KEY_CHECK] ?: false + + // add arguments + this.addArguments(arguments) + // add port + this.addArgument("-P", port.toString()) + // add recursive if configured + if (recursive) { + this.addArgument("-r") + } + // check if ssh host key checking has to be disabled + if (!scpHostKeyCheck) { + this.addArgument("-o", "StrictHostKeyChecking=no") + this.addArgument("-o", "UserKnownHostsFile=/dev/null") + } + return this + } + + /** + * Used to set the username based on a Credential found by auto lookup + * + * @param credential The credential object to use the username from (if set) + */ + @NonCPS + void setCredential(Credential credential) { + this.credentials = credential + if (this.user == null && this.credentials != null && credential.getUserName() != null) { + this.user = credential.getUserName() + } + } + + @Override + Credential getCredential() { + return this.credentials + } +/** + * Builds the commandline by first calling the build function of superclass and then adding + * - shell escaped source path + * - user and host + * - shell escaped destination path + * + * @return The scp command line + */ + @NonCPS + CommandBuilder addArgument(String argument) { + commandBuilder.addArgument(argument) + return this + } + + /** + * @see CommandBuilder#addPathArgument(java.lang.String) + */ + @NonCPS + CommandBuilder addPathArgument(String argument) { + commandBuilder.addPathArgument(argument) + return this + } + + /** + * @see CommandBuilder#addPathArgument(java.lang.String, java.lang.String) + */ + @NonCPS + CommandBuilder addPathArgument(String argumentName, String value) { + commandBuilder.addPathArgument(argumentName, value) + return this + } + + /** + * @see CommandBuilder#addArgument(java.lang.String, java.lang.String) + */ + @NonCPS + CommandBuilder addArgument(String argumentName, String argumentValue) { + commandBuilder.addArgument(argumentName, argumentValue) + return this + } + + /** + * Builds the command line for SCP by using the wrapped command builder and + * adding the specific scp arguments. + * + * @see CommandBuilder#build() + */ + @NonCPS + String build() { + String baseCommand = commandBuilder.build() + if (host == null || destinationPath == null || sourcePath == null) { + log.fatal("One of the mandatory properties is not set! (host: $host, destinationPath: $destinationPath, sourcePath: $sourcePath)") + // exits and throws HudsonAbortException + commandBuilder.dsl.error("One of the mandatory properties is not set! (host: $host, destinationPath: $destinationPath, sourcePath: $sourcePath)") + } + String escapedDestinationPath = ShellUtils.escapePath(destinationPath) + String escapedSourcePath = ShellUtils.escapePath(sourcePath) + + // calculate destination + // add user when defined + String destination = user ? "$user@" : "" + // add the host + destination = "$destination$host:" + // add the destination path surrounded by double quotes since it should be evaluated on target server + destination = destination + "\"$escapedDestinationPath\"" + + // append to the existing command and return + return "$baseCommand $escapedSourcePath $destination" + } + + @NonCPS + CommandBuilder addArguments(String arguments) { + commandBuilder.addArguments(arguments) + return this + } + + @NonCPS + CommandBuilder addArguments(List arguments) { + commandBuilder.addArguments(arguments) + return this + } + + @Override + @NonCPS + CommandBuilder reset() { + host = null + user = null + destinationPath = null + sourcePath = null + credentials = null + commandBuilder.reset() + return this + } + + @Override + @NonCPS + CommandBuilder setExecutable(String executable) { + commandBuilder.setExecutable(executable) + return this + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/shell/ShellUtils.groovy b/src/io/wcm/tooling/jenkins/pipeline/shell/ShellUtils.groovy new file mode 100644 index 0000000..f520051 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/shell/ShellUtils.groovy @@ -0,0 +1,122 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.shell + +import com.cloudbees.groovy.cps.NonCPS + +/** + * Utilities for the shell + */ +class ShellUtils implements Serializable { + + private static final long serialVersionUID = 1L + + /** + * List of all character that must be escaped for shell usage + */ + static escapeCharacters = [ + "\\", + " ", + '"', + "'", + "!", + "#", + "\$", + "&", + "(", + ")", + ",", + ";", + "<", + ">", + "?", + "[", + "]", + "^", + "`", + "{", + "}", + "|" + ] + + /** + * Escapes an incoming path by removing quotes and escaping it for shell + * + * @param path The path to be escaped + * @return the escaped path + */ + @NonCPS + static String escapePath(String path) { + if (path == null) return null + // qualified reference due to GroovySandBox + path = ShellUtils.trimDoubleQuote(path) + path = ShellUtils.trimSingleQuote(path) + path = ShellUtils.escapeShellCharacters(path) + return path + } + + /** + * Removes one double quote at beginning and one double quote at the end + * + * @param str The string to be trimmed + * @return the String with removed first and last double quote (when present) + */ + @NonCPS + static String trimDoubleQuote(String str) { + // TODO: implement functionality by using only one regular expression + // remove beginning double quote + def matcher = str =~ '^"?(.*)' + str = matcher ? matcher[0][1] : str + // remove ending double quote + matcher = str =~ '^(.*)"$' + str = matcher ? matcher[0][1] : str + return str + } + + /** + * Removes one single quote at beginning and one single quote at the end + * + * @param str The string to be trimmed + * @return the String with removed first and last double quote (when present) + */ + @NonCPS + static String trimSingleQuote(String str) { + // TODO: implement functionality by using only one regular expression + def matcher = str =~ "^'?(.*)" + str = matcher ? matcher[0][1] : str + matcher = str =~ "^(.*)'\$" + str = matcher ? matcher[0][1] : str + return str + } + + /** + * Escapes characters for shell usage + * + * @param str The string to be escaped for use in shell + * @return str The escaped String + */ + @NonCPS + static String escapeShellCharacters(String str) { + for (String escapeCharacter : escapeCharacters) { + str = str.replace(escapeCharacter, "\\$escapeCharacter") + } + return str + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/ssh/SSHTarget.groovy b/src/io/wcm/tooling/jenkins/pipeline/ssh/SSHTarget.groovy new file mode 100644 index 0000000..2ea86ac --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/ssh/SSHTarget.groovy @@ -0,0 +1,68 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + + +package io.wcm.tooling.jenkins.pipeline.ssh + +import com.cloudbees.groovy.cps.NonCPS +import io.wcm.tooling.jenkins.pipeline.credentials.Credential +import io.wcm.tooling.jenkins.pipeline.credentials.CredentialAware + +/** + * Value object for ssh targets + */ +class SSHTarget implements Serializable, CredentialAware { + + private static final long serialVersionUID = 1L + + String host + + Credential _credential = null + + /** + * The host to connect to + * @param host + */ + SSHTarget(String host) { + this.host = host + } + + /** + * Used to set the username based on a Credential found by auto lookup + * + * @param credential The credential object to use the username from (if set) + */ + @Override + @NonCPS + void setCredential(Credential credential) { + this._credential = credential + } + + /** + * Getter function for credentials + * + * @return The stored credentials + */ + @Override + @NonCPS + Credential getCredential() { + return this._credential + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/tools/ansible/Role.groovy b/src/io/wcm/tooling/jenkins/pipeline/tools/ansible/Role.groovy new file mode 100644 index 0000000..40ea9b8 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/tools/ansible/Role.groovy @@ -0,0 +1,66 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +package io.wcm.tooling.jenkins.pipeline.tools.ansible + +import com.cloudbees.groovy.cps.NonCPS + +/** + * Object for ansible roles + */ +class Role implements Serializable { + + private static final long serialVersionUID = 1L + + public static SCM_GIT = "git" + + String src = null + + String name = null + + String scm = null + + String version = "master" + + /** + * The src, either a galaxy role name or a scm path + * + * @param src + */ + Role(String src) { + this.src = src + this.name = src + } + + @NonCPS + public Boolean isValid() { + return this.src != null + } + + @NonCPS + public Boolean isScmRole() { + return this.scm == Role.SCM_GIT + } + + @NonCPS + public Boolean isGalaxyRole() { + return this.scm == null + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/tools/ansible/RoleRequirements.groovy b/src/io/wcm/tooling/jenkins/pipeline/tools/ansible/RoleRequirements.groovy new file mode 100644 index 0000000..e23a75f --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/tools/ansible/RoleRequirements.groovy @@ -0,0 +1,118 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +package io.wcm.tooling.jenkins.pipeline.tools.ansible + +import com.cloudbees.groovy.cps.NonCPS +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Utility class for a loaded requirements YAML file. + * Provides parsing into Role objects and transforming them into checkout configurations. + */ +class RoleRequirements implements Serializable { + + private static final long serialVersionUID = 1L + + List _roles = [] + + Logger log = new Logger(this) + + List ymlContent + + boolean _parsed = false + + /** + * @param ymlContent The loaded YAML content from a requirements YAML file + */ + RoleRequirements(List ymlContent) { + this.ymlContent = ymlContent + } + + /** + * parses the ymlContent into Role objects + * + * @see Role + */ + @NonCPS + public void parse() { + if (_parsed == true) { + return + } + for (Map requirement in this.ymlContent) { + String src = requirement.src ?: null + String scm = requirement.scm ?: null + String name = requirement.name ?: null + String version = requirement.version ?: null + + Role role = new Role(src) + if (scm != null) role.setScm(scm) + if (name != null) role.setName(name) + if (version != null) role.setVersion(version) + + if (role.isValid()) { + log.trace("adding role") + _roles.push(role) + } + } + + this._parsed = true + } + + /** + * Getter function for roles + * @return + */ + @NonCPS + public List getRoles() { + this.parse() + return this._roles + } + + /** + * Transforms the parsed ansible roles into checkout configurations which can be used with the checkoutScm step + * @return A list of checkout configurations for scmCheckout + */ + @NonCPS + public List getCheckoutConfigs() { + List ret = [] + for (Role role in this.getRoles()) { + log.debug("getCheckoutConfigs role: " + role.getSrc()) + if (role.isScmRole()) { + log.debug("getCheckoutConfigs role is scmRole!") + Map scmConfig = [ + (SCM): [ + (SCM_URL) : role.getSrc(), + (SCM_BRANCHES) : [[name: role.getVersion()]], + (SCM_EXTENSIONS): [ + [$class: 'LocalBranch'], + [$class: 'RelativeTargetDirectory', relativeTargetDir: role.getName()], + [$class: 'ScmName', name: role.getName()] + ] + ] + ] + ret.push(scmConfig) + } + } + return ret + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy b/src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy new file mode 100644 index 0000000..90d8ea8 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy @@ -0,0 +1,112 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils + +/** + * Constants for configuration values. Used for passing configuration options into the library steps + */ +class ConfigConstants { + + public static final ANSI_COLOR = "ansiColor" + public static final ANSI_COLOR_XTERM = "xterm" + public static final ANSI_COLOR_GNOME_TERMINAL = "gnome-terminal" + public static final ANSI_COLOR_VGA = "vga" + public static final ANSI_COLOR_CSS = "css" + + public static final String ANSIBLE = "ansible" + public static final String ANSIBLE_COLORIZED = "colorized" + public static final String ANSIBLE_CREDENTIALS_ID = "credentialsId" + public static final String ANSIBLE_EXTRA_PARAMETERS = "extraParameters" + public static final String ANSIBLE_EXTRA_VARS = "extraVars" + public static final String ANSIBLE_FORKS = "forks" + public static final String ANSIBLE_INJECT_PARAMS = "injectParams" + public static final String ANSIBLE_INSTALLATION = "installation" + public static final String ANSIBLE_INVENTORY = "inventory" + public static final String ANSIBLE_LIMIT = "limit" + public static final String ANSIBLE_SKIPPED_TAGS = "skippedTags" + public static final String ANSIBLE_START_AT_TASK = "startAtTask" + public static final String ANSIBLE_TAGS = "tags" + public static final String ANSIBLE_SUDO = "sudo" + public static final String ANSIBLE_SUDO_USER = "sudoUser" + public static final String ANSIBLE_PLAYBOOK = "playbook" + + public static final String MAVEN = "maven" + public static final String MAVEN_ARGUMENTS = "arguments" + public static final String MAVEN_DEFINES = "defines" + public static final String MAVEN_EXECUTABLE = "executable" + public static final String MAVEN_GLOBAL_SETTINGS = "globalSettings" + public static final String MAVEN_GOALS = "goals" + public static final String MAVEN_INJECT_PARAMS = "injectParams" + public static final String MAVEN_POM = "pom" + public static final String MAVEN_PROFILES = "profiles" + public static final String MAVEN_SETTINGS = "settings" + + public static final String LOGLEVEL = "logLevel" + + public static final String NOTIFY = "notify" + public static final String NOTIFY_ATTACH_LOG = "attachLog" + public static final String NOTIFY_ATTACHMENTS_PATTERN = "attachmentsPattern" + public static final String NOTIFY_BODY = "body" + public static final String NOTIFY_COMPRESS_LOG = "compressLog" + public static final String NOTIFY_MIME_TYPE = "mimeType" + public static final String NOTIFY_ON_SUCCESS = "onSuccess" + public static final String NOTIFY_ON_FAILURE = "onFailure" + public static final String NOTIFY_ON_STILL_FAILING = "onStillFailing" + public static final String NOTIFY_ON_FIXED = "onFixed" + public static final String NOTIFY_ON_UNSTABLE = "onUnstable" + public static final String NOTIFY_ON_STILL_UNSTABLE = "onStillUnstable" + public static final String NOTIFY_ON_ABORT = "onAbort" + public static final String NOTIFY_RECIPIENT_PROVIDERS = "recipientProviders" + public static final String NOTIFY_SUBJECT = "subject" + public static final String NOTIFY_TO = "to" + + public static final String NPM = "NPM" + public static final String NPM_ARGUMENTS = "arguments" + public static final String NPM_EXECUTABLE = "executable" + + public static final String SCM = "scm" + public static final String SCM_BRANCHES = "branches" + public static final String SCM_CREDENTIALS_ID = "credentialsId" + public static final String SCM_DO_GENERATE_SUBMODULE_CONFIGURATION = "doGenerateSubmoduleConfigurations" + public static final String SCM_EXTENSIONS = "extensions" + public static final String SCM_SUBMODULE_CONFIG = "submoduleCfg" + public static final String SCM_URL = "url" + public static final String SCM_USER_REMOTE_CONFIG = "userRemoteConfig" + public static final String SCM_USER_REMOTE_CONFIGS = "userRemoteConfigs" + public static final String SCM_USE_SCM_VAR = "useScmVar" + + public static final String SCP = "scp" + public static final String SCP_ARGUMENTS = "arguments" + public static final String SCP_DESTINATION = "destination" + public static final String SCP_EXECUTABLE = "executable" + public static final String SCP_HOST = "host" + public static final String SCP_HOST_KEY_CHECK = "hostKeyCheck" + public static final String SCP_PORT = "port" + public static final String SCP_RECURSIVE = "recursive" + public static final String SCP_SOURCE = "source" + public static final String SCP_USER = "user" + + public static final String TOOLS = "tools" + public static final String TOOL_ENVVAR = "envVar" + public static final String TOOL_NAME = "name" + public static final String TOOL_TYPE = "type" + + +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/utils/IntegrationTestHelper.groovy b/src/io/wcm/tooling/jenkins/pipeline/utils/IntegrationTestHelper.groovy new file mode 100644 index 0000000..8561f44 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/utils/IntegrationTestHelper.groovy @@ -0,0 +1,88 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils + +import com.cloudbees.groovy.cps.NonCPS + +/** + * Helper for integration tests to capture test results + */ +class IntegrationTestHelper implements Serializable { + + private static final long serialVersionUID = 1L + + private static Map allResults = [:] + + private static String currentPackage = null + + /** + * Adds a new package to the test results + * + * @param packageName The name of the package + */ + @NonCPS + public static void addTestPackage(String packageName) { + currentPackage = packageName + getCurrentPackageResults() + } + + /** + * Adds a test result to the current package + * + * @param result The test result + */ + @NonCPS + public static void addTestResult(Map result) { + getCurrentPackageResults().push(result) + } + + /** + * Helper to initialize and return the test result list for the current package + * + * @return The result list for the current package + */ + @NonCPS + public static List getCurrentPackageResults() { + if (allResults[currentPackage] == null) { + allResults[currentPackage] = [] + } + return (List) allResults[currentPackage] + } + + /** + * Getter for the test results + * + * @return The results + */ + @NonCPS + public static Map getResults() { + return allResults + } + + /** + * Resets the test results + */ + @NonCPS + public static void reset() { + allResults = [:] + currentPackage = null + } + +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/utils/ListUtils.groovy b/src/io/wcm/tooling/jenkins/pipeline/utils/ListUtils.groovy new file mode 100644 index 0000000..44c29cf --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/utils/ListUtils.groovy @@ -0,0 +1,71 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils + +import com.cloudbees.groovy.cps.NonCPS + +/** + * Utility functions for Lists because of missing white list functions + */ +class ListUtils implements Serializable { + + private static final long serialVersionUID = 1L + + /** + * Workaround for blocked removeAt function + * + * @param list The list to be processed + * @param idx The index to be removed + * @return the manipulated list + */ + @NonCPS + public static List removeAt(List list, Integer idx) { + Integer walkIdx = -1 + // walk through list and remove the item at the given index + list.removeAll { + walkIdx++ + if (idx == walkIdx) { + return true + } + return false + } + return list + } + + /** + * Workaround for blocked indexOf function + * + * @param list The list to search in + * @param item The item to search for + * @return The index of the found item or -1 when item was not found + */ + @NonCPS + public static Integer indexOf(List list, Object item) { + Integer foundPosition = -1 + for (int i = 0; i < list.size(); i++) { + if (list[i] == item) { + foundPosition = i + break + } + } + return foundPosition + } + +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/utils/NotificationTriggerHelper.groovy b/src/io/wcm/tooling/jenkins/pipeline/utils/NotificationTriggerHelper.groovy new file mode 100644 index 0000000..310ce56 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/utils/NotificationTriggerHelper.groovy @@ -0,0 +1,162 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils + +import com.cloudbees.groovy.cps.NonCPS +import hudson.model.Result as JenkinsResult +import io.wcm.tooling.jenkins.pipeline.model.Result + +/** + * Helper class to reimplement the notification options provided by the extmail plugin. + * This helper makes it possible to detect still failing, still unstable and fixed within pipeline scripts + */ +class NotificationTriggerHelper implements Serializable { + + private static final long serialVersionUID = 1L + + final static String ENV_TRIGGER = "NOTIFICATION_TRIGGER" + + protected Result currentResult = null + protected Result lastResult = null + + protected Boolean isAborted = false + protected Boolean isFailure = false + protected Boolean isUnstable = false + protected Boolean isSuccess = false + protected Boolean isStillFailing = false + protected Boolean isStillUnstable = false + protected Boolean isFixed = false + + NotificationTriggerHelper(JenkinsResult currentResultObject) { + this(currentResultObject.toString(), null) + } + + NotificationTriggerHelper(JenkinsResult currentResultObject, JenkinsResult lastResultObject) { + this(currentResultObject.toString(), lastResultObject != null ? lastResultObject.toString() : null) + } + + NotificationTriggerHelper(String currentBuildResult, String lastBuildResult) { + // fix for declarative pipeline, when currentBuildResult is null the build is SUCCESS + if (currentBuildResult == null) { + currentBuildResult = JenkinsResult.SUCCESS.toString() + } + + this.currentResult = Result.fromString(currentBuildResult) + this.lastResult = Result.fromString(lastBuildResult) + } + + /** + * Checks the current and the previous build result and calculates the trigger for which to send the notification + * + * @see Result + * + * @return The trigger for the notification + */ + @NonCPS + protected Result calculateTrigger() { + Result trigger + // reset + isAborted = false + isFailure = false + isUnstable = false + isSuccess = false + isStillFailing = false + isStillUnstable = false + isFixed = false + + // check if build is still failing + if (currentResult == lastResult && currentResult == Result.FAILURE) { + isStillFailing = true + return Result.STILL_FAILING + } // check if build is still unstable + else if (currentResult == lastResult && currentResult == Result.UNSTABLE) { + isStillUnstable = true + return Result.STILL_UNSTABLE + } // check if build is fixed + else if (currentResult == Result.SUCCESS && + lastResult && + currentResult.isBetterThan(lastResult) && + lastResult.isBetterOrEqualTo(Result.FAILURE)) { + isFixed = true + return Result.FIXED + } // per default set the trigger to the current result + else { + trigger = Result.fromString(currentResult.toString()) + } + + // check the result trigger and set the properties + if (trigger == Result.FAILURE) ( + isFailure = true + ) else if (trigger == Result.SUCCESS) { + isSuccess = true + } else if (trigger == Result.UNSTABLE) { + isUnstable = true + } else if (trigger == Result.ABORTED) { + isAborted = true + } + return trigger + } + + @NonCPS + String replaceEnvVar(String str, String value) { + if (str == null || value == null) return null + return str.replaceAll('\\$\\{' + ENV_TRIGGER + '\\}', value) + } + + @NonCPS + Result getTrigger() { + return calculateTrigger() + } + + @NonCPS + Boolean isAborted() { + return isAborted + } + + @NonCPS + Boolean isFailure() { + return isFailure + } + + @NonCPS + Boolean isUnstable() { + return isUnstable + } + + @NonCPS + Boolean isSuccess() { + return isSuccess + } + + @NonCPS + Boolean isStillFailing() { + return isStillFailing + } + + @NonCPS + Boolean isStillUnstable() { + return isStillUnstable + } + + @NonCPS + Boolean isFixed() { + return isFixed + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcher.groovy b/src/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcher.groovy new file mode 100644 index 0000000..b62addf --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcher.groovy @@ -0,0 +1,74 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils + +import com.cloudbees.groovy.cps.NonCPS +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import io.wcm.tooling.jenkins.pipeline.model.PatternMatchable +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger + +import java.util.regex.Matcher + +/** + * Utility function to match incoming strings (scm urls) against a list of PatternMatchable objects. + * Used to get necessary ManagedFile or Credential Objects for an URL (scm url) + * + * @see PatternMatchable + * @see io.wcm.tooling.jenkins.pipeline.credentials.Credential + * @see io.wcm.tooling.jenkins.pipeline.managedfiles.ManagedFile + */ +class PatternMatcher implements Serializable { + + Logger log = new Logger(this) + + /** + * Returns the best match for the searchValue out of a list of PatternMatchable list. + * As score the length of the match is used. The more characters match the better the score. + * + * @param searchValue The String to match against the patterns of the proviced items + * @param items A list of PatternMatchable items in which the algorithm is searching for the best match + * @return The match with the best score (length of match) + */ + @NonCPS + @SuppressFBWarnings('SE_NO_SERIALVERSIONID') + PatternMatchable getBestMatch(String searchValue, List items) { + log.debug("getBestPatternMatch '$searchValue'") + PatternMatchable result = null + int matchScore = 0 + // Walk through list and match each pattern of the PatternMatchable against the searchvalue + items.each { + item -> + log.debug("try to match file: " + item + " with pattern " + item.getPattern()) + Matcher matcher = searchValue =~ item.getPattern() + // check if ther is a match + if (matcher) { + String group = matcher[0] + // check if matcher has a group and if the matched length/score is better as the last found match + if (group && (group.length() > matchScore)) { + matchScore = group.length() + log.trace("match found with score $matchScore") + result = item + } + } + } + return result + } + +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/utils/TypeUtils.groovy b/src/io/wcm/tooling/jenkins/pipeline/utils/TypeUtils.groovy new file mode 100644 index 0000000..cd3778a --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/utils/TypeUtils.groovy @@ -0,0 +1,98 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils + +import com.cloudbees.groovy.cps.NonCPS +import io.wcm.tooling.jenkins.pipeline.versioning.ComparableVersion + +/** + * Utility class for detecting type of variables since instanceof is forbidden in groovy pipeline sandbox. + * This utiltiy uses simple methods with type overloading to simply return true or false. + */ +class TypeUtils implements Serializable { + + private static final long serialVersionUID = 1L + + /** + * Utility function to return false for all non Map objects + * + * @param object Any other object that is not of type Map + * @return false + */ + @NonCPS + Boolean isMap(Object object) { + return false + } + + /** + * Utility function to return true for all Map objects + * + * @param object Map object + * @return true + */ + @NonCPS + Boolean isMap(Map object) { + return true + } + + /** + * Utility function to return false for all non List objects + * + * @param object Any other object that is not of type List + * @return false + */ + @NonCPS + Boolean isList(Object object) { + return false + } + + /** + * Utility function to return true for all List objects + * + * @param object List object + * @return true + */ + @NonCPS + Boolean isList(List object) { + return true + } + + /** + * Utility function to return false for all non ListItem objects + * + * @param object Comparable Version object + * @return true + */ + @NonCPS + Boolean isComparableVersion(ComparableVersion object) { + return true + } + + /** + * Utility function to return false for all non ComparableVersion objects + * + * @param object Any other object that is not of type ComparableVersion + * @return false + */ + @NonCPS + Boolean isComparableVersion(Object object) { + return false + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/utils/logging/LogLevel.groovy b/src/io/wcm/tooling/jenkins/pipeline/utils/logging/LogLevel.groovy new file mode 100644 index 0000000..d429281 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/utils/logging/LogLevel.groovy @@ -0,0 +1,75 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils.logging + +import com.cloudbees.groovy.cps.NonCPS +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings + +/** + * Enumeration for log levels + */ +@SuppressFBWarnings('ME_ENUM_FIELD_SETTER') +enum LogLevel implements Serializable { + + ALL(0, 0), + TRACE(2, 8), + DEBUG(3, 12), + INFO(4, 0), + WARN(5, 202), + ERROR(6, 5), + FATAL(7, 9), + NONE(Integer.MAX_VALUE, 0) + + Integer level + + static COLOR_CODE_PREFIX = "1;38;5;" + + Integer color + + private static final long serialVersionUID = 1L + + LogLevel(Integer level, Integer color) { + this.level = level + this.color = color + } + + @NonCPS + static LogLevel fromInteger(Integer value) { + for (lvl in values()) { + if (lvl.getLevel() == value) return lvl + } + return INFO + } + + @NonCPS + static LogLevel fromString(String value) { + for (lvl in values()) { + if (lvl.toString().equalsIgnoreCase(value)) return lvl + } + return INFO + } + + @NonCPS + public String getColorCode() { + return COLOR_CODE_PREFIX + color.toString() + } + + +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/utils/logging/Logger.groovy b/src/io/wcm/tooling/jenkins/pipeline/utils/logging/Logger.groovy new file mode 100644 index 0000000..76207f7 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/utils/logging/Logger.groovy @@ -0,0 +1,433 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils.logging + +import com.cloudbees.groovy.cps.NonCPS +import io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants +import org.jenkinsci.plugins.scriptsecurity.sandbox.RejectedAccessException +import org.jenkinsci.plugins.workflow.cps.CpsScript +import org.jenkinsci.plugins.workflow.cps.DSL + +/** + * Logging functionality for pipeline scripts. + */ +class Logger implements Serializable { + + private static final long serialVersionUID = 1L + + /** + * Reference to the dsl/script object + */ + static DSL dsl + + /** + * Reference to the CpsScript/WorkflowScript + */ + static Script script + + /** + * The log level + */ + static LogLevel level = LogLevel.TRACE + + /** + * The name of the logger + */ + public String name = "" + + /** + * Flag if the logger is initialized + */ + public static Boolean initialized = false + + /** + * @param name The name of the logger + */ + Logger(String name = "") { + this.name = name + } + + /** + * @param logScope The object the logger is for. The name of the logger is autodetected. + */ + Logger(Object logScope) { + if (logScope instanceof Object) { + this.name = getClassName(logScope) + if (this.name == null) { + this.name = "$logScope" + } + } + } + + /** + * Initializes the logger with DSL/steps object and LogLevel + * + * @param dsl The DSL object of the current pipeline script (available via this.steps in pipeline scripts) + * @param logLvl The log level to use during execution of the pipeline script + * @deprecated + */ + @NonCPS + static void init(DSL dsl, LogLevel logLvl = LogLevel.INFO) { + if (logLvl == null) logLvl = LogLevel.INFO + level = logLvl + if (Logger.initialized == true) { + return + } + this.dsl = dsl + dsl.echo("[WARN]\n[WARN] Deprecated! Initializing the logger with steps/DSL object will be removed in future release\n[WARN]") + initialized = true + } + + /** + * Initializes the logger with DSL/steps object and configuration map + * + * @param dsl The DSL object of the current pipeline script (available via this.steps in pipeline scripts) + * @param map The configuration object of the pipeline + * @deprecated + */ + @NonCPS + static void init(DSL dsl, Map map) { + LogLevel lvl + if (map) { + lvl = map[ConfigConstants.LOGLEVEL] ?: LogLevel.INFO + } else { + lvl = LogLevel.INFO + } + init(dsl, lvl) + } + + /** + * Initializes the logger with DSL/steps object and loglevel as string + * + * @param dsl The DSL object of the current pipeline script (available via this.steps in pipeline scripts) + * @param sLevel the log level as string + * @deprecated + */ + @NonCPS + static void init(DSL dsl, String sLevel) { + if (sLevel == null) sLevel = LogLevel.INFO + init(dsl, LogLevel.fromString(sLevel)) + } + + /** + * Initializes the logger with DSL/steps object and loglevel as integer + * + * @param dsl The DSL object of the current pipeline script (available via this.steps in pipeline scripts) + * @param iLevel the log level as integer + * + * @deprecated + */ + @NonCPS + static void init(DSL dsl, Integer iLevel) { + if (iLevel == null) iLevel = LogLevel.INFO.getLevel() + init(dsl, LogLevel.fromInteger(iLevel)) + } + + /** + * Initializes the logger with CpsScript object and LogLevel + * + * @param script CpsScript object of the current pipeline script (available via this in pipeline scripts) + * @param map The configuration object of the pipeline + */ + @NonCPS + static void init(Script script, LogLevel logLvl = LogLevel.INFO) { + if (logLvl == null) logLvl = LogLevel.INFO + level = logLvl + if (Logger.initialized == true) { + return + } + this.script = script + this.dsl = (DSL) script.steps + initialized = true + } + + /** + * Initializes the logger with CpsScript object and configuration map + * + * @param script CpsScript object of the current pipeline script (available via this in pipeline scripts) + * @param map The configuration object of the pipeline + */ + @NonCPS + static void init(Script script, Map map) { + LogLevel lvl + if (map) { + lvl = map[ConfigConstants.LOGLEVEL] ?: LogLevel.INFO + } else { + lvl = LogLevel.INFO + } + init(script, lvl) + } + + /** + * Initializes the logger with CpsScript object and loglevel as string + * + * @param script CpsScript object of the current pipeline script (available via this in pipeline scripts) + * @param sLevel the log level as string + */ + @NonCPS + static void init(Script script, String sLevel) { + if (sLevel == null) sLevel = LogLevel.INFO + init(script, LogLevel.fromString(sLevel)) + } + + /** + * Initializes the logger with DSL/steps object and loglevel as integer + * + * @param script CpsScript object of the current pipeline script (available via this in pipeline scripts) + * @param iLevel the log level as integer + * + */ + @NonCPS + static void init(Script script, Integer iLevel) { + if (iLevel == null) iLevel = LogLevel.INFO.getLevel() + init(script, LogLevel.fromInteger(iLevel)) + } + + /** + * Logs a trace message followed by object dump + * + * @param message The message to be logged + * @param object The object to be dumped + */ + @NonCPS + void trace(String message, Object object) { + log(LogLevel.TRACE, message, object) + } + + /** + * Logs a info message followed by object dump + * + * @param message The message to be logged + * @param object The object to be dumped + */ + @NonCPS + void info(String message, Object object) { + log(LogLevel.INFO, message, object) + } + + /** + * Logs a debug message followed by object dump + * + * @param message The message to be logged + * @param object The object to be dumped + */ + @NonCPS + void debug(String message, Object object) { + log(LogLevel.DEBUG, message, object) + } + + /** + * Logs warn message followed by object dump + * + * @param message The message to be logged + * @param object The object to be dumped + */ + @NonCPS + void warn(String message, Object object) { + log(LogLevel.WARN, message, object) + } + + /** + * Logs a error message followed by object dump + * + * @param message The message to be logged + * @param object The object to be dumped + */ + @NonCPS + void error(String message, Object object) { + log(LogLevel.ERROR, message, object) + } + + /** + * Logs a fatal message followed by object dump + * + * @param message The message to be logged + * @param object The object to be dumped + */ + @NonCPS + void fatal(String message, Object object) { + log(LogLevel.FATAL, message, object) + } + + /** + * Logs a trace message + * + * @param message The message to be logged + * @param object The object to be dumped + */ + @NonCPS + void trace(String message) { + log(LogLevel.TRACE, message) + } + + /** + * Logs a trace message + * + * @param message The message to be logged + * @param object The object to be dumped + */ + @NonCPS + void info(String message) { + log(LogLevel.INFO, message) + } + + /** + * Logs a debug message + * + * @param message The message to be logged + * @param object The object to be dumped + */ + @NonCPS + void debug(String message) { + log(LogLevel.DEBUG, message) + } + + /** + * Logs a warn message + * + * @param message The message to be logged + * @param object The object to be dumped + */ + @NonCPS + void warn(String message) { + log(LogLevel.WARN, message) + } + + /** + * Logs a error message + * + * @param message The message to be logged + * @param object The object to be dumped + */ + @NonCPS + void error(String message) { + log(LogLevel.ERROR, message) + } + + /** + * Logs a fatal message + * + * @param message The message to be logged + * @param object The object to be dumped + */ + @NonCPS + void fatal(String message) { + log(LogLevel.FATAL, message) + } + + /** + * Helper function for logging/dumping a object at the given log level + * + * @param logLevel the loglevel to be used + * @param message The message to be logged + * @param object The object to be dumped + */ + @NonCPS + void log(LogLevel logLevel, String message, Object object) { + if (doLog(logLevel)) { + def objectName = getClassName(object) + if (objectName != null) { + objectName = "($objectName) " + } else { + objectName = "" + } + + def objectString = object.toString() + String msg = "$name : $message -> $objectName$objectString" + writeLogMsg(logLevel, msg) + } + } + + /** + * Helper function for logging at the given log level + * + * @param logLevel the loglevel to be used + * @param message The message to be logged + */ + @NonCPS + void log(LogLevel logLevel, String message) { + if (doLog(logLevel)) { + String msg = "$name : $message" + writeLogMsg(logLevel, msg) + } + } + + /** + * Utility function for writing to the jenkins console + * + * @param logLevel the loglevel to be used + * @param msg The message to be logged + */ + @NonCPS + private static void writeLogMsg(LogLevel logLevel, String msg) { + String lvlString = "[${logLevel.toString()}]" + + if (script != null) { + // check if color can be used + String termEnv = null + try { + termEnv = script.env.TERM + } catch (Exception ex) { + + } + if (termEnv != null) { + String colorCode = logLevel.getColorCode() + lvlString = "\u001B[${colorCode}m${lvlString}\u001B[0m" + } + } + if (dsl != null) { + dsl.echo("$lvlString $msg") + } + } + + /** + * Utiltiy function to determine if the given logLevel is active + * + * @param logLevel + * @return true , when the loglevel should be displayed, false when the loglevel is disabled + */ + @NonCPS + private static boolean doLog(LogLevel logLevel) { + if (logLevel.getLevel() >= level.getLevel()) { + return true + } + return false + } + + /** + * Helper function to get the name of the object + * @param object + * @return + */ + @NonCPS + private static String getClassName(Object object) { + String objectName = null + // try to retrieve as much information as possible about the class + try { + Class objectClass = object.getClass() + objectName = objectClass.getName().toString() + objectName = objectClass.getCanonicalName().toString() + } catch (RejectedAccessException e) { + // do nothing + } + + return objectName + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/utils/maps/MapUtils.groovy b/src/io/wcm/tooling/jenkins/pipeline/utils/maps/MapUtils.groovy new file mode 100644 index 0000000..24c07a4 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/utils/maps/MapUtils.groovy @@ -0,0 +1,93 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils.maps + +import com.cloudbees.groovy.cps.NonCPS +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import io.wcm.tooling.jenkins.pipeline.utils.TypeUtils +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger + +/** + * Utility functions for Map objects + */ +@SuppressWarnings("UnnecessaryQualifiedReference") +class MapUtils implements Serializable { + + private static final long serialVersionUID = 1L + + static Logger log = new Logger(this) + + static typeUtils = new TypeUtils() + + /** + * Merges 0 to n Map objects recursively into one Map + * + * Overlapping keys will be overwritten by N+1 values. + * E.g. + * map[0] has "key" with "value" + * map[1] has "key" with "newValue" + * + * Resulting will have "key" with "newValue" + * + * @param maps 0 to n maps that have to me merged. + * @return The merged map + */ + @NonCPS + @SuppressFBWarnings('SE_NO_SERIALVERSIONID') + static transient Map merge(Map... maps) { + Map result + + if (maps.length == 0) { + result = [:] + } else if (maps.length == 1) { + result = maps[0] + } else { + result = [:] + maps.each { map -> + map.each { k, v -> + log.trace("result[k]: ", result[k]) + log.trace("v: ", v) + /*log.trace("isList result[k]: ", TypeUtils.isList(result[k])) + log.trace("isList v: ", TypeUtils.isList(v))*/ + if (result[k] != null && typeUtils.isMap(result[k])) { + // unnecessary qualified reference is necessary here otherwise CPS / Sandbox will be violated + result[k] = MapUtils.merge((Map) result[k], (Map) v) + } else if (result[k] != null && typeUtils.isList(result[k]) && typeUtils.isList(v)) { + // execute a list merge + List list1 = (List) result[k] + List list2 = (List) v + + for (Object list2Item : list2) { + if (!list1.contains(list2Item)) + list1.add(list2Item) + } + result[k] = list1 + } else { + result[k] = v + } + } + } + } + + result + } + + +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/utils/resources/JsonLibraryResource.groovy b/src/io/wcm/tooling/jenkins/pipeline/utils/resources/JsonLibraryResource.groovy new file mode 100644 index 0000000..7edb016 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/utils/resources/JsonLibraryResource.groovy @@ -0,0 +1,72 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils.resources + +import com.cloudbees.groovy.cps.NonCPS +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import net.sf.json.JSON +import org.jenkinsci.plugins.workflow.cps.DSL + +/** + * Utility function for loading JSON library files + * + * @see LibraryResource + */ +class JsonLibraryResource implements Serializable { + + private static final long serialVersionUID = 1L + + Logger log = new Logger(this) + + LibraryResource libraryResource + + DSL dsl + + String file + + /** + * @param dsl The DSL object of the current pipeline script (available via this.steps in pipeline scripts) + * @param file Path to the file + */ + JsonLibraryResource(DSL dsl, String file) { + this.dsl = dsl + this.file = file + libraryResource = new LibraryResource(dsl, file) + } + + /** + * Loads the resource file via LibraryResource and uses the Pipeline Utility step readJSON to parse the content into + * a JSON object + * + * @return The loaded file as JSON object + */ + @NonCPS + JSON load() { + def jsonStr = libraryResource.load() + try { + JSON json = dsl.readJSON(text: jsonStr) + log.trace("parsed json: ${json}") + return json + } catch (Exception ex) { + log.fatal("Error parsing '$file' from project pipeline library: ${ex}") + throw ex + } + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/utils/resources/LibraryResource.groovy b/src/io/wcm/tooling/jenkins/pipeline/utils/resources/LibraryResource.groovy new file mode 100644 index 0000000..2bc3a68 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/utils/resources/LibraryResource.groovy @@ -0,0 +1,68 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils.resources + +import com.cloudbees.groovy.cps.NonCPS +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import org.jenkinsci.plugins.workflow.cps.DSL + +/** + * Utility function for loading library resources + */ +class LibraryResource implements Serializable { + + private static final long serialVersionUID = 1L + + String file = null + String content = null + DSL dsl + + Logger log = new Logger(this) + + /** + * @param dsl The DSL object of the current pipeline script (available via this.steps in pipeline scripts) + * @param file path to the file + */ + LibraryResource(DSL dsl, String file) { + this.file = file + this.dsl = dsl + } + + /** + * Loads the file and returns the content as String + * + * @return The content of the loaded library resource + */ + @NonCPS + String load() { + log.trace("loading $file", this) + if (content != null) { + return content + } + try { + content = this.dsl.libraryResource(file) + log.trace("content of $file: ${content}") + return content + } catch (Exception ex) { + log.fatal("Error loading $file from project pipeline library, error ${ex}") + throw ex + } + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/versioning/ComparableVersion.groovy b/src/io/wcm/tooling/jenkins/pipeline/versioning/ComparableVersion.groovy new file mode 100644 index 0000000..a344747 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/versioning/ComparableVersion.groovy @@ -0,0 +1,167 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.versioning + +import com.cloudbees.groovy.cps.NonCPS +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import io.wcm.tooling.jenkins.pipeline.utils.TypeUtils +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger + +/** + * Jenkins groovy sandbox compatible version of + * https://github.com/apache/maven/blob/master/maven-artifact/src/main/java/org/apache/maven/artifact/versioning/ComparableVersion.java + */ +class ComparableVersion implements Comparable, Serializable { + + public String value + + public ListItem items + + public String canonical + + private static final long serialVersionUID = 1L + + Logger log = new Logger(this) + + ComparableVersion(String version) { + log.trace("Constructor") + parseVersion(version) + } + + @Override + @NonCPS + int compareTo(ComparableVersion comparableVersion) { + log.trace("compareTo") + return items.compareTo(comparableVersion.items) + } + + @NonCPS + void parseVersion(String version) { + log.trace("parseVersion '$version'") + this.value = version + + log.trace("parseVersion pos 1") + items = new ListItem() + version = version.toLowerCase() + + ListItem list = items + + List stack = [] + stack.push(list) + + log.trace("parseVersion pos 2") + + boolean isDigit = false + int startIndex = 0 + + for (int i = 0; i < version.length(); i++) { + char c = version.charAt(i) + + if (c == '.') { + log.trace("parseVersion pos 2.1") + if (i == startIndex) { + list.add(IntegerItem.ZERO) + } else { + list.add(parseItem(isDigit, version.substring(startIndex, i))) + } + startIndex = i + 1 + } else if (c == '-') { + log.trace("parseVersion pos 2.2") + if (i == startIndex) { + list.add(IntegerItem.ZERO) + } else { + list.add(parseItem(isDigit, version.substring(startIndex, i))) + } + startIndex = i + 1 + + list.add(list = new ListItem()) + stack.push(list) + } else if (c =~ '^\\d$') { + log.trace("parseVersion pos 2.3") + if (!isDigit && i > startIndex) { + list.add(new StringItem(version.substring(startIndex, i), true)) + startIndex = i + + list.add(list = new ListItem()) + stack.push(list) + } + + isDigit = true + } else { + if (isDigit && i > startIndex) { + log.trace("parseVersion pos 2.4") + list.add(parseItem(true, version.substring(startIndex, i))) + startIndex = i + + list.add(list = new ListItem()) + stack.push(list) + } + + isDigit = false + } + } + + log.trace("parseVersion pos 3") + + if (version.length() > startIndex) { + list.add(parseItem(isDigit, version.substring(startIndex))) + } + + log.trace("parseVersion pos 4") + for (Integer i = stack.size() - 1; i >= 0; i--) { + list = (ListItem) stack[i] + list.normalize() + } + + log.trace("parseVersion pos 5") + canonical = items.toString() + } + + @NonCPS + Item parseItem(boolean isDigit, String buf) { + return isDigit ? new IntegerItem(buf) : new StringItem(buf, false) + } + + @Override + @SuppressFBWarnings("EQ_UNUSUAL") + @NonCPS + boolean equals(Object o) { + TypeUtils typeUtils = new TypeUtils() + return (typeUtils.isComparableVersion(o)) && canonical.equals(((ComparableVersion) o).canonical) + } + + @Override + @NonCPS + int hashCode() { + return canonical.hashCode() + } + + @Override + @NonCPS + String toString() { + return value + } + + @NonCPS + String getCanonical() { + return canonical + } + +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/versioning/IntegerItem.groovy b/src/io/wcm/tooling/jenkins/pipeline/versioning/IntegerItem.groovy new file mode 100644 index 0000000..94cbf95 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/versioning/IntegerItem.groovy @@ -0,0 +1,84 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.versioning + +import com.cloudbees.groovy.cps.NonCPS + +/** + * Jenkins groovy sandbox compatible version of + * https://github.com/apache/maven/blob/master/maven-artifact/src/main/java/org/apache/maven/artifact/versioning/ComparableVersion.java / IntegerItem + */ +class IntegerItem implements Item, Serializable { + + private static final long serialVersionUID = 1L + + public Integer value + + public static final Integer INTEGER_ZER0 = 0 + + public static final IntegerItem ZERO = new IntegerItem() + + IntegerItem() { + value = INTEGER_ZER0 + } + + IntegerItem(String str) { + this.value = str.toInteger() + } + + @Override + @NonCPS + int compareTo(Item item) { + if (item == null) { + return INTEGER_ZER0.equals(value) ? 0 : 1 // 1.0 == 1, 1.1 > 1 + } + + switch (item.getType()) { + case INTEGER_ITEM: + return value.compareTo(((IntegerItem) item).value) + + case STRING_ITEM: + return 1 // 1.1 > 1-sp + + case LIST_ITEM: + return 1 // 1.1 > 1-1 + + default: + throw new RuntimeException("invalid item: " + item.getClass()) + } + } + + @Override + @NonCPS + int getType() { + return INTEGER_ITEM + } + + @Override + @NonCPS + boolean isNull() { + return INTEGER_ZER0 == value + } + + @NonCPS + String toString() { + return value.toString() + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/versioning/Item.groovy b/src/io/wcm/tooling/jenkins/pipeline/versioning/Item.groovy new file mode 100644 index 0000000..b6afdac --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/versioning/Item.groovy @@ -0,0 +1,38 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.versioning + +/** + * Jenkins groovy sandbox compatible version of + * https://github.com/apache/maven/blob/master/maven-artifact/src/main/java/org/apache/maven/artifact/versioning/ComparableVersion.java / Item + */ +interface Item extends Serializable { + + int INTEGER_ITEM = 0 + int STRING_ITEM = 1 + int LIST_ITEM = 2 + + int compareTo(Item item) + + int getType() + + boolean isNull() + +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/versioning/ListItem.groovy b/src/io/wcm/tooling/jenkins/pipeline/versioning/ListItem.groovy new file mode 100644 index 0000000..dd0db4b --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/versioning/ListItem.groovy @@ -0,0 +1,347 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.versioning + +import com.cloudbees.groovy.cps.NonCPS +import io.wcm.tooling.jenkins.pipeline.utils.ListUtils + +/** + * Jenkins groovy sandbox compatible version of + * https://github.com/apache/maven/blob/master/maven-artifact/src/main/java/org/apache/maven/artifact/versioning/ComparableVersion.java / ListItem + * + * Extending ArrayList like the Original is not possible due to the sandbox so List interface was implemented. + */ +class ListItem implements List, Item, Serializable { + + static final long serialVersionUID = 1L + + ArrayList list = [] + + @Override + @NonCPS + int compareTo(Item item) { + if (item == null) { + if (size() == 0) { + return 0 // 1-0 = 1- (normalize) = 1 + } + Item first = get(0); + return first.compareTo(null) + } + switch (item.getType()) { + case INTEGER_ITEM: + return -1 // 1-1 < 1.0.x + + case STRING_ITEM: + return 1 // 1-1 > 1-sp + + case LIST_ITEM: + Iterator left = iterator() + Iterator right = ((ListItem) item).iterator() + + while (left.hasNext() || right.hasNext()) { + Item l = left.hasNext() ? left.next() : null + Item r = right.hasNext() ? right.next() : null + + // if this is shorter, then invert the compare and mul with -1 + int result = l == null ? (r == null ? 0 : -1 * r.compareTo(l)) : l.compareTo(r) + + if (result != 0) { + return result + } + } + + return 0 + + default: + throw new RuntimeException("invalid item: " + item.getClass()) + } + } + + @Override + @NonCPS + int getType() { + return LIST_ITEM + } + + @Override + @NonCPS + boolean isNull() { + return (size() == 0) + } + + @NonCPS + void normalize() { + for (int i = size() - 1; i >= 0; i--) { + Item lastItem = get(i) + + if (lastItem.isNull()) { + // remove null trailing items: 0, "", empty list + ListUtils.removeAt(list, i) + } else if (!(isListItem(lastItem))) { + break + } + } + } + + @NonCPS + String toString() { + String result = "" + for (Item item : this) { + if (result.length() > 0) { + if (isListItem(item)) { + result = "${result}-" + } else { + result = "${result}." + } + } + result = "$result${item.toString()}" + } + return result + } + + /** + * Utility function to return true for all ListItem objects + * + * @param object ListItem object + * @return true + */ + @NonCPS + Boolean isListItem(ListItem object) { + return true + } + + /** + * Utility function to return false for all non ListItem objects + * + * @param object Any other object that is not of type ListItem + * @return false + */ + @NonCPS + Boolean isListItem(Object object) { + return false + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + int size() { + return list.size() + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + boolean isEmpty() { + return list.isEmpty() + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + boolean contains(Object o) { + return list.contains(o) + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + Iterator iterator() { + return list.iterator() + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + Object[] toArray() { + return list.toArray() + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + boolean add(Object o) { + return list.add(o) + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + boolean remove(Object o) { + return list.remove(o) + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + boolean addAll(Collection c) { + return list.addAll(c) + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + boolean addAll(int index, Collection c) { + return list.addAll(index, c) + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + void clear() { + list.clear() + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + Object get(int index) { + return list.get(index) + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + Object set(int index, Object element) { + return list.set(index, element) + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + void add(int index, Object element) { + list.add(index, element) + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + Object remove(int index) { + return list.remove(index) + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + int indexOf(Object o) { + return list.indexOf(o) + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + int lastIndexOf(Object o) { + return list.lastIndexOf(o) + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + ListIterator listIterator() { + return list.listIterator() + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + ListIterator listIterator(int index) { + return list.listIterator(index) + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + List subList(int fromIndex, int toIndex) { + return list.subList(fromIndex, toIndex) + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + boolean retainAll(Collection c) { + return list.retainAll(c) + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + boolean removeAll(Collection c) { + return list.removeAll(c) + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + boolean containsAll(Collection c) { + return list.containsAll(c) + } + + /** + * Adapter function for internal list object + */ + @Override + @NonCPS + Object[] toArray(Object[] a) { + return list.toArray(a) + } +} diff --git a/src/io/wcm/tooling/jenkins/pipeline/versioning/StringItem.groovy b/src/io/wcm/tooling/jenkins/pipeline/versioning/StringItem.groovy new file mode 100644 index 0000000..2f23f85 --- /dev/null +++ b/src/io/wcm/tooling/jenkins/pipeline/versioning/StringItem.groovy @@ -0,0 +1,131 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.versioning + +import com.cloudbees.groovy.cps.NonCPS +import io.wcm.tooling.jenkins.pipeline.utils.ListUtils + +/** + * Jenkins groovy sandbox compatible version of + * https://github.com/apache/maven/blob/master/maven-artifact/src/main/java/org/apache/maven/artifact/versioning/ComparableVersion.java / StringItem + */ +class StringItem implements Item, Serializable { + + static final long serialVersionUID = 1L + + public static final List _QUALIFIERS = ["alpha", "beta", "milestone", "rc", "snapshot", "", "sp"]; + + /** + * A comparable value for the empty-string qualifier. This one is used to determine if a given qualifier makes + * the version older than one without a qualifier, or more recent. + */ + private static final String RELEASE_VERSION_INDEX = String.valueOf(_QUALIFIERS.indexOf("")) + + String value = null + + Map ALIASES = [ + "ga" : "", + "final": "", + "cr" : "rc" + ] + + StringItem(String value, boolean followedByDigit) { + if (followedByDigit && value.length() == 1) { + // a1 = alpha-1, b1 = beta-1, m1 = milestone-1 + switch (value.charAt(0)) { + case 'a': + value = "alpha" + break + case 'b': + value = "beta" + break + case 'm': + value = "milestone" + break + default: + break + } + } + if (ALIASES[value] != null) { + this.value = ALIASES[value] + } else { + this.value = value + } + } + + @Override + @NonCPS + int compareTo(Item item) { + if (item == null) { + // 1-rc < 1, 1-ga > 1 + return comparableQualifier(value).compareTo(RELEASE_VERSION_INDEX) + } + switch (item.getType()) { + case INTEGER_ITEM: + return -1; // 1.any < 1.1 ? + + case STRING_ITEM: + return comparableQualifier(value).compareTo(comparableQualifier(((StringItem) item).value)) + + case LIST_ITEM: + return -1; // 1.any < 1-1 + + default: + throw new RuntimeException("invalid item: " + item.getClass()) + } + } + + @Override + @NonCPS + int getType() { + return STRING_ITEM + } + + @Override + @NonCPS + boolean isNull() { + return (comparableQualifier(value).compareTo(RELEASE_VERSION_INDEX) == 0) + } + + /** + * Returns a comparable value for a qualifier. + * + * This method takes into account the ordering of known qualifiers then unknown qualifiers with lexical + * ordering. + * + * just returning an Integer with the index here is faster, but requires a lot of if/then/else to check for -1 + * or QUALIFIERS.size and then resort to lexical ordering. Most comparisons are decided by the first character, + * so this is still fast. If more characters are needed then it requires a lexical sort anyway. + * + * @param qualifier + * @return an equivalent value that can be used with lexical comparison + */ + @NonCPS + String comparableQualifier(String qualifier) { + int i = ListUtils.indexOf(_QUALIFIERS, qualifier) + return i == -1 ? (_QUALIFIERS.size() + "-" + qualifier) : "$i" + } + + @Override + @NonCPS + String toString() { + return value + } +} diff --git a/src/pipeline-library.gdsl b/src/pipeline-library.gdsl new file mode 100644 index 0000000..f30bfcb --- /dev/null +++ b/src/pipeline-library.gdsl @@ -0,0 +1,24 @@ +/** + * gdsl for pipeline steps that do not need a allocated node + */ +def pipelineCtx = context(scope: scriptScope()) +contributor(pipelineCtx) { + method(name: 'checkoutScm', type: 'void', params: [config: Map], doc: 'Check out from scm with provided configuration') + method(name: 'execManagedShellScript', type: String, params: [fileId: String, args: 'List'], doc: 'Executes the managed shell script and returns the stdout') + method(name: 'execManagedShellScript', type: String, params: [fileId: String, argsLine: String], doc: 'Executes the managed shell script and returns the stdout') + method(name: 'execMaven', type: 'void', params: [config: Map], doc: 'Executes maven with the given configuration') + method(name: 'execMavenRelease', type: 'void', params: [config: Map], doc: 'Performs a maven release with the given configuration') + method(name: 'execNpm', type: 'void', params: [config: Map], doc: 'Executed NPM with the given configuration') + method(name: 'getBuildParameters.groovy', type: String, doc: 'Returns the current build parameters') + method(name: 'getScmUrl', type: String, doc: 'Returns the current scm url') + method(name: 'getScmUrl', type: String, params: [config: Map], doc: 'Returns the current scm url') + method(name: 'notifyMail', type: 'void', params: [config: Map], doc: 'Sends mail notification with the given configuration') + method(name: 'setBuildName', type: 'void', doc: 'Sets the current build name to #BUILD_NUMBER GIT_BRANCH') + method(name: 'setGitBranch', type: 'void', doc: 'Detects the current git branch and sets the result into GIT_BRANCH environment variable') + method(name: 'setScmUrl', type: 'void', params: [config: Map], doc: 'Detects the current scm url and sets the result into SCM_URL environment variable') + method(name: 'setupTools', type: 'void', params: [config: Map], doc: 'Setup tools configured in the provided configuration') + method(name: 'sshAgentWrapper', type: 'void', params: [sshTarget: String, body: Closure], doc: 'Provides auto lookup for ssh credential and wraps body into an sshagent') + method(name: 'sshAgentWrapper', type: 'void', params: [sshTarget: String, credentialAware: 'io.wcm.tooling.jenkins.pipeline.credentials.CredentialAware', body: Closure], doc: 'Provides auto lookup for ssh credential and wraps body into an sshagent') + method(name: 'transferScp', type: 'void', params: [config: Map], doc: 'Transfers files via scp by using the provided configuration') +} + diff --git a/src/pipeline.gdsl b/src/pipeline.gdsl new file mode 100644 index 0000000..9d70889 --- /dev/null +++ b/src/pipeline.gdsl @@ -0,0 +1,137 @@ +//The global script scope +def ctx = context(scope: scriptScope()) +contributor(ctx) { + method(name: 'VersionNumber', type: 'Object', params: [versionNumberString: 'java.lang.String'], doc: 'Determine the correct version number') + method(name: 'VersionNumber', type: 'Object', namedParams: [parameter(name: 'versionNumberString', type: 'java.lang.String'), parameter(name: 'overrideBuildsAllTime', type: 'java.lang.String'), parameter(name: 'overrideBuildsThisMonth', type: 'java.lang.String'), parameter(name: 'overrideBuildsThisWeek', type: 'java.lang.String'), parameter(name: 'overrideBuildsThisYear', type: 'java.lang.String'), parameter(name: 'overrideBuildsToday', type: 'java.lang.String'), parameter(name: 'projectStartDate', type: 'java.lang.String'), parameter(name: 'skipFailedBuilds', type: 'boolean'), parameter(name: 'versionPrefix', type: 'java.lang.String'),], doc: 'Determine the correct version number') + method(name: 'ansiColor', type: 'Object', params: [colorMapName: java.lang.String, body: 'Closure'], doc: 'Color ANSI Console Output') + method(name: 'build', type: 'Object', params: [job: 'java.lang.String'], doc: 'Build a job') + method(name: 'build', type: 'Object', namedParams: [parameter(name: 'job', type: 'java.lang.String'), parameter(name: 'parameters', type: 'Map'), parameter(name: 'propagate', type: 'boolean'), parameter(name: 'quietPeriod', type: 'java.lang.Integer'), parameter(name: 'wait', type: 'boolean'),], doc: 'Build a job') + method(name: 'echo', type: 'Object', params: [message: 'java.lang.String'], doc: 'Print Message') + method(name: 'emailext', type: 'Object', namedParams: [parameter(name: 'subject', type: 'java.lang.String'), parameter(name: 'body', type: 'java.lang.String'), parameter(name: 'attachLog', type: 'boolean'), parameter(name: 'attachmentsPattern', type: 'java.lang.String'), parameter(name: 'compressLog', type: 'boolean'), parameter(name: 'from', type: 'java.lang.String'), parameter(name: 'mimeType', type: 'java.lang.String'), parameter(name: 'postsendScript', type: 'java.lang.String'), parameter(name: 'presendScript', type: 'java.lang.String'), parameter(name: 'recipientProviders', type: 'Map'), parameter(name: 'replyTo', type: 'java.lang.String'), parameter(name: 'to', type: 'java.lang.String'),], doc: 'Extended Email') + method(name: 'emailextrecipients', type: 'Object', params: [recipientProviders: 'Map'], doc: 'Extended Email Recipients') + method(name: 'error', type: 'Object', params: [message: 'java.lang.String'], doc: 'Error signal') + method(name: 'input', type: 'Object', params: [message: 'java.lang.String'], doc: 'Wait for interactive input') + method(name: 'input', type: 'Object', namedParams: [parameter(name: 'message', type: 'java.lang.String'), parameter(name: 'id', type: 'java.lang.String'), parameter(name: 'ok', type: 'java.lang.String'), parameter(name: 'parameters', type: 'Map'), parameter(name: 'submitter', type: 'java.lang.String'), parameter(name: 'submitterParameter', type: 'java.lang.String'),], doc: 'Wait for interactive input') + method(name: 'isUnix', type: 'Object', params: [:], doc: 'Checks if running on a Unix-like node') + method(name: 'jiraComment', type: 'Object', namedParams: [parameter(name: 'issueKey', type: 'java.lang.String'), parameter(name: 'body', type: 'java.lang.String'),], doc: 'JIRA: Add a comment to issue(s)') + method(name: 'jiraIssueSelector', type: 'Object', params: [:], doc: 'JIRA: Issue selector') + method(name: 'jiraIssueSelector', type: 'Object', namedParams: [parameter(name: 'issueSelector', type: 'Map'),], doc: 'JIRA: Issue selector') + method(name: 'jiraSearch', type: 'Object', params: [jql: 'java.lang.String'], doc: 'JIRA: Search issues') + method(name: 'library', type: 'Object', params: [identifier: 'java.lang.String'], doc: 'Load a shared library on the fly') + method(name: 'library', type: 'Object', namedParams: [parameter(name: 'identifier', type: 'java.lang.String'), parameter(name: 'retriever', type: 'Map'),], doc: 'Load a shared library on the fly') + method(name: 'libraryResource', type: 'Object', params: [resource: 'java.lang.String'], doc: 'Load a resource file from a shared library') + method(name: 'mail', type: 'Object', namedParams: [parameter(name: 'subject', type: 'java.lang.String'), parameter(name: 'body', type: 'java.lang.String'), parameter(name: 'bcc', type: 'java.lang.String'), parameter(name: 'cc', type: 'java.lang.String'), parameter(name: 'charset', type: 'java.lang.String'), parameter(name: 'from', type: 'java.lang.String'), parameter(name: 'mimeType', type: 'java.lang.String'), parameter(name: 'replyTo', type: 'java.lang.String'), parameter(name: 'to', type: 'java.lang.String'),], doc: 'Mail') + method(name: 'milestone', type: 'Object', params: [ordinal: 'java.lang.Integer'], doc: 'The milestone step forces all builds to go through in order') + method(name: 'milestone', type: 'Object', namedParams: [parameter(name: 'ordinal', type: 'java.lang.Integer'), parameter(name: 'label', type: 'java.lang.String'),], doc: 'The milestone step forces all builds to go through in order') + method(name: 'node', type: 'Object', params: [label: java.lang.String, body: 'Closure'], doc: 'Allocate node') + method(name: 'properties', type: 'Object', params: [properties: 'Map'], doc: 'Set job properties') + method(name: 'readTrusted', type: 'Object', params: [path: 'java.lang.String'], doc: 'Read trusted file from SCM') + method(name: 'resolveScm', type: 'Object', namedParams: [parameter(name: 'source', type: 'Map'), parameter(name: 'targets', type: 'Map'), parameter(name: 'ignoreErrors', type: 'boolean'),], doc: 'Resolves an SCM from an SCM Source and a list of candidate target branch names') + method(name: 'retry', type: 'Object', params: [count: int, body: 'Closure'], doc: 'Retry the body up to N times') + method(name: 'script', type: 'Object', params: [body: 'Closure'], doc: 'Run arbitrary Pipeline script') + method(name: 'sleep', type: 'Object', params: [time: 'int'], doc: 'Sleep') + method(name: 'sleep', type: 'Object', namedParams: [parameter(name: 'time', type: 'int'), parameter(name: 'unit', type: 'java.util.concurrent.TimeUnit'),], doc: 'Sleep') + method(name: 'stage', type: 'Object', params: [name: java.lang.String, body: 'Closure'], doc: 'Stage') + method(name: 'stage', type: 'Object', params: [body: Closure], namedParams: [parameter(name: 'name', type: 'java.lang.String'), parameter(name: 'concurrency', type: 'java.lang.Integer'),], doc: 'Stage') + method(name: 'task', type: 'Object', params: [name: 'java.lang.String'], doc: 'Task') + method(name: 'timeout', type: 'Object', params: [time: int, body: 'Closure'], doc: 'Enforce time limit') + method(name: 'timeout', type: 'Object', params: [body: Closure], namedParams: [parameter(name: 'time', type: 'int'), parameter(name: 'unit', type: 'java.util.concurrent.TimeUnit'),], doc: 'Enforce time limit') + method(name: 'timestamps', type: 'Object', params: [body: 'Closure'], doc: 'Timestamps') + method(name: 'tool', type: 'Object', params: [name: 'java.lang.String'], doc: 'Use a tool from a predefined Tool Installation') + method(name: 'tool', type: 'Object', namedParams: [parameter(name: 'name', type: 'java.lang.String'), parameter(name: 'type', type: 'java.lang.String'),], doc: 'Use a tool from a predefined Tool Installation') + method(name: 'waitForQualityGate', type: 'Object', params: [:], doc: 'Wait for SonarQube analysis to be completed and return quality gate status') + method(name: 'waitUntil', type: 'Object', params: [body: 'Closure'], doc: 'Wait for condition') + method(name: 'withCredentials', type: 'Object', params: [bindings: Map, body: 'Closure'], doc: 'Bind credentials to variables') + method(name: 'withEnv', type: 'Object', params: [overrides: Map, body: 'Closure'], doc: 'Set environment variables') + method(name: 'ws', type: 'Object', params: [dir: java.lang.String, body: 'Closure'], doc: 'Allocate workspace') + method(name: 'catchError', type: 'Object', params: [body: 'Closure'], doc: 'Advanced/Deprecated Catch error and set build result') + method(name: 'dockerFingerprintRun', type: 'Object', params: [containerId: 'java.lang.String'], doc: 'Advanced/Deprecated Record trace of a Docker image run in a container') + method(name: 'dockerFingerprintRun', type: 'Object', namedParams: [parameter(name: 'containerId', type: 'java.lang.String'), parameter(name: 'toolName', type: 'java.lang.String'),], doc: 'Record trace of a Docker image run in a container') + method(name: 'envVarsForTool', type: 'Object', namedParams: [parameter(name: 'toolId', type: 'java.lang.String'), parameter(name: 'toolVersion', type: 'java.lang.String'),], doc: 'Fetches the environment variables for a given tool in a list of \'FOO=bar\' strings suitable for the withEnv step.') + method(name: 'getContext', type: 'Object', params: [type: 'Map'], doc: 'Advanced/Deprecated Get contextual object from internal APIs') + method(name: 'withContext', type: 'Object', params: [context: java.lang.Object, body: 'Closure'], doc: 'Advanced/Deprecated Use contextual object from internal APIs within a block') + property(name: 'docker', type: 'org.jenkinsci.plugins.docker.workflow.DockerDSL') + property(name: 'pipeline', type: 'org.jenkinsci.plugins.pipeline.modeldefinition.ModelStepLoader') + property(name: 'env', type: 'org.jenkinsci.plugins.workflow.cps.EnvActionImpl.Binder') + property(name: 'params', type: 'java.util.Collections.UnmodifiableMap') + property(name: 'currentBuild', type: 'org.jenkinsci.plugins.workflow.cps.RunWrapperBinder') + property(name: 'scm', type: 'org.jenkinsci.plugins.workflow.multibranch.SCMVar') + property(name: 'manager', type: 'org.jvnet.hudson.plugins.groovypostbuild.WorkflowManager') + +} +//Steps that require a node context +def nodeCtx = context(scope: closureScope()) +contributor(nodeCtx) { + def call = enclosingCall('node') + if (call) { + method(name: 'ansiblePlaybook', type: 'Object', params: [playbook: 'java.lang.String'], doc: 'Invoke an ansible playbook') + method(name: 'ansiblePlaybook', type: 'Object', namedParams: [parameter(name: 'playbook', type: 'java.lang.String'), parameter(name: 'colorized', type: 'boolean'), parameter(name: 'credentialsId', type: 'java.lang.String'), parameter(name: 'dynamicInventory', type: 'boolean'), parameter(name: 'extraVars', type: 'java.util.Map'), parameter(name: 'extras', type: 'java.lang.String'), parameter(name: 'forks', type: 'int'), parameter(name: 'installation', type: 'java.lang.String'), parameter(name: 'inventory', type: 'java.lang.String'), parameter(name: 'inventoryContent', type: 'java.lang.String'), parameter(name: 'limit', type: 'java.lang.String'), parameter(name: 'skippedTags', type: 'java.lang.String'), parameter(name: 'startAtTask', type: 'java.lang.String'), parameter(name: 'sudo', type: 'boolean'), parameter(name: 'sudoUser', type: 'java.lang.String'), parameter(name: 'tags', type: 'java.lang.String'),], doc: 'Invoke an ansible playbook') + method(name: 'bat', type: 'Object', params: [script: 'java.lang.String'], doc: 'Windows Batch Script') + method(name: 'bat', type: 'Object', namedParams: [parameter(name: 'script', type: 'java.lang.String'), parameter(name: 'encoding', type: 'java.lang.String'), parameter(name: 'returnStatus', type: 'boolean'), parameter(name: 'returnStdout', type: 'boolean'),], doc: 'Windows Batch Script') + method(name: 'checkout', type: 'Object', params: [scm: 'Map'], doc: 'General SCM') + method(name: 'checkout', type: 'Object', namedParams: [parameter(name: 'scm', type: 'Map'), parameter(name: 'changelog', type: 'boolean'), parameter(name: 'poll', type: 'boolean'),], doc: 'General SCM') + method(name: 'deleteDir', type: 'Object', params: [:], doc: 'Recursively delete the current directory from the workspace') + method(name: 'dir', type: 'Object', params: [path: java.lang.String, body: 'Closure'], doc: 'Change current directory') + method(name: 'fileExists', type: 'Object', params: [file: 'java.lang.String'], doc: 'Verify if file exists in workspace') + method(name: 'findFiles', type: 'Object', params: [:], doc: 'Find files in the workspace') + method(name: 'findFiles', type: 'Object', namedParams: [parameter(name: 'glob', type: 'java.lang.String'),], doc: 'Find files in the workspace') + method(name: 'gatlingArchive', type: 'Object', params: [:], doc: 'Archive Gatling reports') + method(name: 'git', type: 'Object', params: [url: 'java.lang.String'], doc: 'Git') + method(name: 'git', type: 'Object', namedParams: [parameter(name: 'url', type: 'java.lang.String'), parameter(name: 'branch', type: 'java.lang.String'), parameter(name: 'changelog', type: 'boolean'), parameter(name: 'credentialsId', type: 'java.lang.String'), parameter(name: 'poll', type: 'boolean'),], doc: 'Git') + method(name: 'load', type: 'Object', params: [path: 'java.lang.String'], doc: 'Evaluate a Groovy source file into the Pipeline script') + method(name: 'powershell', type: 'Object', params: [script: 'java.lang.String'], doc: 'PowerShell Script') + method(name: 'powershell', type: 'Object', namedParams: [parameter(name: 'script', type: 'java.lang.String'), parameter(name: 'encoding', type: 'java.lang.String'), parameter(name: 'returnStatus', type: 'boolean'), parameter(name: 'returnStdout', type: 'boolean'),], doc: 'PowerShell Script') + method(name: 'publishHTML', type: 'Object', params: [target: 'Map'], doc: 'Publish HTML reports') + method(name: 'pwd', type: 'Object', params: [:], doc: 'Determine current directory') + method(name: 'pwd', type: 'Object', namedParams: [parameter(name: 'tmp', type: 'boolean'),], doc: 'Determine current directory') + method(name: 'readFile', type: 'Object', params: [file: 'java.lang.String'], doc: 'Read file from workspace') + method(name: 'readFile', type: 'Object', namedParams: [parameter(name: 'file', type: 'java.lang.String'), parameter(name: 'encoding', type: 'java.lang.String'),], doc: 'Read file from workspace') + method(name: 'readJSON', type: 'Object', params: [:], doc: 'Read JSON from files in the workspace.') + method(name: 'readJSON', type: 'Object', namedParams: [parameter(name: 'file', type: 'java.lang.String'), parameter(name: 'text', type: 'java.lang.String'),], doc: 'Read JSON from files in the workspace.') + method(name: 'readManifest', type: 'Object', params: [:], doc: 'Read a Jar Manifest') + method(name: 'readManifest', type: 'Object', namedParams: [parameter(name: 'file', type: 'java.lang.String'), parameter(name: 'text', type: 'java.lang.String'),], doc: 'Read a Jar Manifest') + method(name: 'readMavenPom', type: 'Object', params: [:], doc: 'Read a maven project file.') + method(name: 'readMavenPom', type: 'Object', namedParams: [parameter(name: 'file', type: 'java.lang.String'),], doc: 'Read a maven project file.') + method(name: 'readProperties', type: 'Object', params: [:], doc: 'Read properties from files in the workspace or text.') + method(name: 'readProperties', type: 'Object', namedParams: [parameter(name: 'defaults', type: 'Map'), parameter(name: 'file', type: 'java.lang.String'), parameter(name: 'text', type: 'java.lang.String'),], doc: 'Read properties from files in the workspace or text.') + method(name: 'readYaml', type: 'Object', params: [:], doc: 'Read yaml from files in the workspace or text.') + method(name: 'readYaml', type: 'Object', namedParams: [parameter(name: 'file', type: 'java.lang.String'), parameter(name: 'text', type: 'java.lang.String'),], doc: 'Read yaml from files in the workspace or text.') + method(name: 'sh', type: 'Object', params: [script: 'java.lang.String'], doc: 'Shell Script') + method(name: 'sh', type: 'Object', namedParams: [parameter(name: 'script', type: 'java.lang.String'), parameter(name: 'encoding', type: 'java.lang.String'), parameter(name: 'returnStatus', type: 'boolean'), parameter(name: 'returnStdout', type: 'boolean'),], doc: 'Shell Script') + method(name: 'sshagent', type: 'Object', params: [credentials: Map, body: 'Closure'], doc: 'SSH Agent') + method(name: 'sshagent', type: 'Object', params: [body: Closure], namedParams: [parameter(name: 'credentials', type: 'Map'), parameter(name: 'ignoreMissing', type: 'boolean'),], doc: 'SSH Agent') + method(name: 'stash', type: 'Object', params: [name: 'java.lang.String'], doc: 'Stash some files to be used later in the build') + method(name: 'stash', type: 'Object', namedParams: [parameter(name: 'name', type: 'java.lang.String'), parameter(name: 'allowEmpty', type: 'boolean'), parameter(name: 'excludes', type: 'java.lang.String'), parameter(name: 'includes', type: 'java.lang.String'), parameter(name: 'useDefaultExcludes', type: 'boolean'),], doc: 'Stash some files to be used later in the build') + method(name: 'step', type: 'Object', params: [delegate: 'Map'], doc: 'General Build Step') + method(name: 'svn', type: 'Object', params: [url: 'java.lang.String'], doc: 'Subversion') + method(name: 'svn', type: 'Object', namedParams: [parameter(name: 'url', type: 'java.lang.String'), parameter(name: 'changelog', type: 'boolean'), parameter(name: 'poll', type: 'boolean'),], doc: 'Subversion') + method(name: 'touch', type: 'Object', params: [file: 'java.lang.String'], doc: 'Create a file (if not already exist) in the workspace, and set the timestamp') + method(name: 'touch', type: 'Object', namedParams: [parameter(name: 'file', type: 'java.lang.String'), parameter(name: 'timestamp', type: 'java.lang.Long'),], doc: 'Create a file (if not already exist) in the workspace, and set the timestamp') + method(name: 'unstash', type: 'Object', params: [name: 'java.lang.String'], doc: 'Restore files previously stashed') + method(name: 'unzip', type: 'Object', params: [zipFile: 'java.lang.String'], doc: 'Extract Zip file') + method(name: 'unzip', type: 'Object', namedParams: [parameter(name: 'zipFile', type: 'java.lang.String'), parameter(name: 'charset', type: 'java.lang.String'), parameter(name: 'dir', type: 'java.lang.String'), parameter(name: 'glob', type: 'java.lang.String'), parameter(name: 'read', type: 'boolean'), parameter(name: 'test', type: 'boolean'),], doc: 'Extract Zip file') + method(name: 'validateDeclarativePipeline', type: 'Object', params: [path: 'java.lang.String'], doc: 'Validate a file containing a Declarative Pipeline') + method(name: 'withMaven', type: 'Object', params: [body: 'Closure'], doc: 'Provide Maven environment') + method(name: 'withMaven', type: 'Object', params: [body: Closure], namedParams: [parameter(name: 'globalMavenSettingsConfig', type: 'java.lang.String'), parameter(name: 'globalMavenSettingsFilePath', type: 'java.lang.String'), parameter(name: 'jdk', type: 'java.lang.String'), parameter(name: 'maven', type: 'java.lang.String'), parameter(name: 'mavenLocalRepo', type: 'java.lang.String'), parameter(name: 'mavenOpts', type: 'java.lang.String'), parameter(name: 'mavenSettingsConfig', type: 'java.lang.String'), parameter(name: 'mavenSettingsFilePath', type: 'java.lang.String'), parameter(name: 'options', type: 'Map'),], doc: 'Provide Maven environment') + method(name: 'wrap', type: 'Object', params: [delegate: Map, body: 'Closure'], doc: 'General Build Wrapper') + method(name: 'writeFile', type: 'Object', namedParams: [parameter(name: 'file', type: 'java.lang.String'), parameter(name: 'text', type: 'java.lang.String'), parameter(name: 'encoding', type: 'java.lang.String'),], doc: 'Write file to workspace') + method(name: 'writeJSON', type: 'Object', namedParams: [parameter(name: 'file', type: 'java.lang.String'), parameter(name: 'json', type: 'Map'),], doc: 'Write JSON to a file in the workspace.') + method(name: 'writeMavenPom', type: 'Object', params: [model: 'Map'], doc: 'Write a maven project file.') + method(name: 'writeMavenPom', type: 'Object', namedParams: [parameter(name: 'model', type: 'Map'), parameter(name: 'file', type: 'java.lang.String'),], doc: 'Write a maven project file.') + method(name: 'zip', type: 'Object', params: [zipFile: 'java.lang.String'], doc: 'Create Zip file') + method(name: 'zip', type: 'Object', namedParams: [parameter(name: 'zipFile', type: 'java.lang.String'), parameter(name: 'archive', type: 'boolean'), parameter(name: 'dir', type: 'java.lang.String'), parameter(name: 'glob', type: 'java.lang.String'),], doc: 'Create Zip file') + method(name: 'archive', type: 'Object', params: [includes: 'java.lang.String'], doc: 'Advanced/Deprecated Archive artifacts') + method(name: 'archive', type: 'Object', namedParams: [parameter(name: 'includes', type: 'java.lang.String'), parameter(name: 'excludes', type: 'java.lang.String'),], doc: 'Archive artifacts') + method(name: 'dockerFingerprintFrom', type: 'Object', namedParams: [parameter(name: 'dockerfile', type: 'java.lang.String'), parameter(name: 'image', type: 'java.lang.String'), parameter(name: 'toolName', type: 'java.lang.String'),], doc: 'Record trace of a Docker image used in FROM') + method(name: 'unarchive', type: 'Object', params: [:], doc: 'Advanced/Deprecated Copy archived artifacts into the workspace') + method(name: 'unarchive', type: 'Object', namedParams: [parameter(name: 'mapping', type: 'Map'),], doc: 'Copy archived artifacts into the workspace') + method(name: 'withDockerContainer', type: 'Object', params: [image: java.lang.String, body: 'Closure'], doc: 'Advanced/Deprecated Run build steps inside a Docker container') + method(name: 'withDockerContainer', type: 'Object', params: [body: Closure], namedParams: [parameter(name: 'image', type: 'java.lang.String'), parameter(name: 'args', type: 'java.lang.String'), parameter(name: 'toolName', type: 'java.lang.String'),], doc: 'Run build steps inside a Docker container') + method(name: 'withDockerRegistry', type: 'Object', params: [registry: Map, body: 'Closure'], doc: 'Advanced/Deprecated Sets up Docker registry endpoint') + method(name: 'withDockerServer', type: 'Object', params: [server: Map, body: 'Closure'], doc: 'Advanced/Deprecated Sets up Docker server endpoint') + } +} + +// Errors on: +// class org.jenkinsci.plugins.workflow.cps.steps.ParallelStep: There's no @DataBoundConstructor on any constructor of class org.jenkinsci.plugins.workflow.cps.steps.ParallelStep + diff --git a/test/io/wcm/testing/jenkins/pipeline/CpsScriptMock.groovy b/test/io/wcm/testing/jenkins/pipeline/CpsScriptMock.groovy new file mode 100644 index 0000000..61f0ce3 --- /dev/null +++ b/test/io/wcm/testing/jenkins/pipeline/CpsScriptMock.groovy @@ -0,0 +1,86 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.testing.jenkins.pipeline + +import org.jenkinsci.plugins.workflow.cps.CpsScript + +class CpsScriptMock extends CpsScript { + + /** + * DSL Mock + */ + private DSLMock dslMock + + /** + * Environment variables + */ + private EnvActionImplMock envVars + + CpsScriptMock() { + super() + dslMock = new DSLMock() + envVars = new EnvActionImplMock() + binding.setVariable(CpsScript.STEPS_VAR, dslMock.getMock()) + binding.setVariable('env', envVars) + } + + @Override + Object run() { + return null + } + + /** + * Getter function for logMessages object + * + * @return the recorded logMessages + */ + List getLogMessages() { + return dslMock.getLogMessages() + } + + /** + * Returns the value of an environment variable + * + * @param var The name of the environment variable to return + * @return The value of the environment variable + */ + public getEnv(String var) { + return this.envVars.getProperty(var) + } + + /** + * Sets an environment variable + * + * @param var The name of the environment variable + * @param value The value of the environment variable + */ + public setEnv(String var, String value) { + this.envVars.setProperty(var, value) + } + + /** + * Getter function for DSL mock + * + * @return dslMock + */ + DSLMock getDslMock() { + return dslMock + } +} diff --git a/test/io/wcm/testing/jenkins/pipeline/CpsScriptTestBase.groovy b/test/io/wcm/testing/jenkins/pipeline/CpsScriptTestBase.groovy new file mode 100644 index 0000000..232bc52 --- /dev/null +++ b/test/io/wcm/testing/jenkins/pipeline/CpsScriptTestBase.groovy @@ -0,0 +1,43 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.testing.jenkins.pipeline + +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import org.junit.Before + +/** + * Base for CpsScript related tests + */ +class CpsScriptTestBase { + + /** + * Mock for CpsScript + */ + CpsScriptMock script + + @Before + void setUp() throws Exception { + this.script = new CpsScriptMock() + // reset logger initialization + Logger.initialized = false + // initialize logger + Logger.init(script) + } +} diff --git a/test/io/wcm/testing/jenkins/pipeline/DSLMock.groovy b/test/io/wcm/testing/jenkins/pipeline/DSLMock.groovy new file mode 100644 index 0000000..482576c --- /dev/null +++ b/test/io/wcm/testing/jenkins/pipeline/DSLMock.groovy @@ -0,0 +1,316 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.testing.jenkins.pipeline + +import com.lesfurets.jenkins.unit.PipelineTestHelper +import com.lesfurets.jenkins.unit.global.lib.LibraryConfiguration +import hudson.AbortException +import hudson.FilePath +import net.sf.json.JSON +import net.sf.json.JSONSerializer +import org.apache.commons.io.IOUtils +import org.apache.commons.lang.NotImplementedException +import org.jenkinsci.plugins.pipeline.utility.steps.conf.ReadYamlStep +import org.jenkinsci.plugins.pipeline.utility.steps.json.ReadJSONStep +import org.jenkinsci.plugins.pipeline.utility.steps.shaded.org.yaml.snakeyaml.Yaml +import org.jenkinsci.plugins.pipeline.utility.steps.shaded.org.yaml.snakeyaml.constructor.SafeConstructor +import org.jenkinsci.plugins.pipeline.utility.steps.shaded.org.yaml.snakeyaml.reader.UnicodeReader +import org.jenkinsci.plugins.workflow.cps.DSL +import org.mockito.invocation.InvocationOnMock +import org.mockito.stubbing.Answer + +import static org.apache.commons.lang.StringUtils.isBlank +import static org.apache.commons.lang.StringUtils.isNotBlank +import static org.mockito.ArgumentMatchers.any +import static org.mockito.ArgumentMatchers.eq +import static org.mockito.Mockito.mock +import static org.mockito.Mockito.when + +/** + * Mock for the Jenkins Pipeline DSL Object + * Used during unit-testing of the pipeline library to provide the surrounding pipeline environment + */ +class DSLMock { + + /** + * Storage for all executed 'echo' step arguments + */ + protected List logMessages + + /** + * The mocked DSL object + */ + protected DSL mock + + /** + * Map for providing mocked resources + */ + protected Map mockedResources + + /** + * Reference to the PipelineTestHelper from the JenkinsPipelineUnit unit + * @see JenkinsPipelineUnit + */ + protected PipelineTestHelper helper + + DSLMock() { + // cpsScriptMock the DSL object + this.mock = mock(DSL.class) + + // initialize the log messages + logMessages = [] + + // initialize the mocked resources + mockedResources = new TreeMap() + + // cpsScriptMock libraryResource + when(mock.invokeMethod(eq("libraryResource"), any())).then(new Answer() { + @Override + String answer(InvocationOnMock invocationOnMock) throws Throwable { + Object[] args = invocationOnMock.getArguments() + String resourcePath = args[1][0].toString() + // search in all SourceRetrievers for the the resource with the given path + File foundResource = locateTestResource(resourcePath) + return foundResource.getText("UTF-8") + } + }) + + // cpsScriptMock the 'error' step + when(mock.invokeMethod(eq("error"), any())).then(new Answer() { + @Override + String answer(InvocationOnMock invocationOnMock) throws Throwable { + throw new AbortException((String) invocationOnMock.getArguments()[1][0]) + } + }) + + // cpsScriptMock readJSON method from pipeline utility steps plugin, see: https://github.com/jenkinsci/pipeline-utility-steps-plugin + // TODO: use real implementation of plugin here + when(mock.invokeMethod(eq("readJSON"), any())).then(new Answer() { + @Override + JSON answer(InvocationOnMock invocationOnMock) throws Throwable { + Object[] args = invocationOnMock.getArguments() + def functionArgs = args[1][0] + + return readJSON(functionArgs.file, functionArgs.text) + } + }) + + when(mock.invokeMethod(eq("readYaml"), any())).then(new Answer() { + @Override + List answer(InvocationOnMock invocationOnMock) throws Throwable { + Object[] args = invocationOnMock.getArguments() + def functionArgs = args[1][0] + + return readYaml(functionArgs.file, functionArgs.text) + } + }) + + // cpsScriptMock the 'echo' step and store the arguments in the logMessages object + when(mock.invokeMethod(eq("echo"), any())).then(new Answer() { + @Override + Void answer(InvocationOnMock invocationOnMock) throws Throwable { + Object[] args = invocationOnMock.getArguments() + String logStatement = args[1][0].toString() + + logMessages.push(logStatement) + return null + } + }) + } + + JSON readJSON(String file = null, String text = null) { + ReadJSONStep step = new ReadJSONStep() + step.setFile((String) file) + step.setText((String) text) + + if (isNotBlank(step.getFile()) && isNotBlank(step.getText())) { + throw new IllegalArgumentException("At most one of file or text must be provided to readJSON.") + } + if (isBlank(step.getFile()) && isBlank(step.getText())) { + throw new IllegalArgumentException("At least one of file or text needs to be provided to readJSON.") + } + + JSON json = null + if (!isBlank(step.getFile())) { + // TODO: Implement readJSON from file + throw new NotImplementedException("readJSON from file is currently not implemented") + } + if (!isBlank(step.getText())) { + json = JSONSerializer.toJSON(step.getText().trim()) + } + + + return json + } + + List readYaml(String file = null, String text = null) { + String yamlText = "" + ReadYamlStep step = new ReadYamlStep() + step.setFile((String) file) + step.setText((String) text) + + if (isNotBlank(step.getFile()) && isNotBlank(step.getText())) { + throw new IllegalArgumentException("At most one of file or text must be provided to readJSON.") + } + if (isBlank(step.getFile()) && isBlank(step.getText())) { + throw new IllegalArgumentException("At least one of file or text needs to be provided to readJSON.") + } + + //JSON json = null + if (!isBlank(step.getFile())) { + File ymlFile = locateTestResource(step.getFile()) + FilePath path = new FilePath(ymlFile) + Reader reader = new UnicodeReader(path.read()) + yamlText = IOUtils.toString(reader) + } + if (!isBlank(step.getText())) { + yamlText += System.getProperty("line.separator") + step.getText(); + } + + // Use SafeConstructor to limit objects to standard Java objects like List or Long + Iterable yaml = new Yaml(new SafeConstructor()).loadAll(yamlText) + + List result = new LinkedList() + for (Object data : yaml) { + result.add(data) + } + + // if only one YAML document, return it directly + if (result.size() == 1) { + return result.get(0) + } + + return result + } + + /** + * Searches in all available library sources and in the current workspace for a resource with the given resourcePath + * This function is used to locate test resources below ./test/resources or in registered Sources of the JenkinsPipelineUnit framework + * + * @param resourcePath The path of the resource to locate + * @return Map of found resources with key = LibraryName, value = path to the found resource + */ + Map locateTestResources(String resourcePath) { + Map foundResources = new HashMap<>() + // try to load resource from registered libraries + if (helper) { + helper.libraries.each { + String libraryName, LibraryConfiguration libraryConfig -> + List librarySources = libraryConfig.getRetriever().retrieve(libraryConfig.name, libraryConfig.defaultVersion, libraryConfig.targetPath) + for (URL librarySource in librarySources) { + File libraryResource = new File(librarySource.toURI()).toPath().resolve("test/resources/$resourcePath").toFile() + if (libraryResource.exists()) { + foundResources.put(libraryName, libraryResource) + } + } + } + } + + // try to lookup using mocked resources + String mockedResourcePath = this.mockedResources.get(resourcePath) + if (mockedResourcePath) { + resourcePath = mockedResourcePath + } + + // lookup in local path when helper not present or no resource was found + if (foundResources.size() == 0 && resourcePath != null) { + File libraryResource = new File("test/resources/".concat(resourcePath)) + if (libraryResource.exists()) { + foundResources.put("test-resource", libraryResource) + } + } + + return foundResources + } + + /** + * Utility function to locate a test resource with a given path. + * This function emulates the AbortException when a resourcePath was found in more than one library which would + * result in an Ambigious error during running in Jenkins environment since the Library loaded does not know which + * of the resources is correct. + * + * @param resourcePath The path of the resource to locate + * @return The found file + * @throws AbortException Thrown when the resource was not found or is Ambigious (found more than one time) + */ + File locateTestResource(String resourcePath) throws AbortException { + Map foundResources = locateTestResources(resourcePath) + if (foundResources.size() == 1) { + File fileResource = foundResources.entrySet().iterator().next().value + if (fileResource.exists()) { + return fileResource + } else { + // try to lookup from libraries + System.out.println("Test resource does not exist " + resourcePath) + throw new AbortException(String.format("No such library resource '%s' could be found.", resourcePath)) + } + } else if (foundResources.size() > 1) { + // check if resource is bijective + throw new AbortException("Ambigious resouce $resourcePath. Found resource in these libraries: " + foundResources.toString()) + } else { + System.out.println("Test resource does not exist " + resourcePath) + throw new AbortException(String.format("No such library resource '%s' could be found.", resourcePath)) + } + } + + /** + * Sets the PipelineTestHelper + * @param helper + */ + void setHelper(PipelineTestHelper helper) { + this.helper = helper + } + + /** + * Prints the recorded log messages to system out + */ + void printLogMessages() { + logMessages.each { msg -> + System.out.println("MSG: " + msg) + } + } + + /** + * Getter function for logMessages object + * + * @return the recorded logMessages + */ + List getLogMessages() { + return logMessages + } + + /** + * + * @param resourceName The path of the resource to be mocked + * @param resourcePath The path of the mocked resource + */ + void mockResource(String resourcePath, String mockedPath) { + this.mockedResources.put(resourcePath, mockedPath) + } + + /** + * Getter function for the mocked DSL object + * + * @return The mocked DSL Object + */ + DSL getMock() { + return mock + } +} diff --git a/test/io/wcm/testing/jenkins/pipeline/DSLTestBase.groovy b/test/io/wcm/testing/jenkins/pipeline/DSLTestBase.groovy new file mode 100644 index 0000000..ce3e6df --- /dev/null +++ b/test/io/wcm/testing/jenkins/pipeline/DSLTestBase.groovy @@ -0,0 +1,40 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.testing.jenkins.pipeline + +import io.wcm.tooling.jenkins.pipeline.utils.logging.LogLevel +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import org.junit.Before + +/** + * Utility base class for all test classes that need a mocked DSL object + */ +class DSLTestBase { + + DSLMock dslMock + + @Before + void setUp() throws Exception { + this.dslMock = new DSLMock() + Logger.initialized = false + // init logger + Logger.init(this.dslMock.getMock(), LogLevel.ALL) + } +} diff --git a/test/io/wcm/testing/jenkins/pipeline/EnvActionImplMock.groovy b/test/io/wcm/testing/jenkins/pipeline/EnvActionImplMock.groovy new file mode 100644 index 0000000..189dee2 --- /dev/null +++ b/test/io/wcm/testing/jenkins/pipeline/EnvActionImplMock.groovy @@ -0,0 +1,51 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.testing.jenkins.pipeline + +/** + * Mock for EnvActionImpl to support setProperty and getProperty on env Var + */ +class EnvActionImplMock extends GroovyObjectSupport { + + protected Map env + + EnvActionImplMock() { + env = new TreeMap() + } + + Map getEnvironment() throws IOException, InterruptedException { + return env + } + + @Override + String getProperty(String propertyName) { + return env.getOrDefault(propertyName, null) + } + + @Override + void setProperty(String propertyName, Object newValue) { + if (newValue != null) { + env.put(propertyName, String.valueOf(newValue)) + } else { + env.remove(propertyName) + } + } + +} diff --git a/test/io/wcm/testing/jenkins/pipeline/LibraryIntegrationTestBase.groovy b/test/io/wcm/testing/jenkins/pipeline/LibraryIntegrationTestBase.groovy new file mode 100644 index 0000000..e4d10c9 --- /dev/null +++ b/test/io/wcm/testing/jenkins/pipeline/LibraryIntegrationTestBase.groovy @@ -0,0 +1,538 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.testing.jenkins.pipeline + +import com.lesfurets.jenkins.unit.BasePipelineTest +import hudson.AbortException +import hudson.model.Run +import io.wcm.testing.jenkins.pipeline.global.lib.SelfSourceRetriever +import io.wcm.testing.jenkins.pipeline.recorder.StepRecorder +import io.wcm.testing.jenkins.pipeline.recorder.StepRecorderAssert +import org.apache.maven.model.Model +import org.apache.maven.model.io.xpp3.MavenXpp3Reader +import org.jenkinsci.plugins.configfiles.buildwrapper.ManagedFile +import org.junit.Assert +import org.junit.Before +import org.jvnet.hudson.tools.versionnumber.VersionNumberBuildInfo +import org.jvnet.hudson.tools.versionnumber.VersionNumberCommon +import org.jvnet.hudson.tools.versionnumber.VersionNumberStep + +import java.util.regex.Pattern + +import static com.lesfurets.jenkins.unit.global.lib.LibraryConfiguration.library +import static io.wcm.testing.jenkins.pipeline.StepConstants.* +import static org.mockito.Mockito.mock + +/** + * Base class for integration tests that use the JenkinsPipelineUnit testing framework + * + * @see JenkinsPipelineUnit + */ +class LibraryIntegrationTestBase extends BasePipelineTest { + + public final static String WORKSPACE_PATH = "/path/to/workspace" + public final static String WORKSPACE_TMP_PATH = WORKSPACE_PATH.concat("@tmp/") + public final static String TOOL_JDK_PREFIX = "/some/tool/path/jdk/" + public final static String TOOL_MAVEN_PREFIX = "/some/tool/path/maven/" + + public final static String TOOL_JDK = "sun-java8-jdk" + public final static String TOOL_MAVEN = "apache-maven3" + + /** + * Mock for the pipeline DSL object + */ + protected DSLMock dslMock + + /** + * Mock for the RunWrapper which provides whitelisted access to the currentBuild + */ + protected RunWrapperMock runWrapper + + /** + * Environment variables + */ + protected EnvActionImplMock envVars + + /** + * Current build parameters + */ + protected Map params + + /** + * Path to the log file + */ + protected File logFile = null + + /** + * Utility for recording executed steps + */ + protected StepRecorder stepRecorder + + @Override + @Before + void setUp() throws Exception { + // add the test folder to the script roots for the BasePipelineTest and call the super function + scriptRoots += 'test' + + envVars = new EnvActionImplMock() + envVars.setProperty("PATH", "/usr/bin") + super.setUp() + + // initialize the RunWrapper Mock + runWrapper = new RunWrapperMock(mock(Run)) + + // initialize the DSL Mock + this.dslMock = new DSLMock() + + // give the dslMock the refernce to the pipeline helper to allow access to registered libraries + dslMock.setHelper(helper) + + // initialize the step recorder + stepRecorder = new StepRecorder() + StepRecorderAssert.init(stepRecorder) + + // set binding for steps and assign it the the DSL cpsScriptMock + binding.setVariable("steps", this.dslMock.getMock()) + + // set the environment variables + binding.setVariable('env', envVars) + + // set build parameters + params = [:] + binding.setVariable('params', params) + + // set the enviornment variables + binding.setVariable('params', params) + + // set the currentBuild to the RunWrapper cpsScriptMock + this.binding.setVariable("currentBuild", runWrapper) + + // add callbacks for DSL functions and pass them to the step recorder if necessary + helper.registerAllowedMethod("getName", [], canonicalNameCallback) + helper.registerAllowedMethod("getCanonicalName", [], canonicalNameCallback) + helper.registerAllowedMethod(ANSI_COLOR, [String.class, Closure.class], ansiColorCallback) + helper.registerAllowedMethod(ANSIBLE_PLAYBOOK, [Map.class], { Map incomingCall -> stepRecorder.record(ANSIBLE_PLAYBOOK, incomingCall) }) + + helper.registerAllowedMethod(BOOLEAN_PARAM, [Map.class], booleanParamCallback) + helper.registerAllowedMethod(BUILD_DISCARDER, [Object.class], { Map incomingCall -> stepRecorder.record(BUILD_DISCARDER, incomingCall) }) + + helper.registerAllowedMethod(CHOICE, [Map.class], choiceCallback) + helper.registerAllowedMethod(CHECKOUT, [Map.class], { LinkedHashMap incomingCall -> stepRecorder.record(CHECKOUT, incomingCall) }) + helper.registerAllowedMethod(CHECKSTYLE, [LinkedHashMap.class], { LinkedHashMap map -> stepRecorder.record(CHECKSTYLE, map) }) + helper.registerAllowedMethod(CONFIGFILE, [Map.class], configFileCallback) + helper.registerAllowedMethod(CONFIGFILEPROVIDER, [List.class, Closure.class], configFileProviderCallback) + helper.registerAllowedMethod(CRON, [String.class], cronCallback) + + helper.registerAllowedMethod(DISABLE_CONCURRENT_BUILDS, [], { + stepRecorder.record(DISABLE_CONCURRENT_BUILDS, null) + }) + + helper.registerAllowedMethod(FILE_EXISTS, [String.class], fileExistsCallback) + + helper.registerAllowedMethod(EMAILEXT, [Map.class], { Map incomingCall -> stepRecorder.record(EMAILEXT, incomingCall) }) + helper.registerAllowedMethod(ERROR, [String.class], { String incomingCall -> + stepRecorder.record(ERROR, incomingCall) + throw new AbortException(incomingCall) + }) + helper.registerAllowedMethod(FINDBUGS, [LinkedHashMap.class], { LinkedHashMap map -> stepRecorder.record(FINDBUGS, map) }) + + helper.registerAllowedMethod(JUNIT, [String.class], { String incomingCall -> stepRecorder.record(JUNIT, incomingCall) }) + helper.registerAllowedMethod(JUNIT, [Map.class], { Map incomingCall -> stepRecorder.record(JUNIT, incomingCall) }) + + helper.registerAllowedMethod(LOG_ROTATOR, [Map.class], { + Map incomingCall -> + stepRecorder.record(LOG_ROTATOR, incomingCall) + return [(LOG_ROTATOR): incomingCall] + }) + helper.registerAllowedMethod(OPENTASKS, [LinkedHashMap.class], { LinkedHashMap map -> stepRecorder.record(OPENTASKS, map) }) + + helper.registerAllowedMethod(PARAMETERS, [List.class], { List incomingCall -> stepRecorder.record(PARAMETERS, incomingCall) }) + helper.registerAllowedMethod(PIPELINE_TRIGGERS, [List.class], { List incomingCall -> stepRecorder.record(PIPELINE_TRIGGERS, incomingCall) }) + helper.registerAllowedMethod(PMD, [LinkedHashMap.class], { LinkedHashMap map -> stepRecorder.record(PMD, map) }) + helper.registerAllowedMethod(POLLSCM, [String.class], pollSCMCallback) + + helper.registerAllowedMethod(READ_JSON, [Map.class], readJSONCallback) + helper.registerAllowedMethod(READ_MAVEN_POM, [Map.class], readMavenPomCallback) + helper.registerAllowedMethod(READ_YAML, [Map.class], readYamlCallback) + + helper.registerAllowedMethod(SH, [String.class], { String incomingCommand -> stepRecorder.record(SH, incomingCommand) }) + helper.registerAllowedMethod(SH, [Map.class], shellMapCallback) + helper.registerAllowedMethod(SLEEP, [LinkedHashMap.class], { values -> }) + helper.registerAllowedMethod(SSH_AGENT, [List.class, Closure.class], sshAgentCallback) + helper.registerAllowedMethod(STAGE, [String.class, Closure.class], stageCallback) + helper.registerAllowedMethod(STASH, [Map.class], { Map incomingCall -> stepRecorder.record(STASH, incomingCall) }) + helper.registerAllowedMethod(STEP, [Map.class], { LinkedHashMap incomingCall -> stepRecorder.record(STEP, incomingCall) }) + helper.registerAllowedMethod(STRING, [Map.class], stringCallback) + + helper.registerAllowedMethod(TEXT, [Map.class], textCallback) + helper.registerAllowedMethod(TIMEOUT, [Map.class, Closure.class], timeoutCallback) + helper.registerAllowedMethod(TIMESTAMPS, [Closure.class], { Closure closure -> + stepRecorder.record(TIMESTAMPS, true) + closure.call() + }) + helper.registerAllowedMethod(TOOL, [String.class], toolCallback) + + helper.registerAllowedMethod(UNSTASH, [Map.class], { Map incomingCall -> stepRecorder.record(UNSTASH, incomingCall) }) + helper.registerAllowedMethod(UPSTREAM, [Map.class], upstreamCallback) + + helper.registerAllowedMethod(VERSIONNUMBER, [LinkedHashMap.class], versionNumberMock) + + // register the current workspace as library + def projectPath = new File("").getAbsolutePath() + def library = library().name('local-library') + .defaultVersion("master") + .allowOverride(false) + .implicit(true) + .targetPath(projectPath) + .retriever(SelfSourceRetriever.localSourceRetriever(projectPath)) + .build() + helper.registerSharedLibrary(library) + } + + def timeoutCallback = { + Map params, Closure body -> + stepRecorder.record(TIMEOUT, params) + body.run() + } + + def stageCallback = { + String name, Closure body -> + stepRecorder.record(STAGE, name) + body.run() + } + + def booleanParamCallback = { + Map config -> + stepRecorder.record(BOOLEAN_PARAM, config) + return "booleanParam($config)" + } + + def choiceCallback = { + Map config -> + stepRecorder.record(CHOICE, config) + return "choice($config)" + } + + def stringCallback = { + Map config -> + stepRecorder.record(STRING, config) + return "string($config)" + } + + def textCallback = { + Map config -> + stepRecorder.record(TEXT, config) + return "text($config)" + } + + /** + * Callback for pollscm pipeline trigger + */ + def pollSCMCallback = { + String config -> + stepRecorder.record(POLLSCM, config) + return "pollSCM($config)" + } + + /** + * Callback for cron pipeline trigger + */ + def cronCallback = { + String config -> + stepRecorder.record(CRON, config) + return "cron($config)" + } + + /** + * Callback for upstream pipeline trigger + */ + def upstreamCallback = { + Map config -> + stepRecorder.record(UPSTREAM, config) + return "upstream($config)" + } + + /** + * Mocks the 'fileExists' step + * + * @return true when file exists, false when file does not exist + */ + def fileExistsCallback = { + String path -> + try { + File file = this.dslMock.locateTestResource(path) + return file.exists() + } catch (AbortException ex) { + return false + } + } + + /** + * Mocks the 'readYaml' step + * + * @see Pipeline Utility Steps Plugin + * + * return The Maven model + */ + def readYamlCallback = { + Map incomingCommand -> + String file = incomingCommand.file + String text = incomingCommand.text + return dslMock.readYaml(file, text) + } + + /** + * Mocks the 'readJSON' step + * + * @see Pipeline Utility Steps Plugin + * + * return The file/text as json + */ + def readJSONCallback = { + Map incomingCommand -> + String file = incomingCommand.file + String text = incomingCommand.text + return dslMock.readJSON(file, text) + } + + /** + * Mocks the 'readMavenPom' step + * Emulates the readMavenPom step of the Pipeline Utility Steps Plugin + * + * @see Pipeline Utility Steps Plugin + * + * return The Maven model + */ + def readMavenPomCallback = { + Map incomingCommand -> + String path = incomingCommand.file + File file = this.dslMock.locateTestResource(path) + InputStream inputStream = new FileInputStream(file) + Model ret = new MavenXpp3Reader().read(inputStream) + inputStream.close() + return ret + } + + /** + * Mocks the 'sh' step when executed with named arguments (Map) + * Used to cpsScriptMock some shell commands executed during integration testing + * + * @return A dummy response depending on the incoming command + */ + def shellMapCallback = { Map incomingCommand -> + stepRecorder.record(SH, incomingCommand) + Boolean returnStdout = incomingCommand.returnStdout ?: false + String script = incomingCommand.script ?: "" + // return default values for several commands + if (returnStdout) { + switch (script) { + case "git config remote.origin.url": return "http://remote.origin.url/group/project.git" + break + case "git rev-parse HEAD": return "0HFGC0" + break + case "git branch": return "* (detached from 0HFGC0)" + break + default: return "" + } + } + } + + /** + * Callback to allow getting the canonical name of the calling function when used inside classes + * by using the Stacktrace + * // TODO find better solution + * + * @return the Canoncial name of the object + */ + def canonicalNameCallback = { closure -> + Throwable t = new Throwable() + Pattern pattern = Pattern.compile('^[a-z]+[^.]*$') + String foundClassName = "" + t.getStackTrace().each { StackTraceElement item -> + if (item.getClassName().matches(pattern)) { + foundClassName = item.getClassName() + return foundClassName + } + } + return foundClassName + } + + /** + * Mocks the 'configFile' step + * + * @return a new ManagedFile object with the arguments provided in the Map + */ + def configFileCallback = { Map map -> + stepRecorder.record(CONFIGFILE, map) + return new ManagedFile((String) map.fileId, (String) map.targetLocation, (String) map.variable) + } + + /** + * Mocks the 'sshagent' step + */ + def sshAgentCallback = { List list, Closure closure -> + stepRecorder.record(SSH_AGENT, list) + closure.run() + } + + /** + * Mocks the 'versionNumber' step + * + * @return The formatted versionNumber number + */ + def versionNumberMock = { Map map -> + stepRecorder.record(VERSIONNUMBER, map) + String projectStartDate = map.projectStartDate ?: "1970-01-01" + String versionNumberString = map.versionNumberString ?: "" + VersionNumberStep versionNumberStep = new VersionNumberStep(versionNumberString) + versionNumberStep.projectStartDate = projectStartDate + VersionNumberBuildInfo versionNumberBuildInfo = new VersionNumberBuildInfo(0, 0, 0, 0, 0) + Calendar timeStamp = Calendar.getInstance() + String result = VersionNumberCommon.formatVersionNumber(versionNumberString, versionNumberStep.getProjectStartDate(), versionNumberBuildInfo, this.envVars.getEnvironment(), timeStamp) + return result + } + + /** + * Returns the value of an environment variable + * + * @param var The name of the environment variable to return + * @return The value of the environment variable + */ + protected getEnv(String var) { + return this.envVars.getProperty(var) + } + + /** + * Sets an environment variable + * + * @param var The name of the environment variable + * @param value The value of the environment variable + */ + protected setEnv(String var, String value) { + this.envVars.setProperty(var, value) + } + + /** + * Utility function to load and execute a script (e.g. test pipeline) + * The function calls the utility function 'beforeLoadingScript' before loading the script via the JenkinsPipelineUnit + * framework. To enable the test to redirect executed steps defined in the implicitely loaded library the function + * 'afterLoadingScript' is executed afterwards + * + * @param scriptPath The Path to the test job + * @return The return value of the executed script + */ + protected loadAndExecuteScript(String scriptPath) { + def ret + try { + // call helper function to enable tests to execute code before loading the script + beforeLoadingScript() + def script = loadScript(scriptPath) + // call helper function to enable tests to redirect pipeline steps into own callbacks + afterLoadingScript() + ret = script.execute() + } catch (e) { + e.printStackTrace() + dslMock.printLogMessages() + throw e + } + return ret + } + + /** + * Utility function to enable tests to put code in front of the loading of the script + */ + protected void beforeLoadingScript() {} + + /** + * Utility function to enable tests to execute code after loading but before executing the script + * This is especially helpful in cases were you want to redirect/cpsScriptMock steps defined in the library to own callbacks to + * analyze call parameters. + */ + protected void afterLoadingScript() {} + + /** + * Mocks the 'configFileProvider' step. For each ManagedFile the environment variable is set to a dummy filepath + */ + def configFileProviderCallback = { List configFiles, Closure closure -> + stepRecorder.record(CONFIGFILEPROVIDER, configFiles) + configFiles.each { ManagedFile file -> + String filePath = file.getTargetLocation() + if (filePath == null || filePath.isEmpty()) { + filePath = WORKSPACE_TMP_PATH.concat(file.fileId) + } + file.setTargetLocation(filePath) + if (file.getVariable() != null && file.getVariable().length() > 0) { + Exception catchedException = null + try { + if (getEnv(file.getVariable()) != null) { + throw new Exception("${file.getVariable()} is already registered!") + } + } catch (Exception e) { + catchedException = e + } + Assert.assertNull("The config provider already has a configFile with variable " + file.getVariable(), catchedException) + setEnv(file.getVariable(), filePath) + } + } + closure.run() + } + + /** + * Mocks the 'configFileProvider' step. For each ManagedFile the environment variable is set to a dummy filepath + */ + def ansiColorCallback = { + String colorMode, Closure body -> + stepRecorder.record(ANSI_COLOR, colorMode) + this.setEnv('TERM', colorMode) + body.run() + this.setEnv('TERM', null) + } + + /** + * Mocks the 'tool' step + */ + def toolCallback = { String tool -> + stepRecorder.record(TOOL, tool) + switch (tool) { + case TOOL_MAVEN: + return TOOL_MAVEN_PREFIX.concat(tool) + case TOOL_JDK: + return TOOL_JDK_PREFIX.concat(tool) + } + return "" + } + + /** + * @return The current build parameters + */ + Map getParams() { + return params + } + + /** + * Sets the current build parameters + * + * @param params + */ + void setParams(Map params) { + this.params = params + this.binding.setVariable("params", params) + } +} diff --git a/test/io/wcm/testing/jenkins/pipeline/RunWrapperMock.groovy b/test/io/wcm/testing/jenkins/pipeline/RunWrapperMock.groovy new file mode 100644 index 0000000..648636c --- /dev/null +++ b/test/io/wcm/testing/jenkins/pipeline/RunWrapperMock.groovy @@ -0,0 +1,72 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.testing.jenkins.pipeline + +import hudson.model.Run + +class RunWrapperMock { + + Run run + + public String result = null + + RunWrapperMock previousBuild = null + + String getDisplayName() { + return displayName + } + + String getResult() { + return result + } + + void setResult(String result) { + this.result = result + } + + void setDisplayName(String displayName) { + this.displayName = displayName + } + String displayName = "" + + RunWrapperMock(Run run) { + this.run = run + result = null + } + + Object getPreviousBuild() { + return previousBuild + } + + void setPreviousBuild(RunWrapperMock runWrapper) { + previousBuild = runWrapper + } + + Object getRawBuild() { + return run + } + + void setPreviousBuildResult(String result) { + if (this.previousBuild == null) { + this.previousBuild = new RunWrapperMock(null) + } + this.previousBuild.setResult(result) + } +} diff --git a/test/io/wcm/testing/jenkins/pipeline/StepConstants.groovy b/test/io/wcm/testing/jenkins/pipeline/StepConstants.groovy new file mode 100644 index 0000000..f42f6d0 --- /dev/null +++ b/test/io/wcm/testing/jenkins/pipeline/StepConstants.groovy @@ -0,0 +1,92 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.testing.jenkins.pipeline + +/** + * Constants for steps + */ +class StepConstants { + + public final static String ANALYSISPUBLISHER = "AnalysisPublisher" + public final static String ANSI_COLOR = "ansiColor" + public final static String ANSIBLE_PLAYBOOK = "ansiblePlaybook" + + public final static String BOOLEAN_PARAM = "booleanParam" + public final static String BUILD_DISCARDER = "buildDiscarder" + + public final static String CHECKOUT = "checkout" + public final static String CHECKSTYLE = "checkstyle" + public final static String CHECKOUT_SCM = "checkoutScm" + public final static String CHOICE = "choice" + public final static String CONFIGFILE = "configFile" + public final static String CONFIGFILEPROVIDER = "configFileProvider" + public final static String CRON = "cron" + + public final static String DISABLE_CONCURRENT_BUILDS = "disableConcurrentBuilds" + + public final static String EMAILEXT = "emailext" + public final static String ERROR = "error" + public final static String EXEC_MANAGED_SHELL_SCRIPT = "execManagedShellScript" + + public final static String FILE_EXISTS = "fileExists" + public final static String FIND_FILES = "findFiles" + public final static String FINDBUGS = "findbugs" + + public final static String JACOCOPUBLISHER = "JacocoPublisher" + public final static String JUNIT = "junit" + + public final static String LOG_ROTATOR = "logRotator" + + public final static String NODE = "node" + + public final static String OPENTASKS = "openTasks" + + public final static String PARAMETERS = "parameters" + public final static String PIPELINE_TRIGGERS = "pipelineTriggers" + public final static String PMD = "pmd" + public final static String POLLSCM = "pollSCM" + + + public final static String READ_JSON = "readJSON" + public final static String READ_MAVEN_POM = "readMavenPom" + public final static String READ_YAML = "readYaml" + + public final static String SH = "sh" + public final static String SLEEP = "sleep" + public final static String SSH_AGENT = "sshagent" + public final static String STAGE = "stage" + public final static String STASH = "stash" + public final static String STEP = "step" + public final static String STRING = "string" + + public final static String TEXT = "text" + public final static String TIMESTAMPS = "timestamps" + public final static String TIMEOUT = "timeout" + public final static String TOOL = "tool" + + public final static String UNSTASH = "unstash" + public final static String UPSTREAM = "upstream" + + public final static String VERSIONNUMBER = "VersionNumber" + + public final static String XUNITBUILDER = "XUnitBuilder" + + +} diff --git a/test/io/wcm/testing/jenkins/pipeline/global/lib/SelfSourceRetriever.groovy b/test/io/wcm/testing/jenkins/pipeline/global/lib/SelfSourceRetriever.groovy new file mode 100644 index 0000000..c9b6edb --- /dev/null +++ b/test/io/wcm/testing/jenkins/pipeline/global/lib/SelfSourceRetriever.groovy @@ -0,0 +1,57 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.testing.jenkins.pipeline.global.lib + +import com.lesfurets.jenkins.unit.global.lib.SourceRetriever + +/** + * Source retriever for used JenkinsPipelineUnit testing framework which allows to use the current project as library resource. + * + * @see JenkinsPipelineUnit + */ +class SelfSourceRetriever implements SourceRetriever { + + String sourceURL + + SelfSourceRetriever(String sourceURL) { + this.sourceURL = sourceURL + } + + /** + * Returns the current workspace as part of the source to be used + * + * @param repository Not used since the current workspace is the repo + * @param branch Not used since the current workspace is the repo + * @param targetPath Not used since the current workspace is the repo + * @return The current workspace path as the only entry in the list + */ + @Override + List retrieve(String repository, String branch, String targetPath) { + File sourceDir = new File(this.sourceURL) + if (sourceDir.exists()) { + return [sourceDir.toURI().toURL()] + } + throw new IllegalStateException("Directory $sourceDir.path does not exists") + } + + static SelfSourceRetriever localSourceRetriever(String source) { + new SelfSourceRetriever(source) + } +} diff --git a/test/io/wcm/testing/jenkins/pipeline/global/lib/SubmoduleSourceRetriever.groovy b/test/io/wcm/testing/jenkins/pipeline/global/lib/SubmoduleSourceRetriever.groovy new file mode 100644 index 0000000..9d0c217 --- /dev/null +++ b/test/io/wcm/testing/jenkins/pipeline/global/lib/SubmoduleSourceRetriever.groovy @@ -0,0 +1,50 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.testing.jenkins.pipeline.global.lib + +import com.lesfurets.jenkins.unit.global.lib.SourceRetriever + +/** + * Source retriever for the used JenkinsPipelineUnit testing framework which allows to use git submodules as library + * resource + * + * @see JenkinsPipelineUnit + */ +class SubmoduleSourceRetriever implements SourceRetriever { + + String sourceURL + + SubmoduleSourceRetriever(String sourceURL) { + this.sourceURL = sourceURL + } + + @Override + List retrieve(String repository, String branch, String targetPath) { + def sourceDir = new File(sourceURL).toPath().resolve("$repository/$branch").toFile() + if (sourceDir.exists()) { + return [sourceDir.toURI().toURL()] + } + throw new IllegalStateException("Directory $sourceDir.path does not exists") + } + + static SubmoduleSourceRetriever submoduleSourceRetriever(String source) { + new SubmoduleSourceRetriever(source) + } +} diff --git a/test/io/wcm/testing/jenkins/pipeline/recorder/StepRecorder.groovy b/test/io/wcm/testing/jenkins/pipeline/recorder/StepRecorder.groovy new file mode 100644 index 0000000..6d18c4f --- /dev/null +++ b/test/io/wcm/testing/jenkins/pipeline/recorder/StepRecorder.groovy @@ -0,0 +1,76 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.testing.jenkins.pipeline.recorder + +import io.wcm.testing.jenkins.pipeline.StepConstants + +/** + * Helper for recording executed steps during unit and integration testing + */ +class StepRecorder { + + protected Map recordedSteps + + StepRecorder() { + this.recordedSteps = [:] + } + + /** + * Records an executed step with the given name and the paramaters executed + * + * @param stepName The name of the step to record + * @param value The arguments with with the step was called + */ + void record(String stepName, Object value) { + // create a new entry in the recordedSteps object if necessary + List entryList = (List) recordedSteps[stepName] ?: [] + entryList.add(value) + // add the step to the records + recordedSteps.put(stepName, entryList) + } + + /** + * Returns the recorded items for the given stepName. + * Since there is the possibility to use a general build step like "step([$class: 'AnalysisPublisher', ...)" + * this function also looks into the general steps recorded and returns these calls also based on the "$class" property + * + * @param stepName The name of the step to search for + * @return List of executed calls containing the arguments with which the step was called + */ + List getRecordedSteps(String stepName) { + List steps = (List) recordedSteps[stepName] ?: [] + + // walk through generic steps to find generic steps like Jacoco and Analysis Publisher + List genericSteps = (List) recordedSteps[StepConstants.STEP] ?: [] + genericSteps.each { step -> + if (step instanceof Map) { + String className = step['$class'] ?: "" + if (className == stepName) { + steps.add(step) + } + } + } + return steps + } + + Map getRecordedSteps() { + return recordedSteps + } +} diff --git a/test/io/wcm/testing/jenkins/pipeline/recorder/StepRecorderAssert.groovy b/test/io/wcm/testing/jenkins/pipeline/recorder/StepRecorderAssert.groovy new file mode 100644 index 0000000..923c197 --- /dev/null +++ b/test/io/wcm/testing/jenkins/pipeline/recorder/StepRecorderAssert.groovy @@ -0,0 +1,95 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.testing.jenkins.pipeline.recorder + +import io.wcm.testing.jenkins.pipeline.StepConstants + +import static org.junit.Assert.assertEquals + +/** + * Assert functions for the StepRecorder to make the evaluation of executed steps easier + */ +class StepRecorderAssert { + + private static StepRecorder rec + + /** + * Has to be called once before test execution to provide the reference to the StepRecorder + * @param rec + * @return + */ + static init(StepRecorder rec) { + this.rec = rec + } + + /** + * Asserts that a step with the given stepName was called the given amount of times + * When the assertion is correct the list of detected steps is returned for further processing in the test + * + * @param stepName The name of the step that the assertion should look for + * @param stepCount The amount of times the step is allowed to be recorded + * @return List of recorded step calls + */ + static List assertStepCalls(String stepName, Integer stepCount) { + List recordedSteps = rec.getRecordedSteps(stepName) + assertEquals(String.format("step '%s' expected %s time(s), actualSteps '%s'", stepName, stepCount, recordedSteps.toString()), stepCount, recordedSteps.size()) + return recordedSteps + } + + /** + * Asserts that the step identified by the stepName was not recorded by the StepRecorder + * + * @param stepName The name of the step that the assertion should look for + */ + static void assertNone(String stepName) { + assertStepCalls(stepName, 0) + } + + /** + * Asserts that the step identified by the stepName was recorded once by the StepRecorder + * + * @param stepName The name of the step that the assertion should look for + * @return The recorded step + */ + static Object assertOnce(String stepName) { + return assertStepCalls(stepName, 1).get(0) + } + + /** + * Asserts that the step identified by the stepName was recorded twice by the StepRecorder + * + * @param stepName The name of the step that the assertion should look for + * @return The list of the recorded steps + */ + static List assertTwice(String stepName) { + return assertStepCalls(stepName, 2) + } + + /** + * Asserts that the shell step 'sh' was called once with the given command + * + * @param expectedCommand The command that is expected + */ + static assertOneShellCommand(String expectedCommand) { + String actualCommand = assertOnce(StepConstants.SH) + assertEquals(expectedCommand, actualCommand) + } + +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/credentials/CredentialParserTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/credentials/CredentialParserTest.groovy new file mode 100644 index 0000000..7349c0b --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/credentials/CredentialParserTest.groovy @@ -0,0 +1,53 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.credentials + +import io.wcm.testing.jenkins.pipeline.DSLTestBase +import io.wcm.tooling.jenkins.pipeline.utils.resources.JsonLibraryResource +import net.sf.json.JSON +import org.junit.Test + +import static org.junit.Assert.assertEquals + +class CredentialParserTest extends DSLTestBase { + + JsonLibraryResource jsonLibraryResource + JSON testContent + + @Override + void setUp() throws Exception { + super.setUp() + jsonLibraryResource = new JsonLibraryResource(this.dslMock.getMock(), "credentials/parser-test.json") + testContent = jsonLibraryResource.load() + } + + @Test + void shouldOnlyReturnValidResources() { + CredentialParser underTest = new CredentialParser() + List parseResult = underTest.parse(testContent) + assertEquals("should only contain one managed file", 1, parseResult.size()) + Credential parsedCredential = parseResult.get(0) + assertEquals("should-be-parsed-credential-pattern", parsedCredential.getPattern()) + assertEquals("should-be-parsed-credential-id", parsedCredential.getId()) + assertEquals("should-be-parsed-credential-comment", parsedCredential.getComment()) + assertEquals("should-be-parsed-credential-username", parsedCredential.getUserName()) + } + +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/credentials/CredentialTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/credentials/CredentialTest.groovy new file mode 100644 index 0000000..845321d --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/credentials/CredentialTest.groovy @@ -0,0 +1,36 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.credentials + +import org.junit.Test + +import static org.junit.Assert.assertEquals + +class CredentialTest { + + @Test + void shouldReturnConstructorValues() { + Credential underTest = new Credential("test-pattern", "test-id", "test-comment") + assertEquals("test-pattern", underTest.getPattern()) + assertEquals("test-id", underTest.getId()) + assertEquals("test-comment", underTest.getComment()) + } + +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFileParserTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFileParserTest.groovy new file mode 100644 index 0000000..dcb5c90 --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFileParserTest.groovy @@ -0,0 +1,52 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.managedfiles + +import io.wcm.testing.jenkins.pipeline.DSLTestBase +import io.wcm.tooling.jenkins.pipeline.utils.resources.JsonLibraryResource +import org.junit.Test + +import static org.junit.Assert.assertEquals + +class ManagedFileParserTest extends DSLTestBase { + + JsonLibraryResource jsonLibraryResource + Object testContent + + @Override + void setUp() throws Exception { + super.setUp() + jsonLibraryResource = new JsonLibraryResource(this.dslMock.getMock(), "managedfiles/maven/parser-test.json") + testContent = jsonLibraryResource.load() + + } + + @Test + void shouldOnlyReturnValidResources() { + ManagedFileParser underTest = new ManagedFileParser() + List parseResult = underTest.parse(testContent) + assertEquals("should only contain one managed file", 1, parseResult.size()) + ManagedFile parsedFile = parseResult.get(0) + assertEquals("should-be-parsed-pattern", parsedFile.getPattern()) + assertEquals("should-be-parsed-id", parsedFile.getId()) + assertEquals("should-be-parsed-name", parsedFile.getName()) + assertEquals("should-be-parsed-comment", parsedFile.getComment()) + } +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/managedfiles/MangedFileTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/managedfiles/MangedFileTest.groovy new file mode 100644 index 0000000..9cb377e --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/managedfiles/MangedFileTest.groovy @@ -0,0 +1,63 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.managedfiles + +import org.junit.Test + +import static org.junit.Assert.* + +class MangedFileTest { + + @Test + void shouldReturnConstructorValues() { + ManagedFile underTest = new ManagedFile("test-pattern", "test-id", "test-name", "test-comment") + assertEquals("test-pattern", underTest.getPattern()) + assertEquals("test-id", underTest.getId()) + assertEquals("test-name", underTest.getName()) + assertEquals("test-comment", underTest.getComment()) + } + + @Test + void shouldBeInvalidWhenPatternIsNull() { + ManagedFile underTest = new ManagedFile(null, "test-id") + assertFalse(underTest.isValid()) + } + + @Test + void shouldBeInvalidWhenIdIsNull() { + ManagedFile underTest = new ManagedFile("test-pattern", null) + assertFalse(underTest.isValid()) + } + + @Test + void shouldBeInvalidWhenPatternAndIdIsNull() { + ManagedFile underTest = new ManagedFile(null, null) + assertFalse(underTest.isValid()) + } + + @Test + void shouldBeValidWhenPatternAndIdIsSet() { + ManagedFile underTest = new ManagedFile("valid-pattern", "valid-id") + assertTrue(underTest.isValid()) + assertEquals("valid-pattern", underTest.getPattern()) + assertEquals("valid-id", underTest.getId()) + } + +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/model/ResultTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/model/ResultTest.groovy new file mode 100644 index 0000000..e7e23da --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/model/ResultTest.groovy @@ -0,0 +1,134 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.model + +import hudson.model.Result as HudsonResult +import org.junit.Test + +import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertTrue + +class ResultTest { + + @Test + void shouldReturnDefault() { + assertEquals(Result.FAILURE, Result.fromString("not found")) + } + + @Test + void shouldReturnExistingHudsonResults() { + assertEquals(Result.NOT_BUILD, Result.fromString(HudsonResult.NOT_BUILT.toString())) + assertEquals(Result.ABORTED, Result.fromString(HudsonResult.ABORTED.toString())) + assertEquals(Result.FAILURE, Result.fromString(HudsonResult.FAILURE.toString())) + assertEquals(Result.UNSTABLE, Result.fromString(HudsonResult.UNSTABLE.toString())) + assertEquals(Result.SUCCESS, Result.fromString(HudsonResult.SUCCESS.toString())) + } + + @Test + void shouldReturnCustomResults() { + assertEquals(Result.STILL_UNSTABLE, Result.fromString(Result.STILL_UNSTABLE.toString())) + assertEquals(Result.STILL_FAILING, Result.fromString(Result.STILL_FAILING.toString())) + assertEquals(Result.FIXED, Result.fromString(Result.FIXED.toString())) + } + + @Test + void shouldBeBetterThan() { + assertBetter(Result.NOT_BUILD, Result.ABORTED) + + assertBetter(Result.FAILURE, Result.NOT_BUILD) + assertBetter(Result.STILL_FAILING, Result.NOT_BUILD) + + assertBetter(Result.UNSTABLE, Result.FAILURE) + assertBetter(Result.UNSTABLE, Result.STILL_FAILING) + assertBetter(Result.STILL_UNSTABLE, Result.FAILURE) + + assertBetter(Result.SUCCESS, Result.UNSTABLE) + assertBetter(Result.FIXED, Result.UNSTABLE) + } + + @Test + void shouldBeBetterWorseThan() { + assertWorseThan(Result.ABORTED, Result.NOT_BUILD) + + assertWorseThan(Result.NOT_BUILD, Result.FAILURE) + assertWorseThan(Result.NOT_BUILD, Result.STILL_FAILING) + + assertWorseThan(Result.FAILURE, Result.UNSTABLE) + assertWorseThan(Result.STILL_FAILING, Result.UNSTABLE) + assertWorseThan(Result.FAILURE, Result.STILL_UNSTABLE) + + assertWorseThan(Result.UNSTABLE, Result.SUCCESS) + } + + @Test + void shouldBeBetterThanOrEqual() { + assertBetterOrEqual(Result.NOT_BUILD, Result.NOT_BUILD) + assertBetterOrEqual(Result.NOT_BUILD, Result.ABORTED) + + assertBetterOrEqual(Result.FAILURE, Result.FAILURE) + assertBetterOrEqual(Result.FAILURE, Result.NOT_BUILD) + + assertBetterOrEqual(Result.UNSTABLE, Result.UNSTABLE) + assertBetterOrEqual(Result.UNSTABLE, Result.STILL_UNSTABLE) + assertBetterOrEqual(Result.UNSTABLE, Result.FAILURE) + + + assertBetterOrEqual(Result.STILL_UNSTABLE, Result.FAILURE) + + assertBetterOrEqual(Result.SUCCESS, Result.UNSTABLE) + assertBetterOrEqual(Result.SUCCESS, Result.SUCCESS) + assertBetterOrEqual(Result.FIXED, Result.SUCCESS) + } + + @Test + void shouldBeBetterWorseThanOrEqual() { + assertWorseOrEqual(Result.ABORTED, Result.NOT_BUILD) + + assertWorseOrEqual(Result.NOT_BUILD, Result.FAILURE) + assertWorseOrEqual(Result.NOT_BUILD, Result.STILL_FAILING) + + assertWorseOrEqual(Result.FAILURE, Result.UNSTABLE) + assertWorseOrEqual(Result.STILL_FAILING, Result.UNSTABLE) + assertWorseOrEqual(Result.FAILURE, Result.STILL_UNSTABLE) + + assertWorseOrEqual(Result.UNSTABLE, Result.SUCCESS) + + assertWorseOrEqual(Result.UNSTABLE, Result.STILL_UNSTABLE) + assertWorseOrEqual(Result.STILL_FAILING, Result.STILL_UNSTABLE) + assertWorseOrEqual(Result.STILL_FAILING, Result.FIXED) + assertWorseOrEqual(Result.STILL_FAILING, Result.FAILURE) + } + + protected assertBetter(Result that, Result than) { + assertTrue("'$that' should be better than '$than'", that.isBetterThan(than)) + } + + protected assertWorseThan(Result that, Result than) { + assertTrue("'$that' should be worse than '$than'", that.isWorseThan(than)) + } + + protected assertBetterOrEqual(Result that, Result than) { + assertTrue("'$that' should be better or equal than '$than'", that.isBetterOrEqualTo(than)) + } + + protected assertWorseOrEqual(Result that, Result than) { + assertTrue("'$that' should be worse or equal than '$than'", that.isWorseOrEqualTo(than)) + } +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/shell/CommandBuilderImplTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/shell/CommandBuilderImplTest.groovy new file mode 100644 index 0000000..304b30a --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/shell/CommandBuilderImplTest.groovy @@ -0,0 +1,134 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.shell + +import hudson.AbortException +import io.wcm.testing.jenkins.pipeline.DSLTestBase +import org.junit.Test + +import static org.junit.Assert.assertEquals + +class CommandBuilderImplTest extends DSLTestBase { + + CommandBuilderImpl underTest + + @Override + void setUp() throws Exception { + super.setUp() + underTest = new CommandBuilderImpl(this.dslMock.getMock(), "underTestExec") + } + + @Test + void shouldBuildWithoutArguments() { + assertEquals("underTestExec", underTest.build()) + } + + @Test + void shouldBuildWithArguments() { + underTest.addArgument("-B") + underTest.addArgument("-U") + underTest.addArgument("--global-settings", "/some/value") + assertEquals("underTestExec -B -U --global-settings /some/value", underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldBuildWithCorrectPath() { + underTest.addPathArgument('"/path variant/1"') + .addPathArgument("'/path variant/2'") + .addPathArgument("/path variant/3") + .addPathArgument("--file", "'path variant/4'") + assertEquals("underTestExec /path\\ variant/1 /path\\ variant/2 /path\\ variant/3 --file path\\ variant/4", underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldNotAddNullPathArgument() { + underTest.addPathArgument("nullValue", null) + underTest.addPathArgument(null, "nullArgName") + assertEquals("underTestExec", underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldNotAddNullPath() { + underTest.addPathArgument(null) + assertEquals("underTestExec", underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldNotAddNullArgumentAndValue() { + underTest.addArgument("nullValue", null) + .addArgument(null, "nullArgName") + assertEquals("underTestExec", underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldNotAddNullArgument() { + underTest.addArgument("nullValue", null) + underTest.addArgument(null, "nullArgName") + assertEquals("underTestExec", underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldNotAddEmptyArgument() { + underTest.addArgument("") + assertEquals("underTestExec", underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldBuildWithArgumentsString() { + underTest.addArguments(null) + underTest.addArguments("-Arg1 -Arg2") + assertEquals("underTestExec -Arg1 -Arg2", underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldBuildWithArgumentsList() { + underTest.addArguments(["-Arg1", "-Arg2", null]) + assertEquals("underTestExec -Arg1 -Arg2", underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldBuildWithoutExecutable() { + underTest = new CommandBuilderImpl(this.getDslMock().getMock()) + underTest.addPathArgument('/some/path') + underTest.addArgument('someArg') + assertEquals("/some/path someArg", underTest.build()) + assertEmptyAfterReset("") + } + + @Test(expected = AbortException) + void shouldAbortWithNullExectuable() { + CommandBuilderImpl underTest = new CommandBuilderImpl(this.dslMock.getMock(), null) + } + + void assertEmptyAfterReset(String expectedExecutable = "underTestExec") { + underTest.reset() + String resetCommandLine = underTest.build() + assertEquals(expectedExecutable, resetCommandLine) + } +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/shell/MavenCommandBuilderImplTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/shell/MavenCommandBuilderImplTest.groovy new file mode 100644 index 0000000..a2a7423 --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/shell/MavenCommandBuilderImplTest.groovy @@ -0,0 +1,249 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.shell + +import io.wcm.testing.jenkins.pipeline.DSLTestBase +import org.junit.Test + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +import static org.junit.Assert.assertEquals + +class MavenCommandBuilderImplTest extends DSLTestBase { + + MavenCommandBuilderImpl underTest + + @Override + void setUp() throws Exception { + super.setUp() + underTest = new MavenCommandBuilderImpl(this.dslMock.getMock()) + assertEmptyAfterReset() + } + + @Test + void shouldBuildDefaultCommand() { + assertEquals(MavenCommandBuilderImpl.EXECUTABLE, underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldBuildWithCustomExecutable() { + underTest = new MavenCommandBuilderImpl(this.dslMock.getMock(), "time mvn") + underTest.setPom("customPom1.xml") + underTest.setGoals("clean package") + assertEquals("time mvn -f customPom1.xml clean package", underTest.build()) + assertEmptyAfterReset("time mvn") + } + + @Test + void shouldBuildWithFile() { + underTest.setPom("customPom1.xml") + underTest.setGoals("clean package") + assertEquals("mvn -f customPom1.xml clean package", underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldBuildWithCustomSettings() { + underTest.setPom("customPom2.xml") + underTest.setGoals("clean package") + underTest.setGlobalSettings("/path/to/global settings.xml") + underTest.setSettings("/path/to/settings.xml") + assertEquals("mvn -f customPom2.xml clean package --global-settings /path/to/global\\ settings.xml --settings /path/to/settings.xml", underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldBuildWithManualDefines() { + underTest.setGoals("clean package") + underTest.addDefine(null) + underTest.addDefine(null, null) + underTest.addDefine(null, "value") + underTest.addDefine("defineFlag") + underTest.addDefine("booleanDefine", true) + underTest.addDefine("stringDefine", "stringValue") + underTest.addDefine("defineFlagWithNull", null) + assertEquals("mvn clean package -DdefineFlag -DbooleanDefine=true -DstringDefine=stringValue -DdefineFlagWithNull", underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldBuildWithStringDefines() { + underTest.setGoals("clean package") + underTest.addDefines("-Dflag -Dname=value") + assertEquals("mvn clean package -Dflag -Dname=value", underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldBuildWithMapDefines() { + underTest.setGoals("clean package") + + Map map = [:] + map.put("flag", null) + map.put("string", "value") + map.put("booleanTrue", true) + map.put("booleanFalse", false) + + underTest.addDefines(map) + assertEquals("mvn clean package -Dflag -Dstring=value -DbooleanTrue=true -DbooleanFalse=false", underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldNotFailOnEmptyConfig() { + underTest.applyConfig([:]) + assertEquals("mvn", underTest.build()) + } + + @Test + void shouldApplyConfigVariant1() { + Map config = [ + (MAVEN): [ + (MAVEN_DEFINES) : "-DvalueDefine=value -DflagDefine", + (MAVEN_ARGUMENTS) : "-B -U", + (MAVEN_GOALS) : "clean install", + (MAVEN_EXECUTABLE) : "path/to/custom/maven/bin/mvn", + (MAVEN_GLOBAL_SETTINGS): "global-settings-id", + (MAVEN_POM) : "path with spaces/to/custom/pom.xml", + (MAVEN_SETTINGS) : "settings-id", + (MAVEN_PROFILES) : ["profile1", "profile2"], + ] + ] + underTest.applyConfig(config) + assertEquals("path/to/custom/maven/bin/mvn -f path\\ with\\ spaces/to/custom/pom.xml clean install -B -U -DvalueDefine=value -DflagDefine -Pprofile1,profile2", underTest.build()) + assertEquals("settings-id", underTest.getSettingsId()) + assertEquals("global-settings-id", underTest.getGlobalSettingsId()) + assertEmptyAfterReset("path/to/custom/maven/bin/mvn") + } + + @Test + void shouldApplyConfigVariant2() { + Map config = [ + (MAVEN): [ + (MAVEN_DEFINES) : [valueDefine: "value", "flagDefine": null], + (MAVEN_ARGUMENTS) : ["-B", "-U"], + (MAVEN_GOALS) : ["clean", "install"], + (MAVEN_EXECUTABLE) : "path/to/custom/maven/bin/mvn", + (MAVEN_GLOBAL_SETTINGS): "global-settings-id", + (MAVEN_POM) : "path with spaces/to/custom/pom.xml", + (MAVEN_SETTINGS) : "settings-id", + (MAVEN_PROFILES) : "profile3,profile4", + ] + ] + underTest.applyConfig(config) + assertEquals("path/to/custom/maven/bin/mvn -f path\\ with\\ spaces/to/custom/pom.xml clean install -B -U -DvalueDefine=value -DflagDefine -Pprofile3,profile4", underTest.build()) + assertEquals("settings-id", underTest.getSettingsId()) + assertEquals("global-settings-id", underTest.getGlobalSettingsId()) + assertEmptyAfterReset("path/to/custom/maven/bin/mvn") + } + + @Test + void shouldBuildWithParams() { + Map params = ([choiceParam: "choice1", boolParam: true, stringParam: "text"]) + underTest = new MavenCommandBuilderImpl(this.dslMock.getMock(), params) + Map config = [ + (MAVEN): [ + (MAVEN_INJECT_PARAMS): true, + (MAVEN_GOALS) : ["clean", "install"], + ] + ] + underTest.applyConfig(config) + assertEquals("mvn clean install -DchoiceParam=choice1 -DboolParam=true -DstringParam=text", underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldBuildWithoutParams() { + Map params = ([choiceParam: "choice1", boolParam: true, stringParam: "text"]) + underTest = new MavenCommandBuilderImpl(this.dslMock.getMock(), params) + Map config = [ + (MAVEN): [ + (MAVEN_INJECT_PARAMS): false, + (MAVEN_GOALS) : ["clean", "install"], + ] + ] + underTest.applyConfig(config) + + assertEquals("mvn clean install", underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldBuildWithGoalList() { + underTest.setGoals(["clean", "package"]) + assertEquals("mvn clean package", underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldBuildWithEmptyGoalList() { + underTest.setGoals([]) + assertEquals("mvn", underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldAddProfilesFromString() { + underTest.addProfiles("profile1,profile2") + assertEquals("mvn -Pprofile1,profile2", underTest.build()) + underTest.reset() + underTest.applyConfig( + (MAVEN): [ + (MAVEN_PROFILES): "profile3,profile4" + ] + ) + assertEquals("mvn -Pprofile3,profile4", underTest.build()) + underTest.reset() + underTest.applyConfig( + (MAVEN): [ + (MAVEN_PROFILES): "" + ] + ) + assertEquals("mvn", underTest.build()) + } + + @Test + void shouldAddProfilesFromList() { + underTest.addProfiles(["profile5", "profile6"]) + assertEquals("mvn -Pprofile5,profile6", underTest.build()) + underTest.reset() + underTest.applyConfig( + (MAVEN): [ + (MAVEN_PROFILES): ["profile7", "profile8"] + ] + ) + assertEquals("mvn -Pprofile7,profile8", underTest.build()) + underTest.reset() + underTest.applyConfig( + (MAVEN): [ + (MAVEN_PROFILES): [] + ] + ) + assertEquals("mvn", underTest.build()) + } + + void assertEmptyAfterReset(String expectedExecutable = "mvn") { + underTest.reset() + String resetCommandLine = underTest.build() + assertEquals(expectedExecutable, resetCommandLine) + assertEquals(null, underTest.getSettingsId()) + assertEquals(null, underTest.getGlobalSettingsId()) + } +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/shell/ScpCommandBuilderImplTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/shell/ScpCommandBuilderImplTest.groovy new file mode 100644 index 0000000..1ad08cb --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/shell/ScpCommandBuilderImplTest.groovy @@ -0,0 +1,122 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.shell + +import hudson.AbortException +import io.wcm.testing.jenkins.pipeline.DSLTestBase +import io.wcm.tooling.jenkins.pipeline.credentials.Credential +import org.junit.Before +import org.junit.Test + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertNull + +class ScpCommandBuilderImplTest extends DSLTestBase { + + ScpCommandBuilderImpl underTest + + Map configTemplate = [ + (SCP_HOST) : "testhost", + (SCP_PORT) : null, + (SCP_USER) : null, + (SCP_ARGUMENTS) : [], + (SCP_RECURSIVE) : false, + (SCP_SOURCE) : "/path/to/source/*", + (SCP_DESTINATION) : "/path/to/destination", + (SCP_EXECUTABLE) : null, + (SCP_HOST_KEY_CHECK): false + ] + + @Before + void setUp() throws Exception { + super.setUp() + underTest = new ScpCommandBuilderImpl(this.dslMock.getMock()) + } + + @Test + void shouldBuildNonRecursiveWithoutUser() { + underTest.applyConfig(configTemplate) + assertEquals('scp -P 22 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null /path/to/source/* testhost:"/path/to/destination"', underTest.build()) + assertEmptyAfterReset() + } + + @Test + void shouldBuildWithRecursiveUserPortHostKeyCheck() { + configTemplate[SCP_USER] = "testserveruser" + configTemplate[SCP_RECURSIVE] = true + configTemplate[SCP_HOST_KEY_CHECK] = true + configTemplate[SCP_SOURCE] = "/path/with spaces/to/source dir/*" + configTemplate[SCP_DESTINATION] = "/path/with spaces/to/destination folder" + configTemplate[SCP_ARGUMENTS] = ["-arg1", "-arg2"] + configTemplate[SCP_PORT] = 2222 + configTemplate[SCP_EXECUTABLE] = "/usr/sbin/scp" + underTest.applyConfig(configTemplate) + underTest.setCredential(new Credential("pattern", "id", "comment", "should-not-be-used-username")) + assertEquals('/usr/sbin/scp -arg1 -arg2 -P 2222 -r /path/with\\ spaces/to/source\\ dir/* testserveruser@testhost:"/path/with\\ spaces/to/destination\\ folder"', underTest.build()) + assertEmptyAfterReset("/usr/sbin/scp") + } + + @Test + void shouldUseUserNameFromCredentialAndCustomExecutable() { + underTest.commandBuilder.setExecutable("/usr/bin/scp") + underTest.setHost("propertyhost") + underTest.setSourcePath("source path/from property") + underTest.setDestinationPath("destination path/from property") + underTest.setCredential(new Credential("pattern", "id", "comment", "should-be-used-username")) + assertEquals('/usr/bin/scp source\\ path/from\\ property should-be-used-username@propertyhost:"destination\\ path/from\\ property"', underTest.build()) + assertEmptyAfterReset("/usr/bin/scp") + } + + @Test(expected = AbortException) + void shouldFailWhenNoHostGiven() { + configTemplate[SCP_HOST] = null + underTest.applyConfig(configTemplate) + underTest.build() + assertEmptyAfterReset() + } + + @Test(expected = AbortException) + void shouldFailWhenNoSourceGiven() { + configTemplate[SCP_SOURCE] = null + underTest.applyConfig(configTemplate) + underTest.build() + assertEmptyAfterReset() + } + + @Test(expected = AbortException) + void shouldFailWhenNoDestinationGiven() { + configTemplate[SCP_DESTINATION] = null + underTest.applyConfig(configTemplate) + underTest.build() + assertEmptyAfterReset() + } + + void assertEmptyAfterReset(String expectedExecutable = "scp") { + underTest.reset() + assertNull(underTest.getCredential()) + assertNull(underTest.getHost()) + assertNull(underTest.getUser()) + assertNull(underTest.getDestinationPath()) + assertNull(underTest.getSourcePath()) + assertEquals(expectedExecutable, underTest.getCommandBuilder().build()) + } + +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/shell/ShellUtilsTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/shell/ShellUtilsTest.groovy new file mode 100644 index 0000000..5206a00 --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/shell/ShellUtilsTest.groovy @@ -0,0 +1,79 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.shell + +import org.junit.Test + +import static org.junit.Assert.assertEquals + +class ShellUtilsTest { + + @Test + void shouldEscapeSpaces() { + String actual = ShellUtils.escapePath("folder with spaces/subfolder with spaces/filename with spaces.txt") + assertEquals('folder\\ with\\ spaces/subfolder\\ with\\ spaces/filename\\ with\\ spaces.txt', actual) + } + + @Test + void shouldRemoveSingleQuotes() { + String actual = ShellUtils.escapePath("'folder with spaces/subfolder with spaces'") + assertEquals("folder\\ with\\ spaces/subfolder\\ with\\ spaces", actual) + } + + @Test + void shouldRemoveDoubleQuotes() { + String actual = ShellUtils.escapePath('"folder with spaces/subfolder with spaces"') + assertEquals("folder\\ with\\ spaces/subfolder\\ with\\ spaces", actual) + } + + @Test + void shouldReturnNull() { + String actual = ShellUtils.escapePath(null) + assertEquals(null, actual) + } + + @Test + void shouldOnlyRemoveSurroundingDoubleQuotes() { + assertEquals('va\\"lue', ShellUtils.escapePath('va"lue"')) + assertEquals('va\\"lue', ShellUtils.escapePath('"va"lue')) + } + + @Test + void shouldOnlyRemoveSurroundingSingleQuotes() { + assertEquals("va\\\'lue", ShellUtils.escapePath("va'lue'")) + assertEquals("va\\\'lue", ShellUtils.escapePath("'va'lue")) + } + + @Test + void shouldOnlyTrimBeginningAndEndingDoubleQuote() { + assertEquals('"val"ue"', ShellUtils.trimDoubleQuote('""val"ue""')) + } + + @Test + void shouldOnlyTrimBeginningAndEndingSingleQuote() { + assertEquals("'val'ue'", ShellUtils.trimSingleQuote("''val'ue''")) + } + + @Test + void shouldEscapeShellCharacters() { + assertEquals("\\\\\\ \\\'\\\"\\!\\#\\\$\\&\\(\\)\\,\\;\\<\\>\\?\\[\\]\\^\\`\\{\\|\\}", ShellUtils.escapeShellCharacters("\\ '\"!#\$&(),;<>?[]^`{|}")) + } + +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/tools/ansible/RoleRequirementsTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/tools/ansible/RoleRequirementsTest.groovy new file mode 100644 index 0000000..2bf750d --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/tools/ansible/RoleRequirementsTest.groovy @@ -0,0 +1,115 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +package io.wcm.tooling.jenkins.pipeline.tools.ansible + +import io.wcm.testing.jenkins.pipeline.DSLTestBase +import org.junit.Test + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +import static org.junit.Assert.* + +class RoleRequirementsTest extends DSLTestBase { + + private RoleRequirements underTest + + @Override + void setUp() throws Exception { + super.setUp() + List ymlContent = dslMock.readYaml("tools/ansible/requirements.yml") + underTest = new RoleRequirements(ymlContent) + } + + @Test + void shouldParseRoles() { + assertEquals(4, underTest.getRoles().size()) + List roles = underTest.getRoles() + + // test galaxy role without version + Role role1 = roles.get(0) + assertTrue(role1.isGalaxyRole()) + assertFalse(role1.isScmRole()) + assertEquals("williamyeh.oracle-java", role1.getSrc()) + assertEquals("williamyeh.oracle-java", role1.getName()) + assertEquals(null, role1.getScm()) + assertEquals("master", role1.getVersion()) + + // test galaxy role with version + Role role2 = roles.get(1) + assertTrue(role2.isGalaxyRole()) + assertFalse(role2.isScmRole()) + assertEquals("tecris.maven", role2.getSrc()) + assertEquals("tecris.maven", role2.getName()) + assertEquals(null, role2.getScm()) + assertEquals("v3.5.2", role2.getVersion()) + + // test scm role without version + Role role3 = roles.get(2) + assertFalse(role3.isGalaxyRole()) + assertTrue(role3.isScmRole()) + assertEquals("https://github.com/wcm-io-devops/ansible-aem-cms.git", role3.getSrc()) + assertEquals("aem-cms", role3.getName()) + assertEquals("git", role3.getScm()) + assertEquals("master", role3.getVersion()) + + // test scm role without version + Role role4 = roles.get(3) + assertFalse(role4.isGalaxyRole()) + assertTrue(role4.isScmRole()) + assertEquals("https://github.com/wcm-io-devops/ansible-aem-service.git", role4.getSrc()) + assertEquals("aem-service", role4.getName()) + assertEquals("git", role4.getScm()) + assertEquals("develop", role4.getVersion()) + } + + @Test + void shouldCreateCorrectCheckoutConfigs() { + List checkoutConfigs = underTest.getCheckoutConfigs() + assertEquals(2, checkoutConfigs.size()) + + Map expectedConfig1 = [ + (SCM): [ + (SCM_URL) : "https://github.com/wcm-io-devops/ansible-aem-cms.git", + (SCM_BRANCHES) : [[name: "master"]], + (SCM_EXTENSIONS): [ + [$class: 'LocalBranch'], + [$class: 'RelativeTargetDirectory', relativeTargetDir: 'aem-cms'], + [$class: 'ScmName', name: 'aem-cms'] + ], + ] + ] + + Map expectedConfig2 = [ + (SCM): [ + (SCM_URL) : "https://github.com/wcm-io-devops/ansible-aem-service.git", + (SCM_BRANCHES) : [[name: "develop"]], + (SCM_EXTENSIONS): [ + [$class: 'LocalBranch'], + [$class: 'RelativeTargetDirectory', relativeTargetDir: 'aem-service'], + [$class: 'ScmName', name: 'aem-service'] + ], + ] + ] + + assertEquals(expectedConfig1, checkoutConfigs.get(0)) + assertEquals(expectedConfig2, checkoutConfigs.get(1)) + } + +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/tools/ansible/RoleTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/tools/ansible/RoleTest.groovy new file mode 100644 index 0000000..b4cb5af --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/tools/ansible/RoleTest.groovy @@ -0,0 +1,78 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +package io.wcm.tooling.jenkins.pipeline.tools.ansible + +import org.junit.Test + +import static org.junit.Assert.* + +class RoleTest { + + @Test + void shouldParseGalaxyRole() { + Role underTest = new Role("some.rolename") + assertEquals("some.rolename", underTest.getSrc()) + assertEquals("some.rolename", underTest.getName()) + assertNull(underTest.getScm()) + assertEquals("master", underTest.getVersion()) + assertTrue(underTest.isValid()) + assertTrue(underTest.isGalaxyRole()) + assertFalse(underTest.isScmRole()) + } + + @Test + void shouldBeAGitRoleWithoutVersion() { + Role underTest = new Role("testSrc") + underTest.setName("testName") + underTest.setScm("git") + + assertEquals("testSrc", underTest.getSrc()) + assertEquals("testName", underTest.getName()) + assertEquals("git", underTest.getScm()) + assertEquals("master", underTest.getVersion()) + assertTrue(underTest.isValid()) + assertFalse(underTest.isGalaxyRole()) + assertTrue(underTest.isScmRole()) + } + + @Test + void shouldBeAGitRoleWithVersion() { + Role underTest = new Role("testSrc") + underTest.setName("testName") + underTest.setScm("git") + underTest.setVersion("testVersion") + + assertEquals("testSrc", underTest.getSrc()) + assertEquals("testName", underTest.getName()) + assertEquals("git", underTest.getScm()) + assertEquals("testVersion", underTest.getVersion()) + assertTrue(underTest.isValid()) + assertFalse(underTest.isGalaxyRole()) + assertTrue(underTest.isScmRole()) + } + + @Test + void shouldBeInvalidWhenSrcIsNull() { + Role underTest = new Role(null) + assertFalse(underTest.isValid()) + } + +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/utils/IntegrationTestUtilTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/utils/IntegrationTestUtilTest.groovy new file mode 100644 index 0000000..308c672 --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/utils/IntegrationTestUtilTest.groovy @@ -0,0 +1,61 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils + +import org.junit.Assert +import org.junit.Test + +class IntegrationTestUtilTest { + + @Test + void shouldRecordTestResults() { + IntegrationTestHelper.addTestPackage("package1") + IntegrationTestHelper.addTestResult([name: "class1", exception: null]) + IntegrationTestHelper.addTestResult([name: "class2", exception: null]) + IntegrationTestHelper.addTestPackage("package2") + IntegrationTestHelper.addTestResult([name: "class3", exception: null]) + IntegrationTestHelper.addTestResult([name: "class4", exception: null]) + + Map results = IntegrationTestHelper.getResults() + Assert.assertEquals([ + package1: [[name: "class1", exception: null], [name: "class2", exception: null]], + package2: [[name: "class3", exception: null], [name: "class4", exception: null]], + ], results) + } + + @Test + void shouldReset() { + IntegrationTestHelper.addTestPackage("package1") + IntegrationTestHelper.addTestResult([name: "class1", exception: null]) + IntegrationTestHelper.addTestResult([name: "class2", exception: null]) + IntegrationTestHelper.reset() + + Map results = IntegrationTestHelper.getResults() + Assert.assertEquals([:], results) + } + + @Test + void shouldNotFailWithEmptyTests() { + IntegrationTestHelper.addTestPackage("package1") + Map results = IntegrationTestHelper.getResults() + Assert.assertEquals([package1: []], results) + } + +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/utils/ListUtilsTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/utils/ListUtilsTest.groovy new file mode 100644 index 0000000..467ac4a --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/utils/ListUtilsTest.groovy @@ -0,0 +1,58 @@ +package io.wcm.tooling.jenkins.pipeline.utils + +import org.junit.Assert +import org.junit.Test + +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +class ListUtilsTest { + + @Test + void shouldRemoveMiddleItem() { + List underTest = [1, 2, 3] + underTest = ListUtils.removeAt(underTest, 1) + Assert.assertEquals([1, 3], underTest) + } + + @Test + void shouldRemoveFirstItem() { + List underTest = [1, 2, 3] + underTest = ListUtils.removeAt(underTest, 0) + Assert.assertEquals([2, 3], underTest) + } + + @Test + void shouldRemoveLastItem() { + List underTest = [1, 2, 3] + underTest = ListUtils.removeAt(underTest, 2) + Assert.assertEquals([1, 2], underTest) + } + + @Test + void shouldNotFailOnOutOfBoundsIndex() { + List underTest = [1, 2, 3] + underTest = ListUtils.removeAt(underTest, -1) + Assert.assertEquals([1, 2, 3], underTest) + underTest = ListUtils.removeAt(underTest, 4) + Assert.assertEquals([1, 2, 3], underTest) + } + +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/utils/NotificationTriggerHelperTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/utils/NotificationTriggerHelperTest.groovy new file mode 100644 index 0000000..d16008b --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/utils/NotificationTriggerHelperTest.groovy @@ -0,0 +1,172 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils + +import hudson.model.Result as HudsonResult +import io.wcm.tooling.jenkins.pipeline.model.Result +import org.junit.Before +import org.junit.Test + +import static org.junit.Assert.* + +class NotificationTriggerHelperTest { + + NotificationTriggerHelper underTest + + @Before + void setUp() { + + } + + @Test + void shouldReturnCurrentResultForSuccess() { + underTest = new NotificationTriggerHelper(HudsonResult.SUCCESS) + assertEquals(Result.SUCCESS, underTest.getTrigger()) + + assertTrue(underTest.isSuccess()) + assertFalse(underTest.isFailure()) + assertFalse(underTest.isAborted()) + assertFalse(underTest.isFixed()) + assertFalse(underTest.isStillFailing()) + assertFalse(underTest.isUnstable()) + assertFalse(underTest.isStillUnstable()) + } + + @Test + void shouldReturnCurrentResultForFailure() { + underTest = new NotificationTriggerHelper(HudsonResult.FAILURE) + assertEquals(Result.FAILURE, underTest.getTrigger()) + + assertFalse(underTest.isSuccess()) + assertTrue(underTest.isFailure()) + assertFalse(underTest.isAborted()) + assertFalse(underTest.isFixed()) + assertFalse(underTest.isStillFailing()) + assertFalse(underTest.isUnstable()) + assertFalse(underTest.isStillUnstable()) + } + + @Test + void shouldReturnCurrentResultForUnstable() { + underTest = new NotificationTriggerHelper(HudsonResult.UNSTABLE, null) + assertEquals(Result.UNSTABLE, underTest.getTrigger()) + + assertFalse(underTest.isSuccess()) + assertFalse(underTest.isFailure()) + assertFalse(underTest.isAborted()) + assertFalse(underTest.isFixed()) + assertFalse(underTest.isStillFailing()) + assertTrue(underTest.isUnstable()) + assertFalse(underTest.isStillUnstable()) + } + + @Test + void shouldReturnCurrentResultForAbort() { + underTest = new NotificationTriggerHelper(HudsonResult.ABORTED, null) + assertEquals(Result.ABORTED, underTest.getTrigger()) + + assertFalse(underTest.isSuccess()) + assertFalse(underTest.isFailure()) + assertTrue(underTest.isAborted()) + assertFalse(underTest.isFixed()) + assertFalse(underTest.isStillFailing()) + assertFalse(underTest.isUnstable()) + assertFalse(underTest.isStillUnstable()) + } + + @Test + void shouldReturnStillUnstable() { + underTest = new NotificationTriggerHelper(Result.UNSTABLE.toString(), Result.UNSTABLE.toString()) + assertEquals(Result.STILL_UNSTABLE, underTest.getTrigger()) + + assertFalse(underTest.isSuccess()) + assertFalse(underTest.isFailure()) + assertFalse(underTest.isAborted()) + assertFalse(underTest.isFixed()) + assertFalse(underTest.isStillFailing()) + assertFalse(underTest.isUnstable()) + assertTrue(underTest.isStillUnstable()) + } + + @Test + void shouldReturnStillFailing() { + underTest = new NotificationTriggerHelper(HudsonResult.FAILURE.toString(), HudsonResult.FAILURE.toString()) + assertEquals(Result.STILL_FAILING, underTest.getTrigger()) + + assertFalse(underTest.isSuccess()) + assertFalse(underTest.isFailure()) + assertFalse(underTest.isAborted()) + assertFalse(underTest.isFixed()) + assertTrue(underTest.isStillFailing()) + assertFalse(underTest.isUnstable()) + assertFalse(underTest.isStillUnstable()) + } + + @Test + void shouldReplaceOneOccurrence() { + underTest = new NotificationTriggerHelper(HudsonResult.FAILURE.toString(), HudsonResult.FAILURE.toString()) + assertEquals('-STILL FAILING_', underTest.replaceEnvVar('-${NOTIFICATION_TRIGGER}_', underTest.getTrigger().toString())) + } + + @Test + void shouldReplaceMultipleOccurrences() { + underTest = new NotificationTriggerHelper(HudsonResult.FAILURE, HudsonResult.FAILURE) + assertEquals('-STILL FAILING_loremSTILL FAILING', underTest.replaceEnvVar('-${NOTIFICATION_TRIGGER}_lorem${NOTIFICATION_TRIGGER}', underTest.getTrigger().toString())) + } + + @Test + void shouldReturnFixed() { + assertFixed(HudsonResult.SUCCESS.toString(), HudsonResult.UNSTABLE.toString()) + assertFixed(HudsonResult.SUCCESS.toString(), HudsonResult.FAILURE.toString()) + } + + @Test + void shouldNotReturnFixed() { + assertNotFixed(HudsonResult.SUCCESS.toString(), HudsonResult.ABORTED.toString()) + assertNotFixed(HudsonResult.SUCCESS.toString(), HudsonResult.NOT_BUILT.toString()) + + assertNotFixed(HudsonResult.UNSTABLE.toString(), HudsonResult.ABORTED.toString()) + assertNotFixed(HudsonResult.ABORTED.toString(), HudsonResult.NOT_BUILT.toString()) + } + + @Test + void shouldReturnSuccessPerDefault() { + underTest = new NotificationTriggerHelper((String) null, null) + assertEquals(Result.SUCCESS, underTest.getTrigger()) + } + + protected assertFixed(String currentResult, String lastResult) { + underTest = new NotificationTriggerHelper(currentResult, lastResult) + assertEquals(String.format("expected fixed when result is changing from '%s' to %s", currentResult, lastResult), Result.FIXED, underTest.getTrigger()) + + assertFalse(String.format("'isSuccess' should return false when result is changing from '%s' to '%s'", lastResult, currentResult), underTest.isSuccess()) + assertFalse(String.format("'isFailure' should return false when result is changing from '%s' to '%s'", lastResult, currentResult), underTest.isFailure()) + assertFalse(String.format("'isAborted' should return false when result is changing from '%s' to '%s'", lastResult, currentResult), underTest.isAborted()) + assertTrue(String.format("'isFixed' should return true when result is changing from '%s' to %s", lastResult, currentResult), underTest.isFixed()) + assertFalse(String.format("'isStillFailing' should return false when result is changing from '%s' to %s", lastResult, currentResult), underTest.isStillFailing()) + assertFalse(String.format("'isUnstable' should return false when result is changing from '%s' to %s", lastResult, currentResult), underTest.isUnstable()) + assertFalse(String.format("'isStillUnstable' should return false when result is changing from '%s' to %s", lastResult, currentResult), underTest.isStillUnstable()) + } + + protected assertNotFixed(String currentResult, String lastResult) { + underTest = new NotificationTriggerHelper(currentResult, lastResult) + assertNotEquals(String.format("expected not fixed when result is changing from '%s' to %s", lastResult, currentResult), Result.FIXED, underTest.getTrigger()) + } +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcherGlobalMavenSettingsTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcherGlobalMavenSettingsTest.groovy new file mode 100644 index 0000000..244cc79 --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcherGlobalMavenSettingsTest.groovy @@ -0,0 +1,92 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils + +import io.wcm.testing.jenkins.pipeline.DSLTestBase +import io.wcm.tooling.jenkins.pipeline.managedfiles.ManagedFile +import io.wcm.tooling.jenkins.pipeline.managedfiles.ManagedFileConstants +import io.wcm.tooling.jenkins.pipeline.managedfiles.ManagedFileParser +import io.wcm.tooling.jenkins.pipeline.model.PatternMatchable +import io.wcm.tooling.jenkins.pipeline.utils.resources.JsonLibraryResource +import org.junit.Test + +import static org.junit.Assert.* + +class PatternMatcherGlobalMavenSettingsTest extends DSLTestBase { + + PatternMatcher underTest + + List managedFiles + + @Override + void setUp() throws Exception { + super.setUp() + underTest = new PatternMatcher() + JsonLibraryResource res = new JsonLibraryResource(this.dslMock.getMock(), ManagedFileConstants.GLOBAL_MAVEN_SETTINGS_PATH) + ManagedFileParser parser = new ManagedFileParser() + managedFiles = parser.parse(res.load()) + } + + @Test + void shouldFindSSHFormat() { + ManagedFile settings = underTest.getBestMatch("git@subdomain.domain.tld:group", this.managedFiles) + assertNotNull("resulting managed file is null", settings) + assertEquals("ssh-or-https-id", settings.getId(),) + assertEquals("ssh-or-https-name", settings.getName(),) + assertEquals("ssh-or-https-comment", settings.getComment(),) + assertEquals("subdomain.domain.tld[:/]group", settings.getPattern()) + } + + @Test + void shouldFindUrlFormat() { + ManagedFile settings = underTest.getBestMatch("https://subdomain.domain.tld/group", this.managedFiles) + assertNotNull("resulting managed file is null", settings) + assertEquals("ssh-or-https-id", settings.getId(),) + assertEquals("ssh-or-https-name", settings.getName(),) + assertEquals("ssh-or-https-comment", settings.getComment(),) + assertEquals("subdomain.domain.tld[:/]group", settings.getPattern()) + } + + @Test + void shouldFindBetterMatchWithSSHFormat() { + ManagedFile settings = underTest.getBestMatch("git@subdomain.domain.tld:group/project1", this.managedFiles) + assertNotNull("resulting managed file is null", settings) + assertEquals("ssh-or-https-better-match-id", settings.getId(),) + assertEquals("project1-better-match-name", settings.getName(),) + assertEquals("project1-better-match-comment", settings.getComment(),) + assertEquals("subdomain.domain.tld[:/]group/project1", settings.getPattern()) + } + + @Test + void shouldFindBetterMatchWithURLFormat() { + ManagedFile settings = underTest.getBestMatch("https://subdomain.domain.tld/group/project1", this.managedFiles) + assertNotNull("resulting managed file is null", settings) + assertEquals("ssh-or-https-better-match-id", settings.getId(),) + assertEquals("project1-better-match-name", settings.getName(),) + assertEquals("project1-better-match-comment", settings.getComment(),) + assertEquals("subdomain.domain.tld[:/]group/project1", settings.getPattern()) + } + + @Test + void shouldFindNothing() { + ManagedFile settings = underTest.getBestMatch("should-not-find-me", this.managedFiles) + assertNull("There should be no found file", settings) + } +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcherMavenSettingsTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcherMavenSettingsTest.groovy new file mode 100644 index 0000000..bb2f9dc --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcherMavenSettingsTest.groovy @@ -0,0 +1,93 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils + +import io.wcm.testing.jenkins.pipeline.DSLTestBase +import io.wcm.tooling.jenkins.pipeline.managedfiles.ManagedFile +import io.wcm.tooling.jenkins.pipeline.managedfiles.ManagedFileConstants +import io.wcm.tooling.jenkins.pipeline.managedfiles.ManagedFileParser +import io.wcm.tooling.jenkins.pipeline.model.PatternMatchable +import io.wcm.tooling.jenkins.pipeline.utils.resources.JsonLibraryResource +import org.junit.Test + +import static org.junit.Assert.* + +class PatternMatcherMavenSettingsTest extends DSLTestBase { + + PatternMatcher underTest + + List managedFiles + + @Override + void setUp() throws Exception { + super.setUp() + underTest = new PatternMatcher() + JsonLibraryResource res = new JsonLibraryResource(this.dslMock.getMock(), ManagedFileConstants.MAVEN_SETTINS_PATH) + ManagedFileParser parser = new ManagedFileParser() + managedFiles = parser.parse(res.load()) + } + + @Test + void shouldFindSSHFormat() { + ManagedFile settings = underTest.getBestMatch("git@subdomain.domain.tld:group1", this.managedFiles) + assertNotNull("resulting managed file is null", settings) + + assertEquals("group1-maven-settings-id", settings.getId()) + assertEquals("group1-maven-settings-name", settings.getName()) + assertEquals("group1-maven-settings-comment", settings.getComment()) + assertEquals("subdomain.domain.tld[:/]group1", settings.getPattern()) + } + + @Test + void shouldFindUrlFormat() { + ManagedFile settings = underTest.getBestMatch("https://subdomain.domain.tld/group1", this.managedFiles) + assertNotNull("resulting managed file is null", settings) + assertEquals("group1-maven-settings-id", settings.getId()) + assertEquals("group1-maven-settings-name", settings.getName()) + assertEquals("group1-maven-settings-comment", settings.getComment()) + assertEquals("subdomain.domain.tld[:/]group1", settings.getPattern()) + } + + @Test + void shouldFindBetterMatchWithSSHFormat() { + ManagedFile settings = underTest.getBestMatch("git@subdomain.domain.tld:group1/project1", this.managedFiles) + assertNotNull("resulting managed file is null", settings) + assertEquals("group1-project1-maven-settings-id", settings.getId()) + assertEquals("group1-project1-maven-settings-name", settings.getName()) + assertEquals("group1-project1-maven-settings-comment", settings.getComment()) + assertEquals("subdomain.domain.tld[:/]group1/project1", settings.getPattern()) + } + + @Test + void shouldFindBetterMatchWithURLFormat() { + ManagedFile settings = underTest.getBestMatch("https://subdomain.domain.tld/group1/project2", this.managedFiles) + assertNotNull("resulting managed file is null", settings) + assertEquals("group1-project2-maven-settings-id", settings.getId(),) + assertEquals("group1-project2-maven-settings-name", settings.getName(),) + assertEquals("group1-project2-maven-settings-comment", settings.getComment(),) + assertEquals("subdomain.domain.tld[:/]group1/project2", settings.getPattern()) + } + + @Test + void shouldFindNothing() { + ManagedFile settings = underTest.getBestMatch("should-not-find-me", this.managedFiles) + assertNull("There should be no found file", settings) + } +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcherSCMCredentialTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcherSCMCredentialTest.groovy new file mode 100644 index 0000000..f330957 --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcherSCMCredentialTest.groovy @@ -0,0 +1,61 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils + +import io.wcm.testing.jenkins.pipeline.DSLTestBase +import io.wcm.tooling.jenkins.pipeline.credentials.Credential +import io.wcm.tooling.jenkins.pipeline.credentials.CredentialConstants +import io.wcm.tooling.jenkins.pipeline.credentials.CredentialParser +import io.wcm.tooling.jenkins.pipeline.model.PatternMatchable +import io.wcm.tooling.jenkins.pipeline.utils.resources.JsonLibraryResource +import org.junit.Test + +import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertNotNull + +class PatternMatcherSCMCredentialTest extends DSLTestBase { + + PatternMatcher underTest + + List credentials + + @Override + void setUp() throws Exception { + super.setUp() + underTest = new PatternMatcher() + JsonLibraryResource res = new JsonLibraryResource(this.dslMock.getMock(), CredentialConstants.SCM_CREDENTIALS_PATH) + CredentialParser parser = new CredentialParser() + credentials = parser.parse(res.load()) + } + + @Test + void shouldReturnConfigForSSH() throws Exception { + Credential foundCredential = underTest.getBestMatch("git@git-ssh.domain.tld/group1/project1", credentials) + assertNotNull("SCMCredentialProvider should find one match", foundCredential) + assertEquals("ssh-git-credentials-id", foundCredential.getId()) + } + + @Test + void shouldReturnConfigForHTTP() throws Exception { + Credential foundCredential = underTest.getBestMatch("https://git-http.domain.tld", credentials) + assertNotNull("SCMCredentialProvider should find one match", foundCredential) + assertEquals("https-git-credentials-id", foundCredential.getId()) + } +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcherTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcherTest.groovy new file mode 100644 index 0000000..90e4537 --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcherTest.groovy @@ -0,0 +1,74 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils + +import io.wcm.testing.jenkins.pipeline.DSLTestBase +import io.wcm.tooling.jenkins.pipeline.credentials.Credential +import io.wcm.tooling.jenkins.pipeline.model.PatternMatchable +import org.junit.Before +import org.junit.Test + +import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertNotNull + +class PatternMatcherTest extends DSLTestBase { + + PatternMatcher underTest + + @Override + @Before + void setUp() { + super.setUp() + underTest = new PatternMatcher() + } + + @Test + void shouldFindMatch() { + PatternMatchable result = underTest.getBestMatch("pattern1", this.createTestCredentials()) + assertNotNull("The CredentialUtilTest should find one ManagedFile", result) + assertEquals("pattern1-id", result.getId()) + } + + @Test + void shouldFindFirstMatch() { + PatternMatchable result = underTest.getBestMatch("pattern", this.createTestCredentials()) + assertNotNull("The CredentialUtilTest should find one ManagedFile", result) + assertEquals("pattern-id", result.getId()) + } + + @Test + void shouldFindBetterMatch() { + PatternMatchable result = underTest.getBestMatch("pattern-better", this.createTestCredentials()) + assertNotNull("The CredentialUtilTest should find one ManagedFile", result) + assertEquals("i-am-a-better-match-id", result.getId()) + } + + List createTestCredentials() { + List files = new ArrayList() + files.push(new Credential("pattern1", "pattern1-id", "pattern1-name")) + files.push(new Credential("pattern2", "pattern2-id", "pattern2-name")) + files.push(new Credential("pattern", "pattern-id", "pattern2-name")) + files.push(new Credential("pattern", "i-should-not-be-returned-id", "i-should-not-be-returned-name")) + files.push(new Credential("pattern-b", "i-am-a-better-match-id", "i-am-a-better-match-name")) + + return files + } + +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/utils/TypeUtilsTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/utils/TypeUtilsTest.groovy new file mode 100644 index 0000000..2ff97a0 --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/utils/TypeUtilsTest.groovy @@ -0,0 +1,66 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils + +import org.junit.Before +import org.junit.Test + +import static org.junit.Assert.assertFalse +import static org.junit.Assert.assertTrue + +class TypeUtilsTest { + + TypeUtils underTest + + @Before + void setUp() { + underTest = new TypeUtils() + } + + @Test + void isListShouldReturnTrue() { + assertTrue(underTest.isList(new ArrayList())) + } + + @Test + void isListShouldReturnFalse() { + assertFalse(underTest.isList("")) + assertFalse(underTest.isList(true)) + assertFalse(underTest.isList(false)) + assertFalse(underTest.isList(1)) + assertFalse(underTest.isList([:])) + } + + @Test + void isMapShouldReturnTrue() { + assertTrue(underTest.isMap([:])) + assertTrue(underTest.isMap(new HashMap())) + assertTrue(underTest.isMap(new LinkedHashMap())) + } + + @Test + void isMapShouldReturnFalse() { + assertFalse(underTest.isMap("")) + assertFalse(underTest.isMap(true)) + assertFalse(underTest.isMap(false)) + assertFalse(underTest.isMap(1)) + assertFalse(underTest.isMap(new ArrayList())) + } +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/utils/logging/LogLevelTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/utils/logging/LogLevelTest.groovy new file mode 100644 index 0000000..4a76fa0 --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/utils/logging/LogLevelTest.groovy @@ -0,0 +1,42 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils.logging + +import org.junit.Test + +import static org.junit.Assert.assertEquals + +class LogLevelTest { + + @Test + void shouldReturnDefaultLogLevel() { + assertEquals(LogLevel.INFO, LogLevel.fromInteger(-1)) + assertEquals(LogLevel.INFO, LogLevel.fromInteger(9546131)) + assertEquals(LogLevel.INFO, LogLevel.fromString("unknown")) + } + + @Test + void shouldReturnCorrectValue() { + assertEquals(0, LogLevel.ALL.getLevel()) + assertEquals(4, LogLevel.INFO.getLevel()) + assertEquals(Integer.MAX_VALUE, LogLevel.NONE.getLevel()) + } + +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/utils/logging/LoggerCpsScriptTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/utils/logging/LoggerCpsScriptTest.groovy new file mode 100644 index 0000000..98cacf8 --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/utils/logging/LoggerCpsScriptTest.groovy @@ -0,0 +1,239 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils.logging + +import io.wcm.testing.jenkins.pipeline.CpsScriptTestBase +import org.hamcrest.CoreMatchers +import org.junit.Before +import org.junit.Test + +import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertThat + +class LoggerCpsScriptTest extends CpsScriptTestBase { + + Logger underTest = null + + @Before + @Override + void setUp() throws Exception { + super.setUp() + underTest = new Logger(this) + } + + @Test + void shouldRespectLogLvlNone() throws Exception { + Logger.setLevel(LogLevel.ALL) + Logger.setLevel(LogLevel.NONE) + this.logAllLevels("shouldRespectLogLvlNone") + assertLogSize(0) + } + + @Test + void shouldRespectLogLvlFatal() throws Exception { + Logger.setLevel(LogLevel.FATAL) + this.logAllLevels("shouldRespectLogLvlFatal") + assertLogSize(1) + assertLogContains("[FATAL]") + } + + @Test + void shouldRespectLogLvlError() throws Exception { + Logger.setLevel(LogLevel.ERROR) + this.logAllLevels("shouldRespectLogLvlError") + assertLogSize(2) + assertLogContains("[FATAL]") + assertLogContains("[ERROR]") + } + + @Test + void shouldRespectLogLvlWarn() throws Exception { + Logger.setLevel(LogLevel.WARN) + this.logAllLevels("shouldRespectLogLvlWarn") + assertLogSize(3) + assertLogContains("[FATAL]") + assertLogContains("[ERROR]") + assertLogContains("[WARN]") + } + + @Test + void shouldRespectLogLvlInfo() throws Exception { + Logger.setLevel(LogLevel.INFO) + LogLevel lvl = Logger.getLevel() + this.logAllLevels("shouldRespectLogLvlInfo") + + assertLogSize(4) + + assertLogContains("[FATAL]") + assertLogContains("[ERROR]") + assertLogContains("[WARN]") + assertLogContains("[INFO]") + } + + @Test + void shouldRespectLogLvlDebug() throws Exception { + Logger.setLevel(LogLevel.DEBUG) + this.logAllLevels("shouldRespectLogLvlDebug") + assertLogSize(5) + assertLogContains("[FATAL]") + assertLogContains("[ERROR]") + assertLogContains("[WARN]") + assertLogContains("[INFO]") + assertLogContains("[DEBUG]") + } + + @Test + void shouldRespectLogLvlTrace() throws Exception { + Logger.setLevel(LogLevel.TRACE) + this.logAllLevels("shouldRespectLogLvlTrace") + assertLogSize(6) + assertLogContains("[FATAL]") + assertLogContains("[ERROR]") + assertLogContains("[WARN]") + assertLogContains("[INFO]") + assertLogContains("[DEBUG]") + assertLogContains("[TRACE]") + } + + @Test + void shouldLogObject() throws Exception { + Logger.setLevel(LogLevel.ALL) + underTest.trace("my trace message", this) + underTest.debug("my debug message", this) + underTest.info("my info message", this) + underTest.warn("my warn message", this) + underTest.error("my error message", this) + underTest.fatal("my fatal message", this) + + assertLogSize(6) + assertEquals("[TRACE] io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerCpsScriptTest : my trace message -> (io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerCpsScriptTest) " + this.toString(), getLogMessageAt(0)) + assertEquals("[DEBUG] io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerCpsScriptTest : my debug message -> (io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerCpsScriptTest) " + this.toString(), getLogMessageAt(1)) + assertEquals("[INFO] io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerCpsScriptTest : my info message -> (io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerCpsScriptTest) " + this.toString(), getLogMessageAt(2)) + assertEquals("[WARN] io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerCpsScriptTest : my warn message -> (io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerCpsScriptTest) " + this.toString(), getLogMessageAt(3)) + assertEquals("[ERROR] io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerCpsScriptTest : my error message -> (io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerCpsScriptTest) " + this.toString(), getLogMessageAt(4)) + assertEquals("[FATAL] io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerCpsScriptTest : my fatal message -> (io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerCpsScriptTest) " + this.toString(), getLogMessageAt(5)) + } + + @Test + void shouldReturnConfiguredLevel() { + Logger.setLevel(LogLevel.ALL) + assertEquals(LogLevel.ALL, Logger.getLevel()) + Logger.setLevel(LogLevel.ERROR) + assertEquals(LogLevel.ERROR, Logger.getLevel()) + } + + @Test + void shouldUseConfiguredName() { + Logger.setLevel(LogLevel.ALL) + Logger testLogger = new Logger("i have a custom logger name") + testLogger.info("i should have a custom logger name") + assertEquals("[INFO] i have a custom logger name : i should have a custom logger name", getLogMessageAt(0)) + } + + /*@Test + void shouldNotFailWhenNotInitialized() { + Logger.setLevel(LogLevel.ALL) + Logger.init(null, null) + logAllLevels("shouldNotFailWhenNotInitialized") + }*/ + + @Test + void shouldInstanciateWithIntegerAsLogLevel() { + Logger.setLevel(LogLevel.ALL) + Logger.init(this.script, 0) + logAllLevels("shouldInstanciateWithIntegerAsLogLevel") + } + + @Test + void shouldInstanciateWithStringAsLogLevel() { + Logger.setLevel(LogLevel.ALL) + Logger.init(this.script, "INFO") + logAllLevels("shouldInstanciateWithStringAsLogLevel") + } + + @Test + void shouldChangeLogLevelOnReinitialization() { + Logger.init(this.script, "WARN") + logAllLevels("shouldChangeLogLevelOnReinitialization - 1") + assertEquals(LogLevel.WARN, Logger.getLevel()) + assertLogSize(3) + Logger.setLevel(LogLevel.FATAL) + logAllLevels("shouldChangeLogLevelOnReinitialization - 2") + assertEquals(LogLevel.FATAL, Logger.getLevel()) + assertLogSize(4) + } + + @Test + void shouldLogWithColors() { + Logger.setLevel(LogLevel.TRACE) + this.script.setEnv("TERM", "xterm") + logAllLevels("shouldLogWithColors") + assertLogContains("\u001B[1;38;5;8m[TRACE]\u001B[0m") + assertLogContains("\u001B[1;38;5;12m[DEBUG]\u001B[0m") + assertLogContains("\u001B[1;38;5;0m[INFO]\u001B[0m") + assertLogContains("\u001B[1;38;5;202m[WARN]\u001B[0m") + assertLogContains("\u001B[1;38;5;5m[ERROR]\u001B[0m") + assertLogContains("\u001B[1;38;5;9m[FATAL]\u001B[0m") + } + + @Test + void shouldSwitchColorOutputInstantly() { + Logger.setLevel(LogLevel.TRACE) + this.script.setEnv("TERM", null) + underTest.info("without color 1") + this.script.setEnv("TERM", "xterm") + underTest.info("with color") + // remove env var + this.script.setEnv("TERM", null) + underTest.info("without color 2") + + List expectedLogMessages = [ + "[INFO] io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerCpsScriptTest : without color 1", + "\u001B[1;38;5;0m[INFO]\u001B[0m io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerCpsScriptTest : with color", + "[INFO] io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerCpsScriptTest : without color 2" + ] + + assertEquals(expectedLogMessages, this.script.getDslMock().getLogMessages()) + } + + void logAllLevels(String testName) { + underTest.trace("trace ${testName}") + underTest.debug("debug ${testName}") + underTest.info("info ${testName}") + underTest.warn("warn ${testName}") + underTest.error("error ${testName}") + underTest.fatal("fatal ${testName}") + } + + void assertLogSize(Integer logSize) { + assertEquals(logSize, this.script.getLogMessages().size()) + } + + String getLogMessageAt(Integer idx) { + return this.script.getLogMessages().get(idx) + } + + void assertLogContains(String expected) { + String logMessages = this.script.getLogMessages().toString() + assertThat(logMessages, CoreMatchers.containsString(expected)) + } + +} + diff --git a/test/io/wcm/tooling/jenkins/pipeline/utils/logging/LoggerTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/utils/logging/LoggerTest.groovy new file mode 100644 index 0000000..4f977e0 --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/utils/logging/LoggerTest.groovy @@ -0,0 +1,205 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils.logging + +import io.wcm.testing.jenkins.pipeline.DSLTestBase +import org.hamcrest.CoreMatchers +import org.jenkinsci.plugins.workflow.cps.DSL +import org.junit.Before +import org.junit.Test + +import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertThat + +class LoggerTest extends DSLTestBase { + Logger underTest = null + + @Before + @Override + void setUp() throws Exception { + super.setUp() + underTest = new Logger(this) + } + + @Test + void shouldRespectLogLvlNone() throws Exception { + Logger.setLevel(LogLevel.ALL) + Logger.setLevel(LogLevel.NONE) + this.logAllLevels() + assertLogSize(0) + } + + @Test + void shouldRespectLogLvlFatal() throws Exception { + Logger.setLevel(LogLevel.FATAL) + this.logAllLevels() + assertLogSize(1) + assertLogContains("[FATAL]") + } + + @Test + void shouldRespectLogLvlError() throws Exception { + Logger.setLevel(LogLevel.ERROR) + this.logAllLevels() + assertLogSize(2) + assertLogContains("[FATAL]") + assertLogContains("[ERROR]") + } + + @Test + void shouldRespectLogLvlWarn() throws Exception { + Logger.setLevel(LogLevel.WARN) + this.logAllLevels() + assertLogSize(3) + assertLogContains("[FATAL]") + assertLogContains("[ERROR]") + assertLogContains("[WARN]") + } + + @Test + void shouldRespectLogLvlInfo() throws Exception { + Logger.setLevel(LogLevel.INFO) + this.logAllLevels() + assertLogSize(4) + assertLogContains("[FATAL]") + assertLogContains("[ERROR]") + assertLogContains("[WARN]") + assertLogContains("[INFO]") + } + + @Test + void shouldRespectLogLvlDebug() throws Exception { + Logger.setLevel(LogLevel.DEBUG) + this.logAllLevels() + assertLogSize(5) + assertLogContains("[FATAL]") + assertLogContains("[ERROR]") + assertLogContains("[WARN]") + assertLogContains("[INFO]") + assertLogContains("[DEBUG]") + } + + @Test + void shouldRespectLogLvlTrace() throws Exception { + Logger.setLevel(LogLevel.TRACE) + this.logAllLevels() + assertLogSize(6) + assertLogContains("[FATAL]") + assertLogContains("[ERROR]") + assertLogContains("[WARN]") + assertLogContains("[INFO]") + assertLogContains("[DEBUG]") + assertLogContains("[TRACE]") + } + + @Test + void shouldLogObject() throws Exception { + Logger.setLevel(LogLevel.ALL) + underTest.trace("my message", this) + underTest.debug("my message", this) + underTest.info("my message", this) + underTest.warn("my message", this) + underTest.error("my message", this) + underTest.fatal("my message", this) + + assertLogSize(6) + assertEquals("[TRACE] io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerTest : my message -> (io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerTest) " + this.toString(), getLogMessageAt(0)) + assertEquals("[DEBUG] io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerTest : my message -> (io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerTest) " + this.toString(), getLogMessageAt(1)) + assertEquals("[INFO] io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerTest : my message -> (io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerTest) " + this.toString(), getLogMessageAt(2)) + assertEquals("[WARN] io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerTest : my message -> (io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerTest) " + this.toString(), getLogMessageAt(3)) + assertEquals("[ERROR] io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerTest : my message -> (io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerTest) " + this.toString(), getLogMessageAt(4)) + assertEquals("[FATAL] io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerTest : my message -> (io.wcm.tooling.jenkins.pipeline.utils.logging.LoggerTest) " + this.toString(), getLogMessageAt(5)) + } + + @Test + void shouldReturnConfiguredLevel() { + Logger.setLevel(LogLevel.ALL) + assertEquals(LogLevel.ALL, Logger.getLevel()) + Logger.setLevel(LogLevel.ERROR) + assertEquals(LogLevel.ERROR, Logger.getLevel()) + } + + @Test + void shouldUseConfiguredName() { + Logger.setLevel(LogLevel.ALL) + Logger testLogger = new Logger("i have a custom logger name") + testLogger.info("i should have a custom logger name") + assertEquals("[INFO] i have a custom logger name : i should have a custom logger name", getLogMessageAt(0)) + } + + /*@Test + void shouldNotFailWhenNotInitialized() { + Logger.setLevel(LogLevel.ALL) + Logger.init((DSL) null, null) + logAllLevels() + }*/ + + @Test + void shouldInstanciateWithIntegerAsLogLevel() { + Logger.setLevel(LogLevel.ALL) + Logger.init(this.dslMock.getMock(), 0) + logAllLevels() + } + + @Test + void shouldInstanciateWithStringAsLogLevel() { + Logger.setLevel(LogLevel.ALL) + Logger.init(this.dslMock.getMock(), "INFO") + logAllLevels() + } + + @Test + void shouldChangeLogLevelOnReinitialization() { + Logger.init(this.dslMock.getMock(), "WARN") + logAllLevels() + assertEquals(LogLevel.WARN, Logger.getLevel()) + assertLogSize(3) + Logger.setLevel(LogLevel.FATAL) + logAllLevels() + assertEquals(LogLevel.FATAL, Logger.getLevel()) + assertLogSize(4) + } + + void logAllLevels() { + underTest.trace("trace") + underTest.debug("debug") + underTest.info("info") + underTest.warn("warn") + underTest.error("error") + underTest.fatal("fatal") + } + + void assertLogSize(Integer logSize) { + logSize = logSize + 1 + assertEquals(logSize, dslMock.getLogMessages().size()) + assertLogContains("Deprecated") + } + + void assertLogContains(String expected) { + String logMessages = this.dslMock.getLogMessages().toString() + assertThat(logMessages, CoreMatchers.containsString(expected)) + } + + String getLogMessageAt(Integer idx) { + // increase position since deprecation warning is present + idx++ + return dslMock.getLogMessages().get(idx) + } +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/utils/maps/MapUtilsTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/utils/maps/MapUtilsTest.groovy new file mode 100644 index 0000000..d974de2 --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/utils/maps/MapUtilsTest.groovy @@ -0,0 +1,228 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils.maps + +import org.junit.Test + +import static org.junit.Assert.assertEquals + +class MapUtilsTest { + + @Test + void shouldReturnEmptyMap() { + Map expected = [:] + Map actual = MapUtils.merge() + assertEquals(expected, actual) + } + + @Test + void shouldReturnInputMap() { + Map expected = [ + node1: [ + subnode11: [ + prop111: "value111", + prop112: "value112", + ], + prop1 : 1 + ], + node2: [ + prop2 : 2, + subnode21: [ + prop21: "value21" + ] + ] + ] + Map actual = MapUtils.merge(expected) + assertEquals(expected, actual) + } + + @Test + void shouldMergeTwoMaps() { + Map map1 = [ + node1: [ + subnode11: [ + prop111: "value111", + prop112: "value112", + ], + prop1 : 1 + ], + node2: [ + prop1 : 21, + subnode21: [ + prop21: "value21" + ] + ] + ] + Map map2 = [ + node1: [ + subnode11: [ + prop111: "value111NEW", + prop113: "value113" + ], + prop2 : 12 + ], + node2: [ + prop1: "21NEW", + ] + ] + + Map expected = [ + node1: [ + subnode11: [ + prop111: "value111NEW", + prop112: "value112", + prop113: "value113" + ], + prop1 : 1, + prop2 : 12 + ], + node2: [ + prop1 : "21NEW", + subnode21: [ + prop21: "value21" + ] + ] + ] + + Map actual = MapUtils.merge(map1, map2) + assertEquals(expected, actual) + } + + @Test + void shouldMergeThreeMaps() { + Map map1 = [ + node1 : [ + subnode: [prop: "map1node1prop"] + ], + node2 : [ + subnode: [prop1: "value1"] + ], + map1prop: "value1" + ] + Map map2 = [ + node1 : [ + subnode: [prop: "map2node1prop"] + ], + node2 : [ + subnode: [prop2: "value2"] + ], + map2prop: "value2" + ] + Map map3 = [ + node1 : [ + subnode: [prop: "map3node1prop"] + ], + node2 : [ + subnode: [prop3: "value3"] + ], + map3prop: "value3" + ] + + Map expected = [ + node1 : [ + subnode: [prop: "map3node1prop"] + ], + node2 : [ + subnode: [ + prop1: "value1", + prop2: "value2", + prop3: "value3", + ] + ], + map1prop: "value1", + map2prop: "value2", + map3prop: "value3" + ] + + Map actual = MapUtils.merge(map1, map2, map3) + assertEquals(expected, actual) + } + + @Test + void shouldMergeTwoDeepNestedMaps() { + Map map1 = [l1: [l2: [l3: [l4: [l5: [l5p1: "l5v1"]], l4p1: "l4v1", l4m1p1: "l4m1v1"]]]] + Map map2 = [l1: [l2: [l3: [l4: [l5: [l5p1: "l5v1NEW"]], l4p1: "l4v1NEW", l4m2p1: "l4m2v1"]]]] + Map expected = [l1: [l2: [l3: [l4: [l5: [l5p1: "l5v1NEW"]], l4p1: "l4v1NEW", l4m1p1: "l4m1v1", l4m2p1: "l4m2v1"]]]] + Map actual = MapUtils.merge(map1, map2) + assertEquals(expected, actual) + } + + @Test + void shouldMergeSimpleLists() { + Map map1 = [maven: [goals: ["clean", "install"]]] + Map map2 = [maven: [goals: ["install"]]] + Map map3 = [maven: [goals: ["site"]]] + + Map expected = [maven: [goals: ["clean", "install", "site"]]] + Map actual = MapUtils.merge(map1, map2, map3) + assertEquals(expected, actual) + } + + @Test + void shouldMergeMapLists() { + Map map1 = [ + node1 : [ + subnode: [prop: [[name: "map1"], [name: "map2"]]] + ], + node2 : [ + subnode: [prop1: "value1"] + ], + map1prop: "value1" + ] + Map map2 = [ + node1 : [ + subnode: [prop: [[name: "map2"], [name: "map3"]]] + ], + node2 : [ + subnode: [prop2: "value2"] + ], + map2prop: "value2" + ] + Map map3 = [ + node1 : [ + subnode: [prop: [[name: "map1"], [name: "map3"], [name: "map4"]]] + ], + node2 : [ + subnode: [prop3: "value3"] + ], + map3prop: "value3" + ] + + Map expected = [ + node1 : [ + subnode: [prop: [[name: "map1"], [name: "map2"], [name: "map3"], [name: "map4"]]] + ], + node2 : [ + subnode: [ + prop1: "value1", + prop2: "value2", + prop3: "value3", + ] + ], + map1prop: "value1", + map2prop: "value2", + map3prop: "value3" + ] + + Map actual = MapUtils.merge(map1, map2, map3) + assertEquals(expected, actual) + } + +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/utils/resources/JsonLibraryResourceTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/utils/resources/JsonLibraryResourceTest.groovy new file mode 100644 index 0000000..79a094c --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/utils/resources/JsonLibraryResourceTest.groovy @@ -0,0 +1,55 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils.resources + +import hudson.AbortException +import io.wcm.testing.jenkins.pipeline.DSLTestBase +import net.sf.json.JSONException +import net.sf.json.JSONObject +import org.junit.Test + +import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertTrue + +class JsonLibraryResourceTest extends DSLTestBase { + + JsonLibraryResource underTest + + @Test + void shouldLoadExistingJsonResource() { + underTest = new JsonLibraryResource(this.dslMock.getMock(), 'example-resource.json') + JSONObject actual = underTest.load() + assertTrue(actual.containsKey('foo')) + assertEquals("bar", actual.get('foo')) + } + + @Test(expected = AbortException.class) + void shouldFailOnNonExistingJsonResource() { + underTest = new JsonLibraryResource(this.dslMock.getMock(), 'notexisting.json') + underTest.load() + } + + @Test(expected = JSONException.class) + void shouldFailOnInvalidJson() { + underTest = new JsonLibraryResource(this.dslMock.getMock(), 'invalid.json') + underTest.load() + } + +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/utils/resources/LibraryResourceTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/utils/resources/LibraryResourceTest.groovy new file mode 100644 index 0000000..15c866a --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/utils/resources/LibraryResourceTest.groovy @@ -0,0 +1,68 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.utils.resources + +import hudson.AbortException +import io.wcm.testing.jenkins.pipeline.DSLTestBase +import org.junit.Assert +import org.junit.Test +import org.mockito.invocation.InvocationOnMock +import org.mockito.stubbing.Answer + +import static org.mockito.ArgumentMatchers.any +import static org.mockito.ArgumentMatchers.eq +import static org.mockito.Mockito.when + +class LibraryResourceTest extends DSLTestBase { + + LibraryResource underTest + + @Test + void shouldLoadExistingResource() { + underTest = new LibraryResource(this.dslMock.getMock(), 'example-resource.txt') + String actual = underTest.load() + Assert.assertEquals("foobar", actual) + } + + @Test(expected = AbortException.class) + void shouldFailOnNonExistingResource() { + underTest = new LibraryResource(this.dslMock.getMock(), 'notexisting.txt') + underTest.load() + } + + @Test + void shouldReturnCachedResource() { + underTest = new LibraryResource(this.dslMock.getMock(), 'example-resource.txt') + // load the first time + String actual1 = underTest.load() + + // return modified resouce on next call + when(dslMock.getMock().invokeMethod(eq("libraryResource"), any())).then(new Answer() { + @Override + String answer(InvocationOnMock invocationOnMock) throws Throwable { + return "you should not see me" + } + }) + + String actual2 = underTest.load() + Assert.assertEquals("foobar", actual1) + Assert.assertEquals("foobar", actual2) + } +} diff --git a/test/io/wcm/tooling/jenkins/pipeline/versioning/ComparableVersionTest.groovy b/test/io/wcm/tooling/jenkins/pipeline/versioning/ComparableVersionTest.groovy new file mode 100644 index 0000000..b0d3132 --- /dev/null +++ b/test/io/wcm/tooling/jenkins/pipeline/versioning/ComparableVersionTest.groovy @@ -0,0 +1,214 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package io.wcm.tooling.jenkins.pipeline.versioning + +import org.junit.Test + +import static org.junit.Assert.* + +class ComparableVersionTest { + + private static final List VERSIONS_QUALIFIER = + ["1-alpha2snapshot", "1-alpha2", "1-alpha-123", "1-beta-2", "1-beta123", "1-m2", "1-m11", "1-rc", "1-cr2", + "1-rc123", "1-SNAPSHOT", "1", "1-sp", "1-sp2", "1-sp123", "1-abc", "1-def", "1-pom-1", "1-1-snapshot", + "1-1", "1-2", "1-123"] + + private static final List VERSIONS_NUMBER = + ["2.0", "2-1", "2.0.a", "2.0.0.a", "2.0.2", "2.0.123", "2.1.0", "2.1-a", "2.1b", "2.1-c", "2.1-1", "2.1.0.1", + "2.2", "2.123", "11.a2", "11.a11", "11.b2", "11.b11", "11.m2", "11.m11", "11", "11.a", "11b", "11c", "11m"] + + + private void checkVersionsOrder(List versions) { + Comparable[] c = new Comparable[versions.size()] + for (int i = 0; i < versions.size(); i++) { + c[i] = newComparable(versions[i]) + } + + for (int i = 1; i < versions.size(); i++) { + Comparable low = c[i - 1] + for (int j = i; j < versions.size(); j++) { + Comparable high = c[j] + assertTrue("expected " + low + " < " + high, low.compareTo(high) < 0) + assertTrue("expected " + high + " > " + low, high.compareTo(low) > 0) + } + } + } + + @Test + void testVersionsQualifier() { + checkVersionsOrder(VERSIONS_QUALIFIER) + } + + @Test + void testVersionsNumber() { + checkVersionsOrder(VERSIONS_NUMBER) + } + + @Test + void shouldCompareVersionsInOrder() { + assertVersionsOrder("1", "2") + assertVersionsOrder("1.5", "2") + assertVersionsOrder("1", "2.5") + assertVersionsOrder("1.0", "1.1") + assertVersionsOrder("1.1", "1.2") + assertVersionsOrder("1.0.0", "1.1") + assertVersionsOrder("1.0.1", "1.1") + assertVersionsOrder("1.1", "1.2.0") + + assertVersionsOrder("1.0-alpha-1", "1.0") + assertVersionsOrder("1.0-alpha-1", "1.0-alpha-2") + assertVersionsOrder("1.0-alpha-1", "1.0-beta-1") + + assertVersionsOrder("1.0-beta-1", "1.0-SNAPSHOT") + assertVersionsOrder("1.0-SNAPSHOT", "1.0") + assertVersionsOrder("1.0-alpha-1-SNAPSHOT", "1.0-alpha-1") + + assertVersionsOrder("1.0", "1.0-1") + assertVersionsOrder("1.0-1", "1.0-2") + assertVersionsOrder("1.0.0", "1.0-1") + + assertVersionsOrder("2.0-1", "2.0.1") + assertVersionsOrder("2.0.1-klm", "2.0.1-lmn") + assertVersionsOrder("2.0.1", "2.0.1-xyz") + + assertVersionsOrder("2.0.1", "2.0.1-123") + assertVersionsOrder("2.0.1-xyz", "2.0.1-123") + } + + @Test + void shouldReturnCorrectCanonicalizedVersion() { + ComparableVersion v1 = new ComparableVersion("1.2.3") + assertEquals("1.2.3", v1.getCanonical()) + } + + @Test + void versionsShouldBeEqual() { + newComparable("1.0-alpha"); + assertEqualVersion("1", "1") + assertEqualVersion("1", "1.0") + assertEqualVersion("1", "1.0.0") + assertEqualVersion("1.0", "1.0.0") + assertEqualVersion("1", "1-0") + assertEqualVersion("1", "1.0-0") + assertEqualVersion("1.0", "1.0-0") + + assertEqualVersion("1a", "1-a") + assertEqualVersion("1a", "1.0-a") + assertEqualVersion("1a", "1.0.0-a") + assertEqualVersion("1.0a", "1-a") + assertEqualVersion("1.0.0a", "1-a") + assertEqualVersion("1x", "1-x") + assertEqualVersion("1x", "1.0-x") + assertEqualVersion("1x", "1.0.0-x") + assertEqualVersion("1.0x", "1-x") + assertEqualVersion("1.0.0x", "1-x") + + // aliases + assertEqualVersion("1ga", "1") + assertEqualVersion("1final", "1") + assertEqualVersion("1cr", "1rc") + + // special "aliases" a, b and m for alpha, beta and milestone + assertEqualVersion("1a1", "1-alpha-1") + assertEqualVersion("1b2", "1-beta-2") + assertEqualVersion("1m3", "1-milestone-3") + + // case insensitive + assertEqualVersion("1X", "1x") + assertEqualVersion("1A", "1a") + assertEqualVersion("1B", "1b") + assertEqualVersion("1M", "1m") + assertEqualVersion("1Ga", "1") + assertEqualVersion("1GA", "1") + assertEqualVersion("1Final", "1") + assertEqualVersion("1FinaL", "1") + assertEqualVersion("1FINAL", "1") + assertEqualVersion("1Cr", "1Rc") + assertEqualVersion("1cR", "1rC") + assertEqualVersion("1m3", "1Milestone3") + assertEqualVersion("1m3", "1MileStone3") + assertEqualVersion("1m3", "1MILESTONE3") + } + + void assertEqualVersion(String v1, String v2) { + Comparable c1 = newComparable(v1) + Comparable c2 = newComparable(v2) + assertTrue("expected " + v1 + " == " + v2, c1.compareTo(c2) == 0) + assertTrue("expected " + v2 + " == " + v1, c2.compareTo(c1) == 0) + assertTrue("expected same hashcode for " + v1 + " and " + v2, c1.hashCode() == c2.hashCode()) + assertTrue("expected " + v1 + ".equals( " + v2 + " )", c1.equals(c2)) + assertTrue("expected " + v2 + ".equals( " + v1 + " )", c2.equals(c1)) + } + + void assertVersionsOrder(String v1, String v2) { + Comparable c1 = newComparable(v1) + Comparable c2 = newComparable(v2) + assertTrue("expected " + v1 + " < " + v2, c1.compareTo(c2) < 0) + assertTrue("expected " + v2 + " > " + v1, c2.compareTo(c1) > 0) + } + + void assertGreaterThan(ComparableVersion v1, ComparableVersion v2) { + assertTrue("'$v1' should be greater than '$v2'", v1 > v2) + } + + void assertNotGreaterThan(ComparableVersion v1, ComparableVersion v2) { + assertFalse("'$v1' should not be greater than than '$v2'", v1 > v2) + } + + /** + * Test MNG-5568 edge case + * which was showing transitive inconsistency: since A > B and B > C then we should have A > C + * otherwise sorting a list of ComparableVersions() will in some cases throw runtime exception; + * see Netbeans issues 240845 and + * 226100 + */ + @Test + void testMng5568() { + String a = "6.1.0"; + String b = "6.1.0rc3"; + String c = "6.1H.5-beta"; // this is the unusual version string, with 'H' in the middle + + assertVersionsOrder(b, a); // classical + assertVersionsOrder(b, c); // now b < c, but before MNG-5568, we had b > c + assertVersionsOrder(a, c); + } + + @Test + void testReuse() { + ComparableVersion c1 = new ComparableVersion("1"); + c1.parseVersion("2"); + + Comparable c2 = newComparable("2"); + + assertEquals("reused instance should be equivalent to new instance", c1, c2); + } + + Comparable newComparable(String version) { + ComparableVersion ret = new ComparableVersion(version) + String canonical = ret.getCanonical() + String parsedCanonical = new ComparableVersion(canonical).getCanonical() + + System.out.println("canonical( " + version + " ) = " + canonical) + assertEquals("canonical( " + version + " ) = " + canonical + " -> canonical: " + parsedCanonical, canonical, + parsedCanonical) + + return ret + } +} diff --git a/test/resources/credentials/parser-test.json b/test/resources/credentials/parser-test.json new file mode 100644 index 0000000..e48679b --- /dev/null +++ b/test/resources/credentials/parser-test.json @@ -0,0 +1,11 @@ +[ + { + "pattern": "should-be-parsed-credential-pattern", + "id": "should-be-parsed-credential-id", + "comment": "should-be-parsed-credential-comment", + "username": "should-be-parsed-credential-username" + }, + { + "comment": "should-not-be-parsed-comment" + } +] \ No newline at end of file diff --git a/test/resources/credentials/scm/credentials.json b/test/resources/credentials/scm/credentials.json new file mode 100644 index 0000000..588f8a4 --- /dev/null +++ b/test/resources/credentials/scm/credentials.json @@ -0,0 +1,12 @@ +[ + { + "pattern": "git@git-ssh\.domain\.tld", + "id": "ssh-git-credentials-id", + "comment": "ssh-git-credentials-comment" + }, + { + "pattern": "https:\/\/git-http\.domain\.tld", + "id": "https-git-credentials-id", + "comment": "https-git-credentials-comment" + } +] \ No newline at end of file diff --git a/test/resources/credentials/ssh/credentials.json b/test/resources/credentials/ssh/credentials.json new file mode 100644 index 0000000..5d68692 --- /dev/null +++ b/test/resources/credentials/ssh/credentials.json @@ -0,0 +1,32 @@ +[ + { + "pattern": ".*\.testservers\.domain\.tld", + "id": "ssh-key-for-testservers", + "comment": "SSH key for connecting to test-servers", + "username": "testserveruser" + }, + { + "pattern": "^git@git-ssh\.domain\.tld.+", + "id": "ssh-git-push-credentials-id", + "comment": "ssh-git-push-credentials-comment", + "username": "git-push-username" + }, + { + "pattern": "host1\.domain\.tld", + "id": "domain-ssh-credential-id", + "comment": "domain-ssh-credential-comment", + "username": "domain-ssh-credential-username" + }, + { + "pattern": "host2\.domain\.tld", + "id": "domain-ssh-credential-id", + "comment": "domain-ssh-credential-comment", + "username": "domain-ssh-credential-username" + }, + { + "pattern": "host3\.domain\.tld", + "id": "host3-ssh-credential-id", + "comment": "host3-ssh-credential-comment", + "username": "host3-ssh-credential-username" + } +] \ No newline at end of file diff --git a/test/resources/example-resource.json b/test/resources/example-resource.json new file mode 100644 index 0000000..b42f309 --- /dev/null +++ b/test/resources/example-resource.json @@ -0,0 +1,3 @@ +{ + "foo": "bar" +} \ No newline at end of file diff --git a/test/resources/example-resource.txt b/test/resources/example-resource.txt new file mode 100644 index 0000000..f6ea049 --- /dev/null +++ b/test/resources/example-resource.txt @@ -0,0 +1 @@ +foobar \ No newline at end of file diff --git a/test/resources/invalid.json b/test/resources/invalid.json new file mode 100644 index 0000000..f914d99 --- /dev/null +++ b/test/resources/invalid.json @@ -0,0 +1,2 @@ +{ + "notvalid": true \ No newline at end of file diff --git a/test/resources/managedfiles/maven/global-settings.json b/test/resources/managedfiles/maven/global-settings.json new file mode 100644 index 0000000..e955b58 --- /dev/null +++ b/test/resources/managedfiles/maven/global-settings.json @@ -0,0 +1,20 @@ +[ + { + "pattern": "subdomain\.domain\.tld[:\/]group", + "id": "ssh-or-https-id", + "name": "ssh-or-https-name", + "comment": "ssh-or-https-comment" + }, + { + "pattern": "subdomain\.domain\.tld[:\/]group/project1", + "id": "ssh-or-https-better-match-id", + "name": "project1-better-match-name", + "comment": "project1-better-match-comment" + }, + { + "pattern": "subdomain\.evenbetterdomain\.tld", + "id": "EVEN_BETTER_DOMAIN_MVN_GLOBAL_SETTINGS_ID", + "name": "better-domain-global-settings-name", + "comment": "better-domain-global-settings-comment" + } +] diff --git a/test/resources/managedfiles/maven/parser-test.json b/test/resources/managedfiles/maven/parser-test.json new file mode 100644 index 0000000..fde7f47 --- /dev/null +++ b/test/resources/managedfiles/maven/parser-test.json @@ -0,0 +1,12 @@ +[ + { + "pattern": "should-be-parsed-pattern", + "id": "should-be-parsed-id", + "name": "should-be-parsed-name", + "comment": "should-be-parsed-comment" + }, + { + "name": "should-not-be-parsed-name", + "comment": "should-not-be-parsed-comment" + } +] diff --git a/test/resources/managedfiles/maven/settings.json b/test/resources/managedfiles/maven/settings.json new file mode 100644 index 0000000..05cc556 --- /dev/null +++ b/test/resources/managedfiles/maven/settings.json @@ -0,0 +1,32 @@ +[ + { + "pattern": "subdomain\.domain\.tld[:\/]group1", + "id": "group1-maven-settings-id", + "name": "group1-maven-settings-name", + "comment": "group1-maven-settings-comment" + }, + { + "pattern": "subdomain\.domain\.tld[:\/]group1/project1", + "id": "group1-project1-maven-settings-id", + "name": "group1-project1-maven-settings-name", + "comment": "group1-project1-maven-settings-comment" + }, + { + "pattern": "subdomain\.domain\.tld[:\/]group1/project2", + "id": "group1-project2-maven-settings-id", + "name": "group1-project2-maven-settings-name", + "comment": "group1-project2-maven-settings-comment" + }, + { + "pattern": "subdomain\.betterdomain\.tld[:\/]group1/project2", + "id": "BETTER_DOMAIN_MVN_SETTINGS", + "name": "group1-project2-maven-settings-name", + "comment": "group1-project2-maven-settings-comment" + }, + { + "pattern": "subdomain\.evenbetterdomain\.tld[:\/]group1/project2", + "id": "EVEN_BETTER_DOMAIN_MVN_SETTINGS", + "name": "group1-project2-maven-settings-name", + "comment": "group1-project2-maven-settings-comment" + } +] \ No newline at end of file diff --git a/test/resources/managedfiles/npm/npm-config-userconfig.json b/test/resources/managedfiles/npm/npm-config-userconfig.json new file mode 100644 index 0000000..e048e96 --- /dev/null +++ b/test/resources/managedfiles/npm/npm-config-userconfig.json @@ -0,0 +1,14 @@ +[ + { + "pattern": "subdomain\.npm-domain\.tld[:\/]group", + "id": "npm-user-config-id", + "name": "npm-user-config-name", + "comment": "npm-user-config-comment" + }, + { + "pattern": "subdomain\.npm-domain\.tld[:\/]group/project1", + "id": "npm-user-config-project1-id", + "name": "npm-user-config-project1-name", + "comment": "npm-user-config-project1-comment" + } +] diff --git a/test/resources/managedfiles/npm/npmrc.json b/test/resources/managedfiles/npm/npmrc.json new file mode 100644 index 0000000..35c0811 --- /dev/null +++ b/test/resources/managedfiles/npm/npmrc.json @@ -0,0 +1,14 @@ +[ + { + "pattern": "subdomain\.npm-domain\.tld[:\/]group", + "id": "npmrc-id", + "name": "npmrc-name", + "comment": "npmrc-comment" + }, + { + "pattern": "subdomain\.npm-domain\.tld[:\/]group/project1", + "id": "npmrc-project1-id", + "name": "npmrc-name", + "comment": "npmrc-comment" + } +] diff --git a/test/resources/managedfiles/ruby/bundle-config.json b/test/resources/managedfiles/ruby/bundle-config.json new file mode 100644 index 0000000..f87b081 --- /dev/null +++ b/test/resources/managedfiles/ruby/bundle-config.json @@ -0,0 +1,14 @@ +[ + { + "pattern": "subdomain\.npm-domain\.tld[:/]group", + "id": "bundle-config-id", + "name": "bundle-config-name", + "comment": "bundle-config-comment" + }, + { + "pattern": "subdomain\.npm-domain\.tld[:/]group/project1", + "id": "bundle-config-project1-id", + "name": "bundle-config-project1-name", + "comment": "bundle-config-project1-comment" + } +] diff --git a/test/resources/mavenRelease/invalid-maven-release-version-pom.xml b/test/resources/mavenRelease/invalid-maven-release-version-pom.xml new file mode 100644 index 0000000..5217e4b --- /dev/null +++ b/test/resources/mavenRelease/invalid-maven-release-version-pom.xml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + 4.0.0 + io.wcm.devops.tooling.jenkins + io.wcm.devops.tooling.jenkins.invalid-release-project + 1.0-SNAPSHOT + Pom for invalid maven release testing + + + + + + + + junit + junit + 3.8.1 + test + + + + + + + + + + + + + + + maven-antrun-plugin + 1.3 + + + maven-assembly-plugin + 2.2-beta-5 + + + maven-dependency-plugin + 2.8 + + + maven-release-plugin + 2.3.2 + + + + + + maven-clean-plugin + 2.5 + + + default-clean + clean + + clean + + + + + + maven-resources-plugin + 2.6 + + + default-testResources + process-test-resources + + testResources + + + + default-resources + process-resources + + resources + + + + + + maven-jar-plugin + 2.4 + + + default-jar + package + + jar + + + + + + maven-compiler-plugin + 3.1 + + + default-compile + compile + + compile + + + + default-testCompile + test-compile + + testCompile + + + + + + maven-surefire-plugin + 2.12.4 + + + default-test + test + + test + + + + + + maven-install-plugin + 2.4 + + + default-install + install + + install + + + + + + maven-deploy-plugin + 2.7 + + + default-deploy + deploy + + deploy + + + + + + + diff --git a/test/resources/mavenRelease/invalid-maven-scm-provider-gitexe-pom.xml b/test/resources/mavenRelease/invalid-maven-scm-provider-gitexe-pom.xml new file mode 100644 index 0000000..5217e4b --- /dev/null +++ b/test/resources/mavenRelease/invalid-maven-scm-provider-gitexe-pom.xml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + 4.0.0 + io.wcm.devops.tooling.jenkins + io.wcm.devops.tooling.jenkins.invalid-release-project + 1.0-SNAPSHOT + Pom for invalid maven release testing + + + + + + + + junit + junit + 3.8.1 + test + + + + + + + + + + + + + + + maven-antrun-plugin + 1.3 + + + maven-assembly-plugin + 2.2-beta-5 + + + maven-dependency-plugin + 2.8 + + + maven-release-plugin + 2.3.2 + + + + + + maven-clean-plugin + 2.5 + + + default-clean + clean + + clean + + + + + + maven-resources-plugin + 2.6 + + + default-testResources + process-test-resources + + testResources + + + + default-resources + process-resources + + resources + + + + + + maven-jar-plugin + 2.4 + + + default-jar + package + + jar + + + + + + maven-compiler-plugin + 3.1 + + + default-compile + compile + + compile + + + + default-testCompile + test-compile + + testCompile + + + + + + maven-surefire-plugin + 2.12.4 + + + default-test + test + + test + + + + + + maven-install-plugin + 2.4 + + + default-install + install + + install + + + + + + maven-deploy-plugin + 2.7 + + + default-deploy + deploy + + deploy + + + + + + + diff --git a/test/resources/mavenRelease/valid-effective-pom.xml b/test/resources/mavenRelease/valid-effective-pom.xml new file mode 100644 index 0000000..7913e9a --- /dev/null +++ b/test/resources/mavenRelease/valid-effective-pom.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + 4.0.0 + io.wcm.devops.tooling.jenkins + io.wcm.devops.tooling.jenkins.invalid-release-project + 1.0-SNAPSHOT + Pom for invalid maven release testing + + + + + + + + + + + + + + + + + + + + + + + + + + + + maven-release-plugin + 2.5.3 + + + org.apache.maven.scm + maven-scm-provider-gitexe + 1.9.5 + compile + + + + true + false + release + deploy + + + + + + + + maven-release-plugin + 2.5.3 + + + org.apache.maven.scm + maven-scm-provider-gitexe + 1.9.5 + compile + + + + + + + + diff --git a/test/resources/tools/ansible/requirements.yml b/test/resources/tools/ansible/requirements.yml new file mode 100644 index 0000000..1eafed2 --- /dev/null +++ b/test/resources/tools/ansible/requirements.yml @@ -0,0 +1,10 @@ +- src: williamyeh.oracle-java +- src: tecris.maven + version: v3.5.2 +- src: https://github.com/wcm-io-devops/ansible-aem-cms.git + name: aem-cms + scm: git +- src: https://github.com/wcm-io-devops/ansible-aem-service.git + name: aem-service + scm: git + version: develop \ No newline at end of file diff --git a/test/resources/tools/ansible/tecris.maven.json b/test/resources/tools/ansible/tecris.maven.json new file mode 100644 index 0000000..5c277a0 --- /dev/null +++ b/test/resources/tools/ansible/tecris.maven.json @@ -0,0 +1,110 @@ +{ + "count": 1, + "cur_page": 1, + "num_pages": 1, + "next_link": null, + "previous_link": null, + "next": null, + "previous": null, + "results": [ + { + "url": "/api/v1/roles/7576/", + "related": { + "imports": "/api/v1/roles/7576/imports/", + "versions": "/api/v1/roles/7576/versions/", + "dependencies": "/api/v1/roles/7576/dependencies/", + "notifications": "/api/v1/roles/7576/notifications/" + }, + "summary_fields": { + "platforms": [ + { + "release": "trusty", + "name": "Ubuntu" + }, + { + "release": "xenial", + "name": "Ubuntu" + } + ], + "versions": [ + { + "release_date": "2017-04-19T15:03:09Z", + "name": "v3.5.2", + "id": 30180 + }, + { + "release_date": "2017-04-19T14:36:47Z", + "name": "v3.5.1", + "id": 30176 + }, + { + "release_date": "2017-04-19T13:46:30Z", + "name": "v3.5.0", + "id": 30172 + }, + { + "release_date": "2016-05-08T10:41:28Z", + "name": "v1.0.1", + "id": 13948 + }, + { + "release_date": "2016-02-27T16:39:09Z", + "name": "v1.0.0", + "id": 11018 + }, + { + "release_date": "2016-02-08T10:58:02Z", + "name": "v0.1.1", + "id": 11019 + }, + { + "release_date": "2016-02-07T17:41:41Z", + "name": "v0.1.0", + "id": 10315 + }, + { + "release_date": "2017-08-01T18:40:12Z", + "name": "17.08.02", + "id": 36110 + } + ], + "dependencies": [], + "tags": [ + { + "name": "development" + }, + { + "name": "maven" + } + ] + }, + "id": 7576, + "created": "2016-02-07T17:27:09.066Z", + "modified": "2017-10-26T10:55:06.079Z", + "name": "maven", + "role_type": "ANS", + "namespace": "tecris", + "is_valid": true, + "github_user": "tecris", + "github_repo": "ansible-maven", + "github_branch": "", + "min_ansible_version": "2.0", + "issue_tracker_url": "https://github.com/tecris/ansible-maven/issues", + "license": "license (GPLv2, CC-BY, etc)", + "company": "your company (optional)", + "description": "Maven ansible role", + "readme": "# Maven ansible role\n\n\n[![Build Status](https://travis-ci.org/tecris/ansible-maven.svg?branch=master)](https://travis-ci.org/tecris/ansible-maven)\n\n\nRole Variables\n--------------\n\n[defaults/main.yml](defaults/main.yml)\n\n|*Variable* | *Default Value* |*Description* |\n| --- | --- | --- |\n| maven_major | 3 | MAJOR [version](http://semver.org/) |\n| maven_version | 3.5.0 | Version number|\n| maven_home_parent_directory | /opt | MAVEN_HOME parent directory|\n\nInstallation\n------------\n\n `$ ansible-galaxy install tecris.maven`\n\nExample Playbook\n----------------\n```\n - hosts: all\n roles:\n - { role: tecris.maven, maven_major: 3, maven_release: 3.5.0, maven_home_parent_directory: /opt }\n```\nTests\n-----\nReferences:\n - [Ansible role testing](http://www.jeffgeerling.com/blog/testing-ansible-roles-travis-ci-github)\n - [Ansible apache role](https://github.com/geerlingguy/ansible-role-apache)\n - [Testing on different OSs with Docker](https://www.jeffgeerling.com/blog/2016/how-i-test-ansible-configuration-on-7-different-oses-docker)\n", + "readme_html": "

Maven ansible role

\n

\"Build

\n

Role Variables

\n

defaults/main.yml

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
VariableDefault ValueDescription
maven_major3MAJOR version
maven_version3.5.0Version number
maven_home_parent_directory/optMAVEN_HOME parent directory
\n

Installation

\n

$ ansible-galaxy install tecris.maven

\n

Example Playbook

\n
 - hosts: all\n   roles:\n     - { role: tecris.maven, maven_major: 3, maven_release: 3.5.0, maven_home_parent_directory: /opt }\n
\n

Tests

\n

References:

\n\n
", + "travis_status_url": "https://travis-ci.org/tecris/ansible-maven.svg?branch=17.08.02", + "stargazers_count": 3, + "watchers_count": 1, + "forks_count": 5, + "open_issues_count": 0, + "commit": "5ecf6c1a4567ac2fac61e9c6b5729b1009e7e1e9", + "commit_message": "Merge pull request #4 from dewthefifth/feature/3_Downloads-Maven-Every-Time\n\nAdded a 'stat' call to check if maven is already installed before ini…", + "commit_url": "https://github.com/tecris/ansible-maven/commit/5ecf6c1a4567ac2fac61e9c6b5729b1009e7e1e9", + "download_count": 1040, + "active": true + } + ] +} diff --git a/test/resources/tools/ansible/williamyeh.oracle-java.json b/test/resources/tools/ansible/williamyeh.oracle-java.json new file mode 100644 index 0000000..09cb0f9 --- /dev/null +++ b/test/resources/tools/ansible/williamyeh.oracle-java.json @@ -0,0 +1,271 @@ +{ + "count": 1, + "cur_page": 1, + "num_pages": 1, + "next_link": null, + "previous_link": null, + "next": null, + "previous": null, + "results": [ + { + "url": "/api/v1/roles/2851/", + "related": { + "imports": "/api/v1/roles/2851/imports/", + "versions": "/api/v1/roles/2851/versions/", + "dependencies": "/api/v1/roles/2851/dependencies/", + "notifications": "/api/v1/roles/2851/notifications/" + }, + "summary_fields": { + "platforms": [ + { + "release": "jessie", + "name": "Debian" + }, + { + "release": "wheezy", + "name": "Debian" + }, + { + "release": "6", + "name": "EL" + }, + { + "release": "7", + "name": "EL" + }, + { + "release": "12.1", + "name": "opensuse" + }, + { + "release": "12.2", + "name": "opensuse" + }, + { + "release": "12.3", + "name": "opensuse" + }, + { + "release": "13.1", + "name": "opensuse" + }, + { + "release": "13.2", + "name": "opensuse" + }, + { + "release": "10SP3", + "name": "SLES" + }, + { + "release": "10SP4", + "name": "SLES" + }, + { + "release": "11", + "name": "SLES" + }, + { + "release": "11SP1", + "name": "SLES" + }, + { + "release": "11SP2", + "name": "SLES" + }, + { + "release": "11SP3", + "name": "SLES" + }, + { + "release": "11SP4", + "name": "SLES" + }, + { + "release": "12", + "name": "SLES" + }, + { + "release": "12SP1", + "name": "SLES" + }, + { + "release": "precise", + "name": "Ubuntu" + }, + { + "release": "trusty", + "name": "Ubuntu" + } + ], + "versions": [ + { + "release_date": "2016-06-23T05:01:41Z", + "name": "2.9.0", + "id": 15876 + }, + { + "release_date": "2016-06-13T09:51:11Z", + "name": "2.8.0", + "id": 15428 + }, + { + "release_date": "2016-06-01T07:19:54Z", + "name": "2.7.0", + "id": 15053 + }, + { + "release_date": "2016-05-23T03:02:31Z", + "name": "2.6.0", + "id": 14717 + }, + { + "release_date": "2016-04-11T07:44:05Z", + "name": "2.5.0", + "id": 13022 + }, + { + "release_date": "2016-04-11T04:15:26Z", + "name": "2.4.0", + "id": 13010 + }, + { + "release_date": "2016-04-08T08:41:38Z", + "name": "2.3.0", + "id": 12946 + }, + { + "release_date": "2016-03-25T03:00:53Z", + "name": "2.2.0", + "id": 12427 + }, + { + "release_date": "2017-02-02T09:56:13Z", + "name": "2.11.0", + "id": 25743 + }, + { + "release_date": "2016-08-26T08:13:36Z", + "name": "2.10.0", + "id": 18129 + }, + { + "release_date": "2016-03-01T09:10:50Z", + "name": "2.1.0", + "id": 11176 + }, + { + "release_date": "2016-02-23T03:51:57Z", + "name": "2.0.0", + "id": 10846 + }, + { + "release_date": "2016-01-11T10:55:28Z", + "name": "1.1.9", + "id": 9229 + }, + { + "release_date": "2015-12-24T10:42:01Z", + "name": "1.1.8", + "id": 8758 + }, + { + "release_date": "2015-12-21T10:00:35Z", + "name": "1.1.7", + "id": 8730 + }, + { + "release_date": "2015-12-21T03:08:45Z", + "name": "1.1.6", + "id": 8713 + }, + { + "release_date": "2015-12-14T06:29:54Z", + "name": "1.1.5", + "id": 8582 + }, + { + "release_date": "2015-12-08T07:11:30Z", + "name": "1.1.4", + "id": 8484 + }, + { + "release_date": "2015-10-27T03:23:45Z", + "name": "1.1.3", + "id": 7391 + }, + { + "release_date": "2015-10-22T04:02:05Z", + "name": "1.1.2", + "id": 7392 + }, + { + "release_date": "2016-01-21T07:22:52Z", + "name": "1.1.11", + "id": 9435 + }, + { + "release_date": "2015-10-20T14:32:53Z", + "name": "1.1.1", + "id": 7323 + }, + { + "release_date": "2015-10-19T15:16:27Z", + "name": "1.1.0", + "id": 7301 + }, + { + "release_date": "2015-09-22T15:20:42Z", + "name": "1.0.0", + "id": 7302 + } + ], + "dependencies": [], + "tags": [ + { + "name": "development" + }, + { + "name": "java" + }, + { + "name": "jdk" + }, + { + "name": "jvm" + }, + { + "name": "system" + } + ] + }, + "id": 2851, + "created": "2015-02-10T10:18:55.255Z", + "modified": "2017-10-26T14:52:15.774Z", + "name": "oracle-java", + "role_type": "ANS", + "namespace": "williamyeh", + "is_valid": true, + "github_user": "William-Yeh", + "github_repo": "ansible-oracle-java", + "github_branch": "", + "min_ansible_version": "2.0.0", + "issue_tracker_url": "https://github.com/William-Yeh/ansible-oracle-java/issues", + "license": "Apache", + "company": "", + "description": "Oracle JDK 7/8 for CentOS/Debian/Ubuntu/Suse/MacOSX", + "readme": "\nwilliamyeh.oracle-java for Ansible Galaxy\n============\n\n[![Build Status](https://travis-ci.org/William-Yeh/ansible-oracle-java.svg?branch=master)](https://travis-ci.org/William-Yeh/ansible-oracle-java) [![Circle CI](https://circleci.com/gh/William-Yeh/ansible-oracle-java.svg?style=shield)](https://circleci.com/gh/William-Yeh/ansible-oracle-java)\n\n## Summary\n\nRole name in Ansible Galaxy: **[williamyeh.oracle-java](https://galaxy.ansible.com/williamyeh/oracle-java/)**\n\nThis Ansible role has the following features for Oracle JDK:\n\n - Install JDK 7 or 8 version.\n - Install optional Java Cryptography Extensions (JCE)\n - Install for CentOS, Debian/Ubuntu, SUSE, and Mac OS X families.\n\nIf you prefer OpenJDK, try alternatives such as [geerlingguy.java](https://galaxy.ansible.com/geerlingguy/java/) or [smola.java](https://galaxy.ansible.com/smola/java/).\n\n\n## Role Variables\n\n### Mandatory variables\n\nNone.\n\n### Optional variables\n\n\nUser-configurable defaults:\n\n```yaml\n# which version?\njava_version: 8\n\n# which subversion?\njava_subversion: 112\n\n# which directory to put the download file?\njava_download_path: /tmp\n\n# rpm/tar.gz file location:\n# - true: download from Oracle on-the-fly;\n# - false: copy from `{{ playbook_dir }}/files` on the control machine.\njava_download_from_oracle: true\n\n# remove temporary downloaded files?\njava_remove_download: true\n\n# set $JAVA_HOME?\njava_set_javahome: false\n\n# install JCE?\njava_install_jce: false\n```\n\nFor other configurable internals, read `tasks/set-role-variables.yml` file; for example, supported `java_version`/`java_subversion` combinations.\n\nIf you want to install a Java release which is not supported out-of-the-box, you have to specify the corresponding Java build number in the variable `java_build` in addition to `java_version` and `java_subversion`, e.g.\n```yaml\n---\n- hosts: all\n\n roles:\n - williamyeh.oracle-java\n\n vars:\n java_version: 8\n java_subversion: 91\n java_build: 14\n```\n\n\n### Customized variables, if absolutely necessary\n\nIf you have a pre-downloaded `jdk_tarball_file` whose filename cannot be inferred successfully by `tasks/set-role-variables.yml`, you may specify it explicitly: \n\n```yaml\n# Specify the pre-fetch filename (without tailing .tar.gz or .rpm or .dmg);\n# used in conjunction with `java_download_from_oracle: false`.\n\njdk_tarball_file\n\n# For example, if you have a `files/jdk-7u79-linux-x64.tar.gz` locally,\n# but the filename cannot be inferred successfully by `tasks/set-role-variables.yml`,\n# you may specify the following variables in your playbook:\n#\n# java_version: 7\n# java_subversion: 79\n# java_download_from_oracle: false\n# jdk_tarball_file: jdk-7u79-linux-x64\n#\n```\n\n\n## Usage\n\n\n### Step 1: add role\n\nAdd role name `williamyeh.oracle-java` to your playbook file.\n\n\n### Step 2: add variables\n\nSet vars in your playbook file.\n\nSimple example:\n\n```yaml\n---\n# file: simple-playbook.yml\n\n- hosts: all\n\n roles:\n - williamyeh.oracle-java\n\n vars:\n java_version: 8\n```\n\n\n### (Optionally) pre-fetch .rpm and .tar.gz files\n\nFor some reasons, you may want to pre-fetch .rpm and .tar.gz files *before the execution of this role*, instead of downloading from Oracle on-the-fly.\n\nTo do this, put the file on the `{{ playbook_dir }}/files` directory in advance, and then set the `java_download_from_oracle` variable to `false`:\n\n```yaml\n---\n# file: prefetch-playbook.yml\n\n- hosts: all\n\n roles:\n - williamyeh.oracle-java\n\n vars:\n java_version: 8\n java_download_from_oracle: false\n```\n\n\n\n\n\n\n## Dependencies\n\n\n## License\n\nLicensed under the Apache License V2.0. See the [LICENSE file](LICENSE) for details.\n\n\n## History\n\nRewritten from my pre-Galaxy version: [server-config-template](https://github.com/William-Yeh/server-config-template).\n", + "readme_html": "

williamyeh.oracle-java for Ansible Galaxy

\n

\"Build \"Circle

\n

Summary

\n

Role name in Ansible Galaxy: williamyeh.oracle-java

\n

This Ansible role has the following features for Oracle JDK:

\n
    \n
  • Install JDK 7 or 8 version.
  • \n
  • Install optional Java Cryptography Extensions (JCE)
  • \n
  • Install for CentOS, Debian/Ubuntu, SUSE, and Mac OS X families.
  • \n
\n

If you prefer OpenJDK, try alternatives such as geerlingguy.java or smola.java.

\n

Role Variables

\n

Mandatory variables

\n

None.

\n

Optional variables

\n

User-configurable defaults:

\n
# which version?\njava_version: 8\n\n# which subversion?\njava_subversion: 112\n\n# which directory to put the download file?\njava_download_path: /tmp\n\n# rpm/tar.gz file location:\n#   - true: download from Oracle on-the-fly;\n#   - false: copy from `{{ playbook_dir }}/files` on the control machine.\njava_download_from_oracle: true\n\n# remove temporary downloaded files?\njava_remove_download: true\n\n# set $JAVA_HOME?\njava_set_javahome: false\n\n# install JCE?\njava_install_jce: false
\n

For other configurable internals, read tasks/set-role-variables.yml file; for example, supported java_version/java_subversion combinations.

\n

If you want to install a Java release which is not supported out-of-the-box, you have to specify the corresponding Java build number in the variable java_build in addition to java_version and java_subversion, e.g.

\n
---\n- hosts: all\n\n  roles:\n    - williamyeh.oracle-java\n\n  vars:\n    java_version: 8\n    java_subversion: 91\n    java_build: 14
\n

Customized variables, if absolutely necessary

\n

If you have a pre-downloaded jdk_tarball_file whose filename cannot be inferred successfully by tasks/set-role-variables.yml, you may specify it explicitly:

\n
# Specify the pre-fetch filename (without tailing .tar.gz or .rpm or .dmg);\n# used in conjunction with `java_download_from_oracle: false`.\n\njdk_tarball_file\n\n# For example, if you have a `files/jdk-7u79-linux-x64.tar.gz` locally,\n# but the filename cannot be inferred successfully by `tasks/set-role-variables.yml`,\n# you may specify the following variables in your playbook:\n#\n#    java_version:    7\n#    java_subversion: 79\n#    java_download_from_oracle: false\n#    jdk_tarball_file: jdk-7u79-linux-x64\n#
\n

Usage

\n

Step 1: add role

\n

Add role name williamyeh.oracle-java to your playbook file.

\n

Step 2: add variables

\n

Set vars in your playbook file.

\n

Simple example:

\n
---\n# file: simple-playbook.yml\n\n- hosts: all\n\n  roles:\n    - williamyeh.oracle-java\n\n  vars:\n    java_version: 8
\n

(Optionally) pre-fetch .rpm and .tar.gz files

\n

For some reasons, you may want to pre-fetch .rpm and .tar.gz files before the execution of this role, instead of downloading from Oracle on-the-fly.

\n

To do this, put the file on the {{ playbook_dir }}/files directory in advance, and then set the java_download_from_oracle variable to false:

\n
---\n# file: prefetch-playbook.yml\n\n- hosts: all\n\n  roles:\n    - williamyeh.oracle-java\n\n  vars:\n    java_version: 8\n    java_download_from_oracle: false
\n

Dependencies

\n

License

\n

Licensed under the Apache License V2.0. See the LICENSE file for details.

\n

History

\n

Rewritten from my pre-Galaxy version: server-config-template.

\n
", + "travis_status_url": "https://travis-ci.org/William-Yeh/ansible-oracle-java.svg?branch=master", + "stargazers_count": 138, + "watchers_count": 26, + "forks_count": 91, + "open_issues_count": 19, + "commit": "c6c8de6efaa18fc7ab76f0959d049c1721fe918c", + "commit_message": "Update: default version to 8u112 (#48)\n\nMerged from https://github.com/William-Yeh/ansible-oracle-java/pull/48", + "commit_url": "https://github.com/William-Yeh/ansible-oracle-java/commit/c6c8de6efaa18fc7ab76f0959d049c1721fe918c", + "download_count": 14124, + "active": true + } + ] +} diff --git a/test/vars/ansible/AnsibleIT.groovy b/test/vars/ansible/AnsibleIT.groovy new file mode 100644 index 0000000..daf312a --- /dev/null +++ b/test/vars/ansible/AnsibleIT.groovy @@ -0,0 +1,228 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +package vars.ansible + +import io.wcm.testing.jenkins.pipeline.LibraryIntegrationTestBase +import io.wcm.testing.jenkins.pipeline.StepConstants +import io.wcm.testing.jenkins.pipeline.recorder.StepRecorder +import io.wcm.testing.jenkins.pipeline.recorder.StepRecorderAssert +import net.sf.json.JSONObject +import org.apache.commons.lang.NotImplementedException +import org.junit.Assert +import org.junit.Test + +class AnsibleIT extends LibraryIntegrationTestBase { + + @Override + void setUp() throws Exception { + super.setUp() + helper.registerAllowedMethod(StepConstants.SH, [Map.class], shellMapCallback) + } + + @Test + public void shouldCheckoutRequirements() { + loadAndExecuteScript("vars/ansible/jobs/ansibleCheckoutRequirementsTestJob.groovy") + List checkoutCalls = StepRecorderAssert.assertStepCalls(StepConstants.CHECKOUT, 4) + + Map expectedCheckoutCall0 = [ + '$class' : "GitSCM", + "branches" : [ + ["name": "master"] + ], + "doGenerateSubmoduleConfigurations": false, + "extensions" : [ + [$class: 'LocalBranch'], + [$class: 'RelativeTargetDirectory', relativeTargetDir: 'williamyeh.oracle-java'], + [$class: 'ScmName', name: 'williamyeh.oracle-java'] + ], + "submoduleCfg" : [], + "userRemoteConfigs" : [ + [url: "https://github.com/William-Yeh/ansible-oracle-java.git"] + ] + ] + + Map expectedCheckoutCall1 = [ + '$class' : "GitSCM", + "branches" : [ + ["name": "v3.5.2"] + ], + "doGenerateSubmoduleConfigurations": false, + "extensions" : [ + [$class: 'LocalBranch'], + [$class: 'RelativeTargetDirectory', relativeTargetDir: 'tecris.maven'], + [$class: 'ScmName', name: 'tecris.maven'] + ], + "submoduleCfg" : [], + "userRemoteConfigs" : [ + [url: "https://github.com/tecris/ansible-maven.git"] + ] + ] + + Map expectedCheckoutCall2 = [ + '$class' : "GitSCM", + "branches" : [ + ["name": "master"] + ], + "doGenerateSubmoduleConfigurations": false, + "extensions" : [ + [$class: 'LocalBranch'], + [$class: 'RelativeTargetDirectory', relativeTargetDir: 'aem-cms'], + [$class: 'ScmName', name: 'aem-cms'] + ], + "submoduleCfg" : [], + "userRemoteConfigs" : [ + [url: "https://github.com/wcm-io-devops/ansible-aem-cms.git"] + ] + ] + + Map expectedCheckoutCall3 = [ + '$class' : "GitSCM", + "branches" : [ + ["name": "develop"] + ], + "doGenerateSubmoduleConfigurations": false, + "extensions" : [ + [$class: 'LocalBranch'], + [$class: 'RelativeTargetDirectory', relativeTargetDir: 'aem-service'], + [$class: 'ScmName', name: 'aem-service'] + ], + "submoduleCfg" : [], + "userRemoteConfigs" : [ + [url: "https://github.com/wcm-io-devops/ansible-aem-service.git"] + ] + ] + + Assert.assertEquals(expectedCheckoutCall0, checkoutCalls.get(0)) + Assert.assertEquals(expectedCheckoutCall1, checkoutCalls.get(1)) + Assert.assertEquals(expectedCheckoutCall2, checkoutCalls.get(2)) + Assert.assertEquals(expectedCheckoutCall3, checkoutCalls.get(3)) + } + + @Test + public void shouldRunAnsibleWithMinimalConfiguration() { + loadAndExecuteScript("vars/ansible/jobs/ansibleExecPlaybookMinimalTestJob.groovy") + Map actualPlaybookCall = StepRecorderAssert.assertOnce(StepConstants.ANSIBLE_PLAYBOOK) + + Map expectedPlayBookCall = [ + colorized : true, + extras : '--extra-vars \'{}\'', + forks : 5, + installation : 'ansible-1.0.0', + inventory : 'ansible-inventory', + limit : null, + playbook : 'ansible-playbook-path', + skippedTags : null, + startAtTask : null, + sudo : false, + sudoUser : null, + tags : null, + credentialsId: null, + ] + + Assert.assertEquals(expectedPlayBookCall, actualPlaybookCall) + } + + @Test + public void shouldRunAnsibleWithCustomConfiguration() { + loadAndExecuteScript("vars/ansible/jobs/ansibleExecPlaybookCustomConfigurationTestJob.groovy") + Map actualPlaybookCall = StepRecorderAssert.assertOnce(StepConstants.ANSIBLE_PLAYBOOK) + + Map expectedPlayBookCall = [ + colorized : false, + extras : '-v --extra-vars \'{"string":"value","boolean":true,"integer":1,"list":[1,2,3,4]}\'', + forks : 10, + installation : 'ansible-installation-variant2', + inventory : 'ansible-inventory-variant2', + limit : "ansible-limit-variant2", + playbook : 'ansible-playbook-variant2', + skippedTags : "ansible-tags-variant2", + startAtTask : "ansible-start-at-task-variant2", + sudo : true, + sudoUser : "ansible-sudo-user-variant2", + tags : "ansible-tags-variant2", + credentialsId: "ansible-credentials-variant2", + ] + + Assert.assertEquals(expectedPlayBookCall, actualPlaybookCall) + } + + @Test + public void shouldInjectBuildParams() { + this.setParams([choiceParam: "choice1", boolParam: true, stringParam: "text"]) + loadAndExecuteScript("vars/ansible/jobs/ansibleExecPlaybookInjectParamsTestJob.groovy") + Map actualPlaybookCall = StepRecorderAssert.assertOnce(StepConstants.ANSIBLE_PLAYBOOK) + + + Map expectedPlayBookCall = [ + colorized : true, + extras : '--extra-vars \'{"param":"value","choiceParam":"choice1","boolParam":true,"stringParam":"text"}\'', + forks : 5, + installation : 'ansible-inject-params-installation', + inventory : 'ansible-inject-params-inventory', + limit : null, + playbook : 'ansible-inject-params-playbook', + skippedTags : null, + startAtTask : null, + sudo : false, + sudoUser : null, + tags : null, + credentialsId: null, + ] + + Assert.assertEquals(expectedPlayBookCall, actualPlaybookCall) + } + + @Test + public void shouldGetGalaxyRoleInfo() { + JSONObject result = loadAndExecuteScript("vars/ansible/jobs/ansibleGetGalaxyRoleInfoTestJob.groovy") + Assert.assertEquals("William-Yeh", result["github_user"]) + Assert.assertEquals("ansible-oracle-java", result["github_repo"]) + } + + @Test + public void shouldNotGetGalaxyRoleInfo() { + JSONObject result = loadAndExecuteScript("vars/ansible/jobs/ansibleGetGalaxyRoleInfoWithErrorsTestJob.groovy") + Assert.assertNull(result) + } + + // cpsScriptMock the curl command + def shellMapCallback = { Map incomingCommand -> + stepRecorder.record(StepConstants.SH, incomingCommand) + Boolean returnStdout = incomingCommand.returnStdout ?: false + String script = incomingCommand.script ?: "" + // return default values for several commands + if (returnStdout) { + switch (script) { + case "curl --silent 'https://galaxy.ansible.com/api/v1/roles/?owner__username=williamyeh&name=oracle-java'": + File mockedResponse = this.dslMock.locateTestResource("tools/ansible/williamyeh.oracle-java.json") + return mockedResponse.getText("UTF-8") + break + case "curl --silent 'https://galaxy.ansible.com/api/v1/roles/?owner__username=tecris&name=maven'": + File mockedResponse = this.dslMock.locateTestResource("tools/ansible/tecris.maven.json") + return mockedResponse.getText("UTF-8") + break + default: throw new Exception() + } + } + } + + +} diff --git a/test/vars/ansible/jobs/ansibleCheckoutRequirementsTestJob.groovy b/test/vars/ansible/jobs/ansibleCheckoutRequirementsTestJob.groovy new file mode 100644 index 0000000..35ee41b --- /dev/null +++ b/test/vars/ansible/jobs/ansibleCheckoutRequirementsTestJob.groovy @@ -0,0 +1,34 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execMaven.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs execMaven step with path to custom maven executable + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + ansible.checkoutRequirements("tools/ansible/requirements.yml") +} + +return this diff --git a/test/vars/ansible/jobs/ansibleExecPlaybookCustomConfigurationTestJob.groovy b/test/vars/ansible/jobs/ansibleExecPlaybookCustomConfigurationTestJob.groovy new file mode 100644 index 0000000..a0b7235 --- /dev/null +++ b/test/vars/ansible/jobs/ansibleExecPlaybookCustomConfigurationTestJob.groovy @@ -0,0 +1,60 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.ansible.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs execMaven step with path to custom maven executable + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + + Map config = [ + (ANSIBLE): [ + (ANSIBLE_COLORIZED) : false, + (ANSIBLE_EXTRA_PARAMETERS): ["-v"], + (ANSIBLE_EXTRA_VARS) : [ + "string" : "value", + "boolean": true, + "integer": 1, + "list" : [1, 2, 3, 4] + ], + (ANSIBLE_FORKS) : 10, + (ANSIBLE_INSTALLATION) : "ansible-installation-variant2", + (ANSIBLE_INVENTORY) : "ansible-inventory-variant2", + (ANSIBLE_LIMIT) : "ansible-limit-variant2", + (ANSIBLE_PLAYBOOK) : "ansible-playbook-variant2", + (ANSIBLE_CREDENTIALS_ID) : "ansible-credentials-variant2", + (ANSIBLE_SKIPPED_TAGS) : "ansible-tags-variant2", + (ANSIBLE_START_AT_TASK) : "ansible-start-at-task-variant2", + (ANSIBLE_SUDO) : true, + (ANSIBLE_SUDO_USER) : "ansible-sudo-user-variant2", + (ANSIBLE_TAGS) : "ansible-tags-variant2", + (ANSIBLE_INJECT_PARAMS) : false + ] + ] + + ansible.execPlaybook(config) +} + +return this diff --git a/test/vars/ansible/jobs/ansibleExecPlaybookInjectParamsTestJob.groovy b/test/vars/ansible/jobs/ansibleExecPlaybookInjectParamsTestJob.groovy new file mode 100644 index 0000000..0157cfa --- /dev/null +++ b/test/vars/ansible/jobs/ansibleExecPlaybookInjectParamsTestJob.groovy @@ -0,0 +1,45 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.ansible.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs execMaven step with path to custom maven executable + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + + Map config = [ + (ANSIBLE): [ + (ANSIBLE_INSTALLATION) : "ansible-inject-params-installation", + (ANSIBLE_INVENTORY) : "ansible-inject-params-inventory", + (ANSIBLE_PLAYBOOK) : "ansible-inject-params-playbook", + (ANSIBLE_EXTRA_VARS) : ["param": "value"], + (ANSIBLE_INJECT_PARAMS): true + ] + ] + + ansible.execPlaybook(config) +} + +return this diff --git a/test/vars/ansible/jobs/ansibleExecPlaybookMinimalTestJob.groovy b/test/vars/ansible/jobs/ansibleExecPlaybookMinimalTestJob.groovy new file mode 100644 index 0000000..b43da16 --- /dev/null +++ b/test/vars/ansible/jobs/ansibleExecPlaybookMinimalTestJob.groovy @@ -0,0 +1,43 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execMaven.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs execMaven step with path to custom maven executable + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + + Map config = [ + (ANSIBLE): [ + (ANSIBLE_INSTALLATION): "ansible-1.0.0", + (ANSIBLE_INVENTORY) : "ansible-inventory", + (ANSIBLE_PLAYBOOK) : "ansible-playbook-path" + ] + ] + + ansible.execPlaybook(config) +} + +return this diff --git a/test/vars/ansible/jobs/ansibleGetGalaxyRoleInfoTestJob.groovy b/test/vars/ansible/jobs/ansibleGetGalaxyRoleInfoTestJob.groovy new file mode 100644 index 0000000..1fdb555 --- /dev/null +++ b/test/vars/ansible/jobs/ansibleGetGalaxyRoleInfoTestJob.groovy @@ -0,0 +1,38 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.ansible.jobs + +import io.wcm.tooling.jenkins.pipeline.tools.ansible.Role + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs execMaven step with path to custom maven executable + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + + Role existingRole = new Role("williamyeh.oracle-java") + return ansible.getGalaxyRoleInfo(existingRole) +} + +return this diff --git a/test/vars/ansible/jobs/ansibleGetGalaxyRoleInfoWithErrorsTestJob.groovy b/test/vars/ansible/jobs/ansibleGetGalaxyRoleInfoWithErrorsTestJob.groovy new file mode 100644 index 0000000..5988250 --- /dev/null +++ b/test/vars/ansible/jobs/ansibleGetGalaxyRoleInfoWithErrorsTestJob.groovy @@ -0,0 +1,38 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.ansible.jobs + +import io.wcm.tooling.jenkins.pipeline.tools.ansible.Role + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs execMaven step with path to custom maven executable + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + + Role notExistingRole = new Role("not.existingrole") + return ansible.getGalaxyRoleInfo(notExistingRole) +} + +return this diff --git a/test/vars/checkoutScm/CheckoutScmIT.groovy b/test/vars/checkoutScm/CheckoutScmIT.groovy new file mode 100644 index 0000000..90e538d --- /dev/null +++ b/test/vars/checkoutScm/CheckoutScmIT.groovy @@ -0,0 +1,144 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.checkoutScm + +import io.wcm.testing.jenkins.pipeline.LibraryIntegrationTestBase +import io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants +import org.junit.Test + +import static io.wcm.testing.jenkins.pipeline.StepConstants.CHECKOUT +import static io.wcm.testing.jenkins.pipeline.recorder.StepRecorderAssert.assertOnce +import static org.junit.Assert.assertEquals + +class CheckoutScmIT extends LibraryIntegrationTestBase { + + @Override + void setUp() throws Exception { + super.setUp() + } + + @Test + void shouldUseDefaultConfiguration() { + loadAndExecuteScript("vars/checkoutScm/jobs/checkoutScmDefaultsJob.groovy") + Map scmCheckoutCall = (Map) assertOnce(CHECKOUT) + + assertEquals("Used SCM should be git", "GitSCM", scmCheckoutCall.get('$class')) + assertEquals("doGenerateSubmoduleConfigurations should be false", false, scmCheckoutCall.get("doGenerateSubmoduleConfigurations")) + assertEquals("'extensions' should have LocalBranch extension as default", [[$class: 'LocalBranch']], scmCheckoutCall.get("extensions")) + assertEquals("'submoduleCfg' should be empty", 0, scmCheckoutCall.get("submoduleCfg").size()) + + // test branches + List branches = scmCheckoutCall.get("branches") + assertEquals("[[name:*/master], [name:*/develop]]", branches.toString()) + + List userRemoteConfigs = (List) scmCheckoutCall.get("userRemoteConfigs") + assertEquals("One userRemoteConfig expected", 1, userRemoteConfigs.size()) + Map userRemoteConfig = (Map) userRemoteConfigs.get(0) + // check for credential auto detection + assertEquals("ssh-git-credentials-id", userRemoteConfig.get("credentialsId")) + // check for correct url + assertEquals("git@git-ssh.domain.tld/group/project1.git", userRemoteConfig.get("url")) + } + + @Test + void shouldUseCustomConfiguration() { + loadAndExecuteScript("vars/checkoutScm/jobs/checkoutScmCustomVariant1Job.groovy") + Map scmCheckoutCall = (Map) assertOnce(CHECKOUT) + + assertEquals("Used SCM should be git", "GitSCM", scmCheckoutCall.get('$class')) + + // test sub module configuration + assertEquals("doGenerateSubmoduleConfigurations should be true", true, scmCheckoutCall.get("doGenerateSubmoduleConfigurations")) + + // test extensions + List actualExtensions = (List) scmCheckoutCall.get("extensions") + List actualSubmoduleCfg = (List) scmCheckoutCall.get("submoduleCfg") + assertEquals('[[$class:CleanBeforeCheckout], [$class:CloneOption, depth:0, noTags:false, reference:, shallow:true]]', actualExtensions.toString()) + assertEquals('[[$class:CustomSubModuleCfg]]', actualSubmoduleCfg.toString()) + + // test branches + List branches = (List) scmCheckoutCall.get("branches") + assertEquals("[[name:customBranch]]", branches.toString()) + + List userRemoteConfigs = (List) scmCheckoutCall.get("userRemoteConfigs") + assertEquals("One userRemoteConfig expected", 1, userRemoteConfigs.size()) + Map userRemoteConfig = (Map) userRemoteConfigs.get(0) + // check for credential auto detection + assertEquals("CUSTOM_CREDENTIAL_ID", userRemoteConfig.get("credentialsId")) + // check for correct url + assertEquals("git@git-ssh.betterdomain.tld/group/project1.git", userRemoteConfig.get("url")) + } + + @Test + void shouldUsePassedUserRemoteConfigs() { + loadAndExecuteScript("vars/checkoutScm/jobs/checkoutScmCustomVariant2Job.groovy") + Map scmCheckoutCall = (Map) assertOnce(CHECKOUT) + assertEquals("Used SCM should be git", "GitSCM", scmCheckoutCall.get('$class')) + + List userRemoteConfigs = (List) scmCheckoutCall.get("userRemoteConfigs") + assertEquals("One userRemoteConfig expected", 1, userRemoteConfigs.size()) + Map userRemoteConfig = (Map) userRemoteConfigs.get(0) + // check for credential auto detection + assertEquals("USER_REMOTE_CONFIGS_CREDENTIAL", userRemoteConfig.get("credentialsId")) + // check for correct url + assertEquals("USER_REMOTE_CONFIGS_URL", userRemoteConfig.get("url")) + } + + @Test + void shouldUsePassedUserRemoteConfig() { + loadAndExecuteScript("vars/checkoutScm/jobs/checkoutScmCustomVariant3Job.groovy") + Map scmCheckoutCall = (Map) assertOnce(CHECKOUT) + assertEquals("Used SCM should be git", "GitSCM", scmCheckoutCall.get('$class')) + + List userRemoteConfigs = (List) scmCheckoutCall.get("userRemoteConfigs") + assertEquals("One userRemoteConfig expected", 1, userRemoteConfigs.size()) + Map userRemoteConfig = (Map) userRemoteConfigs.get(0) + // check for credential auto detection + assertEquals("USER_REMOTE_CONFIG_CREDENTIAL", userRemoteConfig.get("credentialsId")) + // check for correct url + assertEquals("USER_REMOTE_CONFIG_URL", userRemoteConfig.get("url")) + } + + @Test + void shouldCheckoutWithEmptyCredentials() { + loadAndExecuteScript("vars/checkoutScm/jobs/checkoutScmEmptyCredentialsJob.groovy") + Map scmCheckoutCall = (Map) assertOnce(CHECKOUT) + + assertEquals("Used SCM should be git", "GitSCM", scmCheckoutCall.get('$class')) + assertEquals("doGenerateSubmoduleConfigurations should be false", false, scmCheckoutCall.get("doGenerateSubmoduleConfigurations")) + assertEquals("'extensions' should have LocalBranch extension as default", [[$class: 'LocalBranch']], scmCheckoutCall.get("extensions")) + assertEquals("'submoduleCfg' should be empty", 0, scmCheckoutCall.get("submoduleCfg").size()) + + // test branches + List branches = scmCheckoutCall.get("branches") + assertEquals("[[name:*/master], [name:*/develop]]", branches.toString()) + + List userRemoteConfigs = (List) scmCheckoutCall.get("userRemoteConfigs") + assertEquals("One userRemoteConfig expected", 1, userRemoteConfigs.size()) + Map userRemoteConfig = (Map) userRemoteConfigs.get(0) + + // check for empty credentails + assertEquals("noCredentialsIdFound", userRemoteConfig[ConfigConstants.SCM_CREDENTIALS_ID] ?: "noCredentialsIdFound") + // check for correct url + assertEquals("git@unknowndomain.tld/group/project1.git", userRemoteConfig.get("url")) + } + + +} diff --git a/test/vars/checkoutScm/jobs/checkoutScmCustomVariant1Job.groovy b/test/vars/checkoutScm/jobs/checkoutScmCustomVariant1Job.groovy new file mode 100644 index 0000000..e34aa89 --- /dev/null +++ b/test/vars/checkoutScm/jobs/checkoutScmCustomVariant1Job.groovy @@ -0,0 +1,41 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.checkoutScm.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Executes a custom checkout with all supported configuration options that should not use the provided credentialsId + * + * @return The script + * @see vars.checkoutScm.CheckoutScmIT + */ +def execute() { + checkoutScm((SCM): [ + (SCM_URL) : "git@git-ssh.betterdomain.tld/group/project1.git", + (SCM_CREDENTIALS_ID) : 'CUSTOM_CREDENTIAL_ID', + (SCM_BRANCHES) : [[name: 'customBranch']], + (SCM_DO_GENERATE_SUBMODULE_CONFIGURATION): true, + (SCM_EXTENSIONS) : [[$class: 'CleanBeforeCheckout'], [$class: 'CloneOption', depth: 0, noTags: false, reference: '', shallow: true]], + (SCM_SUBMODULE_CONFIG) : [[$class: 'CustomSubModuleCfg']] + ]) +} + +return this diff --git a/test/vars/checkoutScm/jobs/checkoutScmCustomVariant2Job.groovy b/test/vars/checkoutScm/jobs/checkoutScmCustomVariant2Job.groovy new file mode 100644 index 0000000..ce9edf3 --- /dev/null +++ b/test/vars/checkoutScm/jobs/checkoutScmCustomVariant2Job.groovy @@ -0,0 +1,39 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.checkoutScm.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Executes a custom checkout with the provided userRemoteConfigs that should not use the provided credentialsId and the scm url + * + * @return The script + * + * @see vars.checkoutScm.CheckoutScmIT + */ +def execute() { + checkoutScm((SCM): [ + (SCM_CREDENTIALS_ID) : 'NOT_USED_CREDENTIAL', + (SCM_URL) : 'NOT_USED_URL', + (SCM_USER_REMOTE_CONFIGS): [[credentialsId: 'USER_REMOTE_CONFIGS_CREDENTIAL', url: 'USER_REMOTE_CONFIGS_URL']] + ]) +} + +return this diff --git a/test/vars/checkoutScm/jobs/checkoutScmCustomVariant3Job.groovy b/test/vars/checkoutScm/jobs/checkoutScmCustomVariant3Job.groovy new file mode 100644 index 0000000..c90e56e --- /dev/null +++ b/test/vars/checkoutScm/jobs/checkoutScmCustomVariant3Job.groovy @@ -0,0 +1,38 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.checkoutScm.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Executes a custom checkout with the provided userRemoteConfig that should not use the provided credentialsId + * + * @return The script + * @see vars.checkoutScm.CheckoutScmIT + */ +def execute() { + checkoutScm((SCM): [ + (SCM_URL) : "git@git-ssh.betterdomain.tld/group/project1.git", + (SCM_CREDENTIALS_ID) : "SHOULD_NOT_USE_ME", + (SCM_USER_REMOTE_CONFIG): [credentialsId: 'USER_REMOTE_CONFIG_CREDENTIAL', url: 'USER_REMOTE_CONFIG_URL'] + ]) +} + +return this diff --git a/test/vars/checkoutScm/jobs/checkoutScmDefaultsJob.groovy b/test/vars/checkoutScm/jobs/checkoutScmDefaultsJob.groovy new file mode 100644 index 0000000..22e4baf --- /dev/null +++ b/test/vars/checkoutScm/jobs/checkoutScmDefaultsJob.groovy @@ -0,0 +1,39 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.checkoutScm.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM_URL + +/** + * Executes default checkout with credential auto lookup for the given scm url + * + * @return The script + * @see vars.checkoutScm.CheckoutScmIT + */ +def execute() { + checkoutScm((SCM): + [ + (SCM_URL): "git@git-ssh.domain.tld/group/project1.git" + ] + ) +} + +return this diff --git a/test/vars/checkoutScm/jobs/checkoutScmEmptyCredentialsJob.groovy b/test/vars/checkoutScm/jobs/checkoutScmEmptyCredentialsJob.groovy new file mode 100644 index 0000000..fc3a284 --- /dev/null +++ b/test/vars/checkoutScm/jobs/checkoutScmEmptyCredentialsJob.groovy @@ -0,0 +1,37 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.checkoutScm.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM_URL + +/** + * Executes default checkout with no credential found during auto lookup for the given scm url + * + * @return The script + * @see vars.checkoutScm.CheckoutScmIT + */ +def execute() { + checkoutScm((SCM): [ + (SCM_URL): "git@unknowndomain.tld/group/project1.git" + ]) +} + +return this diff --git a/test/vars/execManagedShellScript/ExecManagedShellScriptIT.groovy b/test/vars/execManagedShellScript/ExecManagedShellScriptIT.groovy new file mode 100644 index 0000000..b2d1fc2 --- /dev/null +++ b/test/vars/execManagedShellScript/ExecManagedShellScriptIT.groovy @@ -0,0 +1,55 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execManagedShellScript + +import io.wcm.testing.jenkins.pipeline.LibraryIntegrationTestBase +import org.junit.Test + +import static io.wcm.testing.jenkins.pipeline.StepConstants.SH +import static io.wcm.testing.jenkins.pipeline.recorder.StepRecorderAssert.assertTwice +import static org.junit.Assert.assertEquals + +class ExecManagedShellScriptIT extends LibraryIntegrationTestBase { + + @Test + void shouldCallWithOneListItem() { + loadAndExecuteScript("vars/execManagedShellScript/jobs/execMangedShellScriptVariant1Test.groovy") + List actualShellCalls = (List) assertTwice(SH) + assertEquals("chmod +x /path/to/workspace@tmp/some-file-id-variant1", actualShellCalls.get(0)) + assertEquals([returnStdout: true, script: "/path/to/workspace@tmp/some-file-id-variant1 oneArg"], actualShellCalls.get(1)) + } + + @Test + void shouldCallWithMultipleListItems() { + loadAndExecuteScript("vars/execManagedShellScript/jobs/execMangedShellScriptVariant2Test.groovy") + List actualShellCalls = (List) assertTwice(SH) + assertEquals("chmod +x /path/to/workspace@tmp/some-file-id-variant2", actualShellCalls.get(0)) + assertEquals([returnStdout: true, script: "/path/to/workspace@tmp/some-file-id-variant2 argOne argTwo argThree=value"], actualShellCalls.get(1)) + } + + @Test + void shouldCallWithArgLine() { + loadAndExecuteScript("vars/execManagedShellScript/jobs/execMangedShellScriptVariant3Test.groovy") + List actualShellCalls = (List) assertTwice(SH) + assertEquals("chmod +x /path/to/workspace@tmp/some-file-id-variant3", actualShellCalls.get(0)) + assertEquals([returnStdout: true, script: "/path/to/workspace@tmp/some-file-id-variant3 customArgLine prop1=value1 -Dtest"], actualShellCalls.get(1)) + } + +} diff --git a/test/vars/execManagedShellScript/jobs/execMangedShellScriptVariant1Test.groovy b/test/vars/execManagedShellScript/jobs/execMangedShellScriptVariant1Test.groovy new file mode 100644 index 0000000..2ad8b1e --- /dev/null +++ b/test/vars/execManagedShellScript/jobs/execMangedShellScriptVariant1Test.groovy @@ -0,0 +1,32 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execManagedShellScript.jobs + +/** + * Executes a managed shell script with one argument + * + * @return The script + * @see vars.execManagedShellScript.ExecManagedShellScriptIT + */ +def execute() { + execManagedShellScript('some-file-id-variant1', ["oneArg"]) +} + +return this diff --git a/test/vars/execManagedShellScript/jobs/execMangedShellScriptVariant2Test.groovy b/test/vars/execManagedShellScript/jobs/execMangedShellScriptVariant2Test.groovy new file mode 100644 index 0000000..0bc58f6 --- /dev/null +++ b/test/vars/execManagedShellScript/jobs/execMangedShellScriptVariant2Test.groovy @@ -0,0 +1,32 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execManagedShellScript.jobs + +/** + * Executes a managed shell script with two flag and one value argument + * + * @return The script + * @see vars.execManagedShellScript.ExecManagedShellScriptIT + */ +def execute() { + execManagedShellScript('some-file-id-variant2', ["argOne", "argTwo", "argThree=value"]) +} + +return this diff --git a/test/vars/execManagedShellScript/jobs/execMangedShellScriptVariant3Test.groovy b/test/vars/execManagedShellScript/jobs/execMangedShellScriptVariant3Test.groovy new file mode 100644 index 0000000..3c3ff4a --- /dev/null +++ b/test/vars/execManagedShellScript/jobs/execMangedShellScriptVariant3Test.groovy @@ -0,0 +1,32 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execManagedShellScript.jobs + +/** + * Executes a managed shell script with custom argument line + * + * @return The script, + * @see vars.execManagedShellScript.ExecManagedShellScriptIT + */ +def execute() { + execManagedShellScript("some-file-id-variant3", "customArgLine prop1=value1 -Dtest") +} + +return this diff --git a/test/vars/execMaven/ExecMavenIT.groovy b/test/vars/execMaven/ExecMavenIT.groovy new file mode 100644 index 0000000..a6e833d --- /dev/null +++ b/test/vars/execMaven/ExecMavenIT.groovy @@ -0,0 +1,123 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execMaven + +import io.wcm.testing.jenkins.pipeline.LibraryIntegrationTestBase +import io.wcm.tooling.jenkins.pipeline.environment.EnvironmentConstants +import io.wcm.tooling.jenkins.pipeline.managedfiles.ManagedFileConstants +import org.junit.Test + +import static io.wcm.testing.jenkins.pipeline.recorder.StepRecorderAssert.assertOneShellCommand +import static org.junit.Assert.assertEquals + +class ExecMavenIT extends LibraryIntegrationTestBase { + + protected String expectedCommand = null + + @Override + void setUp() throws Exception { + super.setUp() + } + + @Test + void shouldExecuteWithGlobalSettings() { + expectedCommand = "mvn --global-settings /path/to/workspace@tmp/ssh-or-https-better-match-id" + loadAndExecuteScript("vars/execMaven/jobs/execMavenGlobalSettingsJob.groovy") + assertOneShellCommand(expectedCommand) + } + + @Test + void shouldExecuteWithLocalSettings() { + expectedCommand = "mvn --settings /path/to/workspace@tmp/BETTER_DOMAIN_MVN_SETTINGS" + loadAndExecuteScript("vars/execMaven/jobs/execMavenLocalSettingsJob.groovy") + assertOneShellCommand(expectedCommand) + } + + @Test + void shouldExecuteWithDefaults() { + expectedCommand = "mvn" + loadAndExecuteScript("vars/execMaven/jobs/execMavenDefaultJob.groovy") + assertOneShellCommand(expectedCommand) + } + + @Test + void shouldExecuteWithGlobalAndLocalSetting() { + expectedCommand = "mvn --global-settings /path/to/workspace@tmp/EVEN_BETTER_DOMAIN_MVN_GLOBAL_SETTINGS_ID --settings /path/to/workspace@tmp/EVEN_BETTER_DOMAIN_MVN_SETTINGS" + loadAndExecuteScript("vars/execMaven/jobs/execMavenGlobalAndLocalSettingsJob.groovy") + + assertOneShellCommand(expectedCommand) + } + + @Test + void shouldExecuteWithCustomConfigVariant1() { + + expectedCommand = "mvn -f path/to/customPom1.xml customGoal1 customGoal2 -B -U -DdefineValue1=true -DdefineFlag1 --global-settings /path/to/workspace@tmp/CUSTOM_GLOBAL_SETTINGS_VARIANT1 --settings /path/to/workspace@tmp/CUSTOM_SETTINGS_VARIANT1" + loadAndExecuteScript("vars/execMaven/jobs/execMavenCustomVariant1Job.groovy") + + assertOneShellCommand(expectedCommand) + } + + @Test + void shouldExecuteWithCustomConfigVariant2() { + + expectedCommand = "mvn -f path/to/customPom2.xml customGoal3 customGoal4 -B -U -DdefineValue2=true -DdefineFlag2 --global-settings /path/to/workspace@tmp/CUSTOM_GLOBAL_SETTINGS_VARIANT2 --settings /path/to/workspace@tmp/CUSTOM_SETTINGS_VARIANT2" + loadAndExecuteScript("vars/execMaven/jobs/execMavenCustomVariant2Job.groovy") + + assertOneShellCommand(expectedCommand) + } + + @Test + void shouldExecMavenWithSettingsViaScmUrlFromEnvJob() { + this.setEnv(EnvironmentConstants.SCM_URL, "https://subdomain.evenbetterdomain.tld/group1/project2.git") + expectedCommand = "mvn --global-settings /path/to/workspace@tmp/EVEN_BETTER_DOMAIN_MVN_GLOBAL_SETTINGS_ID --settings /path/to/workspace@tmp/EVEN_BETTER_DOMAIN_MVN_SETTINGS" + loadAndExecuteScript("vars/execMaven/jobs/execMavenWithSettingsViaScmUrlFromEnvJob.groovy") + assertOneShellCommand(expectedCommand) + } + + @Test + void shouldExecMavenWithNPMAndRubyEnvVarsJob() { + expectedCommand = "mvn" + loadAndExecuteScript("vars/execMaven/jobs/execMavenWithNPMAndRubyTestJob.groovy") + assertOneShellCommand(expectedCommand) + // check if env was set correctly + assertEquals("/path/to/workspace@tmp/npmrc-project1-id", getEnv(ManagedFileConstants.NPMRC_ENV)) + assertEquals("/path/to/workspace@tmp/npm-user-config-project1-id", getEnv(ManagedFileConstants.NPM_CONFIG_USERCONFIG_ENV)) + assertEquals("/path/to/workspace@tmp/bundle-config-project1-id", getEnv(ManagedFileConstants.BUNDLE_CONFIG_ENV)) + } + + @Test + void shouldExecuteWithCustomExecutable() { + + expectedCommand = "time /path/to/custom/maven -f path/to/customPom1.xml customGoal1 customGoal2" + loadAndExecuteScript("vars/execMaven/jobs/execMavenCustomCommandTestJob.groovy") + + assertOneShellCommand(expectedCommand) + } + + @Test + void shouldExecuteWithBuildParameters() { + this.setParams([choiceParam: "choice1", boolParam: true, stringParam: "text"]) + expectedCommand = "mvn clean verify -DchoiceParam=choice1 -DboolParam=true -DstringParam=text --global-settings /path/to/workspace@tmp/EVEN_BETTER_DOMAIN_MVN_GLOBAL_SETTINGS_ID" + loadAndExecuteScript("vars/execMaven/jobs/execMavenWithBuildParametersTestJob.groovy") + + assertOneShellCommand(expectedCommand) + } + +} diff --git a/test/vars/execMaven/jobs/execMavenCustomCommandTestJob.groovy b/test/vars/execMaven/jobs/execMavenCustomCommandTestJob.groovy new file mode 100644 index 0000000..2e704cc --- /dev/null +++ b/test/vars/execMaven/jobs/execMavenCustomCommandTestJob.groovy @@ -0,0 +1,41 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execMaven.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs execMaven step with path to custom maven executable + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + execMaven( + (SCM): [(SCM_URL): "https://subdomain.domain-new.tld/group/project1.git"], + (MAVEN): [ + (MAVEN_EXECUTABLE): "time /path/to/custom/maven", + (MAVEN_POM) : "path/to/customPom1.xml", + (MAVEN_GOALS) : ["customGoal1", "customGoal2"] + ] + ) +} + +return this diff --git a/test/vars/execMaven/jobs/execMavenCustomVariant1Job.groovy b/test/vars/execMaven/jobs/execMavenCustomVariant1Job.groovy new file mode 100644 index 0000000..035e74e --- /dev/null +++ b/test/vars/execMaven/jobs/execMavenCustomVariant1Job.groovy @@ -0,0 +1,44 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execMaven.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs execMaven step with custom configuration where goals, defines and arguments are represented by a list + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + execMaven( + (SCM): [(SCM_URL): "https://subdomain.domain-new.tld/group/project1.git"], + (MAVEN): [ + (MAVEN_POM) : "path/to/customPom1.xml", + (MAVEN_GOALS) : ["customGoal1", "customGoal2"], + (MAVEN_DEFINES) : ["defineValue1": true, "defineFlag1": null], + (MAVEN_GLOBAL_SETTINGS): "CUSTOM_GLOBAL_SETTINGS_VARIANT1", + (MAVEN_SETTINGS) : "CUSTOM_SETTINGS_VARIANT1", + (MAVEN_ARGUMENTS) : ["-B", "-U"] + ] + ) +} + +return this diff --git a/test/vars/execMaven/jobs/execMavenCustomVariant2Job.groovy b/test/vars/execMaven/jobs/execMavenCustomVariant2Job.groovy new file mode 100644 index 0000000..0101eb6 --- /dev/null +++ b/test/vars/execMaven/jobs/execMavenCustomVariant2Job.groovy @@ -0,0 +1,44 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execMaven.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs execMaven step with custom configuration where goals, defines and arguments are represented by strings + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + execMaven( + (SCM): [(SCM_URL): "https://subdomain.domain-new.tld/group/project1.git"], + (MAVEN): [ + (MAVEN_POM) : "path/to/customPom2.xml", + (MAVEN_GOALS) : "customGoal3 customGoal4", + (MAVEN_DEFINES) : "-DdefineValue2=true -DdefineFlag2", + (MAVEN_GLOBAL_SETTINGS): "CUSTOM_GLOBAL_SETTINGS_VARIANT2", + (MAVEN_SETTINGS) : "CUSTOM_SETTINGS_VARIANT2", + (MAVEN_ARGUMENTS) : "-B -U" + ] + ) +} + +return this diff --git a/test/vars/execMaven/jobs/execMavenDefaultJob.groovy b/test/vars/execMaven/jobs/execMavenDefaultJob.groovy new file mode 100644 index 0000000..9f18285 --- /dev/null +++ b/test/vars/execMaven/jobs/execMavenDefaultJob.groovy @@ -0,0 +1,36 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execMaven.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM_URL + +/** + * Runs execMaven step with default configuration by providing only the scm url, no global or local maven settings expected + * from auto lookup here + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + execMaven((SCM): [(SCM_URL): "https://subdomain.domain-new.tld/group/project1.git"]) +} + +return this diff --git a/test/vars/execMaven/jobs/execMavenGlobalAndLocalSettingsJob.groovy b/test/vars/execMaven/jobs/execMavenGlobalAndLocalSettingsJob.groovy new file mode 100644 index 0000000..3824d0d --- /dev/null +++ b/test/vars/execMaven/jobs/execMavenGlobalAndLocalSettingsJob.groovy @@ -0,0 +1,35 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execMaven.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM_URL + +/** + * Runs execMaven step with default configuration to test auto lookup for global and local maven settings + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + execMaven((SCM): [(SCM_URL): "https://subdomain.evenbetterdomain.tld/group1/project2.git"]) +} + +return this diff --git a/test/vars/execMaven/jobs/execMavenGlobalSettingsJob.groovy b/test/vars/execMaven/jobs/execMavenGlobalSettingsJob.groovy new file mode 100644 index 0000000..1ceb9bc --- /dev/null +++ b/test/vars/execMaven/jobs/execMavenGlobalSettingsJob.groovy @@ -0,0 +1,35 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execMaven.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM_URL + +/** + * Runs execMaven step with default configuration to test auto lookup for global maven settings only + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + execMaven((SCM): [(SCM_URL): "https://subdomain.domain.tld/group/project1.git"]) +} + +return this diff --git a/test/vars/execMaven/jobs/execMavenLocalSettingsJob.groovy b/test/vars/execMaven/jobs/execMavenLocalSettingsJob.groovy new file mode 100644 index 0000000..e98fcfb --- /dev/null +++ b/test/vars/execMaven/jobs/execMavenLocalSettingsJob.groovy @@ -0,0 +1,35 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execMaven.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM_URL + +/** + * Runs execMaven step with default configuration to test auto lookup for local maven settings only + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + execMaven((SCM): [(SCM_URL): "https://subdomain.betterdomain.tld/group1/project2.git"]) +} + +return this diff --git a/test/vars/execMaven/jobs/execMavenWithBuildParametersTestJob.groovy b/test/vars/execMaven/jobs/execMavenWithBuildParametersTestJob.groovy new file mode 100644 index 0000000..7a42e71 --- /dev/null +++ b/test/vars/execMaven/jobs/execMavenWithBuildParametersTestJob.groovy @@ -0,0 +1,46 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execMaven.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.MAVEN +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.MAVEN_GOALS +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.MAVEN_INJECT_PARAMS +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM_URL + +/** + * Runs execMaven step with default configuration to test auto lookup for global and local maven settings + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + execMaven( + (SCM): [ + (SCM_URL): "https://subdomain.evenbetterdomain.tld/group3/project1.git" + ], + (MAVEN): [ + (MAVEN_GOALS) : ["clean", "verify"], + (MAVEN_INJECT_PARAMS): true + ] + ) +} + +return this diff --git a/test/vars/execMaven/jobs/execMavenWithNPMAndRubyTestJob.groovy b/test/vars/execMaven/jobs/execMavenWithNPMAndRubyTestJob.groovy new file mode 100644 index 0000000..f7e59c4 --- /dev/null +++ b/test/vars/execMaven/jobs/execMavenWithNPMAndRubyTestJob.groovy @@ -0,0 +1,38 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execMaven.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM_URL + +/** + * Runs execMaven step with default configuration and the configFile auto lookup should add local and global maven settings, + * NPM and Ruby specific configuration files via environment variables + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + execMaven( + (SCM): [(SCM_URL): "https://subdomain.npm-domain.tld/group/project1.git"], + ) +} + +return this diff --git a/test/vars/execMaven/jobs/execMavenWithSettingsViaScmUrlFromEnvJob.groovy b/test/vars/execMaven/jobs/execMavenWithSettingsViaScmUrlFromEnvJob.groovy new file mode 100644 index 0000000..e53549e --- /dev/null +++ b/test/vars/execMaven/jobs/execMavenWithSettingsViaScmUrlFromEnvJob.groovy @@ -0,0 +1,32 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execMaven.jobs + +/** + * Runs execMaven step without providing the scm url, so execMaven should fallback to SCM_URL environment variable + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + execMaven() +} + +return this diff --git a/test/vars/execMavenRelease/ExecMavenReleaseIT.groovy b/test/vars/execMavenRelease/ExecMavenReleaseIT.groovy new file mode 100644 index 0000000..64bb21b --- /dev/null +++ b/test/vars/execMavenRelease/ExecMavenReleaseIT.groovy @@ -0,0 +1,130 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execMavenRelease + +import hudson.AbortException +import io.wcm.testing.jenkins.pipeline.LibraryIntegrationTestBase +import io.wcm.testing.jenkins.pipeline.StepConstants +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExpectedException + +import static io.wcm.testing.jenkins.pipeline.recorder.StepRecorderAssert.assertNone +import static io.wcm.testing.jenkins.pipeline.recorder.StepRecorderAssert.assertOnce +import static io.wcm.testing.jenkins.pipeline.recorder.StepRecorderAssert.assertOneShellCommand +import static io.wcm.testing.jenkins.pipeline.recorder.StepRecorderAssert.assertTwice +import static org.junit.Assert.assertEquals + +class ExecMavenReleaseIT extends LibraryIntegrationTestBase { + + @Rule + public ExpectedException expectedEx = ExpectedException.none() + + @Override + void setUp() throws Exception { + super.setUp() + this.dslMock.mockResource("effective-pom.tmp", "mavenRelease/valid-effective-pom.xml") + } + + @Test + void shouldFailWhenScmUrlIsNull() throws AbortException { + expectedEx.expect(AbortException.class) + expectedEx.expectMessage("Unable to retrieve SCM url") + + loadAndExecuteScript("vars/execMavenRelease/jobs/shouldFailWhenScmUrlIsNullTestJob.groovy") + assertNone(StepConstants.SH) + assertNone(StepConstants.SSH_AGENT) + } + + @Test + void shouldFailWhenScmUrlIsNotGitSSH() { + expectedEx.expect(AbortException.class) + expectedEx.expectMessage("Invalid SCM url") + + loadAndExecuteScript("vars/execMavenRelease/jobs/shouldFailWhenScmUrlIsNotGitSSHTestJob.groovy") + assertNone(StepConstants.SH) + assertNone(StepConstants.SSH_AGENT) + } + + @Test + void shouldFailWithNoBranchEnvVar() { + expectedEx.expect(AbortException.class) + expectedEx.expectMessage("Unable to retrieve 'GIT_BRANCH' environment variable") + + loadAndExecuteScript("vars/execMavenRelease/jobs/shouldFailWithNoBranchEnvVarTestJob.groovy") + assertNone(StepConstants.SH) + assertNone(StepConstants.SSH_AGENT) + } + + @Test + void shouldFailWithNotAllowedBranchName() { + expectedEx.expect(AbortException.class) + expectedEx.expectMessage("Not allowed branch detected.") + + loadAndExecuteScript("vars/execMavenRelease/jobs/shouldFailWithNotAllowedBranchNameTestJob.groovy") + assertNone(StepConstants.SH) + assertNone(StepConstants.SSH_AGENT) + } + + @Test + void shouldFailDueToWrongMavenReleasePluginVersion() { + this.dslMock.mockResource("effective-pom.tmp", "mavenRelease/invalid-maven-release-version-pom.xml") + expectedEx.expect(AbortException.class) + expectedEx.expectMessage("org.apache.maven.plugins:maven-release-plugin version requirement not met.") + + loadAndExecuteScript("vars/execMavenRelease/jobs/shouldExecMavenReleaseWithKeyAgentTestJob.groovy") + + // assert that the sshagent step was called once + List keyAgentCredentialList = (List) assertOnce(StepConstants.SSH_AGENT) + assertEquals("provided ssh credentials are wrong", ['ssh-git-push-credentials-id'], keyAgentCredentialList) + + List shellCommands = assertTwice(StepConstants.SH) + assertEquals(["mvn help:effective-pom -B -U -Doutput=effective-pom.tmp", "mvn release:prepare release:perform -B -U"], shellCommands) + } + + /*@Test + void shouldFailDueToWrongScmProviderGitExeVersion() { + this.dslMock.mockResource("effective-pom.tmp","mavenRelease/invalid-maven-scm-provider-gitexe-pom.xml") + + loadAndExecuteScript("vars/execMavenRelease/jobs/shouldExecMavenReleaseWithKeyAgentTestJob.groovy") + expectedEx.expect(AbortException.class) + expectedEx.expectMessage("org.apache.maven.scm:maven-scm-provider-gitexe versionNumber requirement not met.") + + // assert that the sshagent step was called once + List keyAgentCredentialList = (List) assertOnce(StepConstants.SSH_AGENT) + assertEquals("provided ssh credentials are wrong", ['ssh-git-push-credentials-id'], keyAgentCredentialList) + + List shellCommands = assertTwice(StepConstants.SH) + assertEquals(["mvn help:effective-pom -B -U -Doutput=effective-pom.tmp", "mvn release:prepare release:perform -B -U"], shellCommands) + }*/ + + @Test + void shouldExecMavenReleaseWithKeyAgent() { + loadAndExecuteScript("vars/execMavenRelease/jobs/shouldExecMavenReleaseWithKeyAgentTestJob.groovy") + + // assert that the sshagent step was called once + List keyAgentCredentialList = (List) assertOnce(StepConstants.SSH_AGENT) + assertEquals("provided ssh credentials are wrong", ['ssh-git-push-credentials-id'], keyAgentCredentialList) + + List shellCommands = assertTwice(StepConstants.SH) + assertEquals(["mvn help:effective-pom -B -U -Doutput=effective-pom.tmp", "mvn release:prepare release:perform -B -U"], shellCommands) + } + +} diff --git a/test/vars/execMavenRelease/jobs/shouldExecMavenReleaseWithKeyAgentTestJob.groovy b/test/vars/execMavenRelease/jobs/shouldExecMavenReleaseWithKeyAgentTestJob.groovy new file mode 100644 index 0000000..c1a1cc7 --- /dev/null +++ b/test/vars/execMavenRelease/jobs/shouldExecMavenReleaseWithKeyAgentTestJob.groovy @@ -0,0 +1,37 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execMavenRelease.jobs + +import io.wcm.tooling.jenkins.pipeline.environment.EnvironmentConstants + +/** + * Runs execMavenRelease step with not supported https url + * + * @return The script + * @see vars.execMavenRelease.ExecMavenReleaseIT + */ +def execute() { + env.setProperty(EnvironmentConstants.GIT_BRANCH, "master") + env.setProperty(EnvironmentConstants.SCM_URL, "git@git-ssh.domain.tld:group/project.git") + String test = env.getProperty(EnvironmentConstants.SCM_URL) + execMavenRelease() +} + +return this diff --git a/test/vars/execMavenRelease/jobs/shouldFailWhenScmUrlIsNotGitSSHTestJob.groovy b/test/vars/execMavenRelease/jobs/shouldFailWhenScmUrlIsNotGitSSHTestJob.groovy new file mode 100644 index 0000000..0e40427 --- /dev/null +++ b/test/vars/execMavenRelease/jobs/shouldFailWhenScmUrlIsNotGitSSHTestJob.groovy @@ -0,0 +1,36 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execMavenRelease.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs execMavenRelease step with not supported https url + * + * @return The script + * @see vars.execMavenRelease.ExecMavenReleaseIT + */ +def execute() { + execMavenRelease( + (SCM): [(SCM_URL): "https://subdomain.domain-new.tld/group/project1.git"], + ) +} + +return this diff --git a/test/vars/execMavenRelease/jobs/shouldFailWhenScmUrlIsNullTestJob.groovy b/test/vars/execMavenRelease/jobs/shouldFailWhenScmUrlIsNullTestJob.groovy new file mode 100644 index 0000000..721268c --- /dev/null +++ b/test/vars/execMavenRelease/jobs/shouldFailWhenScmUrlIsNullTestJob.groovy @@ -0,0 +1,38 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execMavenRelease.jobs + +import io.wcm.tooling.jenkins.pipeline.environment.EnvironmentConstants + +/** + * Runs execMavenRelease step with not supported https url + * + * @return The script + * @see vars.execMavenRelease.ExecMavenReleaseIT + */ +def execute() { + env.setProperty(EnvironmentConstants.SCM_URL, null) + String test = env.getProperty(EnvironmentConstants.SCM_URL) + execMavenRelease( + + ) +} + +return this diff --git a/test/vars/execMavenRelease/jobs/shouldFailWithNoBranchEnvVarTestJob.groovy b/test/vars/execMavenRelease/jobs/shouldFailWithNoBranchEnvVarTestJob.groovy new file mode 100644 index 0000000..a2012a7 --- /dev/null +++ b/test/vars/execMavenRelease/jobs/shouldFailWithNoBranchEnvVarTestJob.groovy @@ -0,0 +1,39 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execMavenRelease.jobs + +import io.wcm.tooling.jenkins.pipeline.environment.EnvironmentConstants + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs execMavenRelease step with not supported https url + * + * @return The script + * @see vars.execMavenRelease.ExecMavenReleaseIT + */ +def execute() { + env.setProperty(EnvironmentConstants.GIT_BRANCH, null) + execMavenRelease( + (SCM): [(SCM_URL): "git@git-ssh.domain.tld:group/project.git"], + ) +} + +return this diff --git a/test/vars/execMavenRelease/jobs/shouldFailWithNotAllowedBranchNameTestJob.groovy b/test/vars/execMavenRelease/jobs/shouldFailWithNotAllowedBranchNameTestJob.groovy new file mode 100644 index 0000000..15da568 --- /dev/null +++ b/test/vars/execMavenRelease/jobs/shouldFailWithNotAllowedBranchNameTestJob.groovy @@ -0,0 +1,40 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execMavenRelease.jobs + +import io.wcm.tooling.jenkins.pipeline.environment.EnvironmentConstants + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM_URL + +/** + * Runs execMavenRelease step with not supported https url + * + * @return The script + * @see vars.execMavenRelease.ExecMavenReleaseIT + */ +def execute() { + env.setProperty(EnvironmentConstants.GIT_BRANCH, "develop") + execMavenRelease( + (SCM): [(SCM_URL): "git@git-ssh.domain.tld:group/project.git"], + ) +} + +return this diff --git a/test/vars/execNpm/ExecNpmIT.groovy b/test/vars/execNpm/ExecNpmIT.groovy new file mode 100644 index 0000000..f4533ca --- /dev/null +++ b/test/vars/execNpm/ExecNpmIT.groovy @@ -0,0 +1,48 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execNpm + +import io.wcm.testing.jenkins.pipeline.LibraryIntegrationTestBase +import io.wcm.tooling.jenkins.pipeline.managedfiles.ManagedFileConstants +import org.junit.Assert +import org.junit.Test + +import static io.wcm.testing.jenkins.pipeline.recorder.StepRecorderAssert.assertOneShellCommand + +class ExecNpmIT extends LibraryIntegrationTestBase { + + protected String expectedCommand = null + + @Test + void shouldRunWithDefaults() { + expectedCommand = "npm" + loadAndExecuteScript("vars/execNpm/jobs/execNpmDefaultTestJob.groovy") + assertOneShellCommand(expectedCommand) + } + + @Test + void shouldRunWithCustomAndAutoLookup() { + expectedCommand = "/path/to/custom/npm run build -flag --property=value --userconfig /path/to/workspace@tmp/npm-user-config-id --globalconfig /path/to/workspace@tmp/npmrc-id" + loadAndExecuteScript("vars/execNpm/jobs/execNpmCustomAndAutoLookupTestJob.groovy") + assertOneShellCommand(expectedCommand) + // check if npm config userconfig was automatically provived by setting environment variable + Assert.assertEquals(WORKSPACE_TMP_PATH.concat("npm-user-config-id"), getEnv(ManagedFileConstants.NPM_CONF_USERCONFIG_ENV)) + } +} diff --git a/test/vars/execNpm/jobs/execNpmCustomAndAutoLookupTestJob.groovy b/test/vars/execNpm/jobs/execNpmCustomAndAutoLookupTestJob.groovy new file mode 100644 index 0000000..5c4c320 --- /dev/null +++ b/test/vars/execNpm/jobs/execNpmCustomAndAutoLookupTestJob.groovy @@ -0,0 +1,41 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execNpm.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs execNpm step with default configuration by providing only the scm url + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + execNpm( + (SCM): [ + (SCM_URL): "https://subdomain.npm-domain.tld/group/some-project.git" + ], + (NPM): [ + (NPM_EXECUTABLE): "/path/to/custom/npm", + (NPM_ARGUMENTS) : ["run", "build", "-flag", "--property=value"] + ]) +} + +return this diff --git a/test/vars/execNpm/jobs/execNpmDefaultTestJob.groovy b/test/vars/execNpm/jobs/execNpmDefaultTestJob.groovy new file mode 100644 index 0000000..807f13b --- /dev/null +++ b/test/vars/execNpm/jobs/execNpmDefaultTestJob.groovy @@ -0,0 +1,35 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.execNpm.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM_URL + +/** + * Runs execNpm step with default configuration by providing only the scm url + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + execNpm((SCM): [(SCM_URL): "https://subdomain.domain-new.tld/group/project1.git"]) +} + +return this diff --git a/test/vars/getScmUrl/GetScmUrlIT.groovy b/test/vars/getScmUrl/GetScmUrlIT.groovy new file mode 100644 index 0000000..d498764 --- /dev/null +++ b/test/vars/getScmUrl/GetScmUrlIT.groovy @@ -0,0 +1,39 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.getScmUrl + +import io.wcm.testing.jenkins.pipeline.LibraryIntegrationTestBase +import org.junit.Assert +import org.junit.Test + +class GetScmUrlIT extends LibraryIntegrationTestBase { + + @Test + void shouldUseScmConfig() { + String actualScmUrl = loadAndExecuteScript("vars/getScmUrl/jobs/getScmUrlFromConfigTestJob.groovy") + Assert.assertEquals("scm-url-from-scm-config", actualScmUrl) + } + + @Test + void shouldUseEnvVar() { + String actualScmUrl = loadAndExecuteScript("vars/getScmUrl/jobs/getScmUrlFromEnvVarTestJob.groovy") + Assert.assertEquals("scm-url-from-env-var", actualScmUrl) + } +} diff --git a/test/vars/getScmUrl/jobs/getScmUrlFromConfigTestJob.groovy b/test/vars/getScmUrl/jobs/getScmUrlFromConfigTestJob.groovy new file mode 100644 index 0000000..889c504 --- /dev/null +++ b/test/vars/getScmUrl/jobs/getScmUrlFromConfigTestJob.groovy @@ -0,0 +1,39 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.getScmUrl.jobs + +import io.wcm.tooling.jenkins.pipeline.environment.EnvironmentConstants + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM_URL + + +/** + * Runs execNpm step with default configuration by providing only the scm url + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + env.setProperty(EnvironmentConstants.SCM_URL, "scm-url-from-env-var") + return getScmUrl((SCM): [(SCM_URL): "scm-url-from-scm-config"]) +} + +return this diff --git a/test/vars/getScmUrl/jobs/getScmUrlFromEnvVarTestJob.groovy b/test/vars/getScmUrl/jobs/getScmUrlFromEnvVarTestJob.groovy new file mode 100644 index 0000000..7316f83 --- /dev/null +++ b/test/vars/getScmUrl/jobs/getScmUrlFromEnvVarTestJob.groovy @@ -0,0 +1,37 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.getScmUrl.jobs + +import io.wcm.tooling.jenkins.pipeline.environment.EnvironmentConstants + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM + +/** + * Runs execNpm step with default configuration by providing only the scm url + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + env.setProperty(EnvironmentConstants.SCM_URL, "scm-url-from-env-var") + return getScmUrl((SCM): [:]) +} + +return this diff --git a/test/vars/logging/LoggingIT.groovy b/test/vars/logging/LoggingIT.groovy new file mode 100644 index 0000000..60de304 --- /dev/null +++ b/test/vars/logging/LoggingIT.groovy @@ -0,0 +1,32 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.logging + +import io.wcm.testing.jenkins.pipeline.LibraryIntegrationTestBase +import org.junit.Test + +class LoggingIT extends LibraryIntegrationTestBase { + + @Test + void shouldInitializeWithinITEnvironment() { + loadAndExecuteScript("vars/logging/jobs/shouldInitializeWithinITEnvironmentTestJob.groovy") + } + +} diff --git a/test/vars/logging/jobs/shouldInitializeWithinITEnvironmentTestJob.groovy b/test/vars/logging/jobs/shouldInitializeWithinITEnvironmentTestJob.groovy new file mode 100644 index 0000000..b2f7edb --- /dev/null +++ b/test/vars/logging/jobs/shouldInitializeWithinITEnvironmentTestJob.groovy @@ -0,0 +1,40 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.logging.jobs + +import io.wcm.tooling.jenkins.pipeline.environment.EnvironmentConstants +import io.wcm.tooling.jenkins.pipeline.utils.logging.LogLevel +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCM_URL + + +/** + * Runs execNpm step with default configuration by providing only the scm url + * + * @return The script + * @see vars.execMaven.ExecMavenIT + */ +def execute() { + Logger.init(this, LogLevel.INFO) +} + +return this diff --git a/test/vars/notifyMail/NotifyMailCustomIT.groovy b/test/vars/notifyMail/NotifyMailCustomIT.groovy new file mode 100644 index 0000000..adf9f15 --- /dev/null +++ b/test/vars/notifyMail/NotifyMailCustomIT.groovy @@ -0,0 +1,115 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.notifyMail + +import hudson.model.Result +import io.wcm.testing.jenkins.pipeline.LibraryIntegrationTestBase +import org.junit.Test + +import static io.wcm.testing.jenkins.pipeline.StepConstants.EMAILEXT +import static io.wcm.testing.jenkins.pipeline.recorder.StepRecorderAssert.assertNone +import static io.wcm.testing.jenkins.pipeline.recorder.StepRecorderAssert.assertOnce +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +import static org.junit.Assert.assertEquals + +class NotifyMailCustomIT extends LibraryIntegrationTestBase { + + @Override + void setUp() throws Exception { + super.setUp() + this.setEnv("BUILD_NUMBER", "2") + this.setEnv("GIT_BRANCH", "DETECTED_GIT_BRANCH") + } + + @Test + void shouldNotifyOnSuccess() { + this.runWrapper.setResult(Result.SUCCESS.toString()) + loadAndExecuteScript("vars/notifyMail/jobs/notifyMailCustomJob.groovy") + Map extmailCall = assertOnce(EMAILEXT) + assertCorrectExtmailCall(extmailCall) + } + + @Test + void shouldNotifyOnAbort() { + this.runWrapper.setResult(Result.ABORTED.toString()) + loadAndExecuteScript("vars/notifyMail/jobs/notifyMailCustomJob.groovy") + assertOnce(EMAILEXT) + } + + @Test + void shouldNotNotifyOnNotBuild() { + this.runWrapper.setResult(Result.NOT_BUILT.toString()) + loadAndExecuteScript("vars/notifyMail/jobs/notifyMailCustomJob.groovy") + assertNone(EMAILEXT) + } + + @Test + void shouldNotNotifyOnFixed() { + this.runWrapper.setPreviousBuildResult(Result.UNSTABLE.toString()) + this.runWrapper.setResult(Result.SUCCESS.toString()) + loadAndExecuteScript("vars/notifyMail/jobs/notifyMailCustomJob.groovy") + assertNone(EMAILEXT) + } + + @Test + void shouldNotNotifyOnUnstable() { + this.runWrapper.setPreviousBuildResult(Result.SUCCESS.toString()) + this.runWrapper.setResult(Result.UNSTABLE.toString()) + loadAndExecuteScript("vars/notifyMail/jobs/notifyMailCustomJob.groovy") + assertNone(EMAILEXT) + } + + @Test + void shouldNotNotifyOnStillUnstable() { + this.runWrapper.setPreviousBuildResult(Result.UNSTABLE.toString()) + this.runWrapper.setResult(Result.UNSTABLE.toString()) + loadAndExecuteScript("vars/notifyMail/jobs/notifyMailCustomJob.groovy") + assertNone(EMAILEXT) + } + + @Test + void shouldNotNotifyOnFailure() { + this.runWrapper.setResult(Result.FAILURE.toString()) + loadAndExecuteScript("vars/notifyMail/jobs/notifyMailCustomJob.groovy") + assertNone(EMAILEXT) + } + + @Test + void shouldNotNotifyOnStillFailing() { + this.runWrapper.setResult(Result.FAILURE.toString()) + this.runWrapper.setPreviousBuildResult(Result.FAILURE.toString()) + loadAndExecuteScript("vars/notifyMail/jobs/notifyMailCustomJob.groovy") + } + + void assertCorrectExtmailCall(Map extmailCall) { + assertEquals("subject is wrong", 'custom mail subject with trigger: SUCCESS', extmailCall[NOTIFY_SUBJECT] ?: 'subjectNotSet') + assertEquals("body is wrong", 'custom body with trigger: SUCCESS', extmailCall[NOTIFY_BODY] ?: 'bodyNotSet') + assertEquals("attachmentsPattern is wrong", 'custom/pattern/**/*.txt', extmailCall[NOTIFY_ATTACHMENTS_PATTERN] ?: 'attachmentsPatternNotSet') + assertEquals("attachLog is wrong", true, extmailCall[NOTIFY_ATTACH_LOG] ?: 'attachLogNotSet') + assertEquals("compressLog is wrong", true, extmailCall[NOTIFY_COMPRESS_LOG] ?: 'compressLogNotSet') + assertEquals("mimeType is wrong", 'text/html', extmailCall[NOTIFY_MIME_TYPE] ?: 'mimeTypeNotSet') + assertEquals("to is wrong", 'test@test.com', extmailCall[NOTIFY_TO] ?: 'toNotSet') + + + String expectedRecipientProviderList = '[[$class:CulpritsRecipientProvider], [$class:DevelopersRecipientProvider], [$class:FirstFailingBuildSuspectsRecipientProvider], [$class:RequesterRecipientProvider], [$class:UpstreamComitterRecipientProvider]]' + + assertEquals("expectedRecipientProviderList is wrong", expectedRecipientProviderList, extmailCall[NOTIFY_RECIPIENT_PROVIDERS] ? extmailCall[NOTIFY_RECIPIENT_PROVIDERS].toString() : 'recipientProvidersNotSet') + } +} diff --git a/test/vars/notifyMail/NotifyMailDefaultsIT.groovy b/test/vars/notifyMail/NotifyMailDefaultsIT.groovy new file mode 100644 index 0000000..210bbab --- /dev/null +++ b/test/vars/notifyMail/NotifyMailDefaultsIT.groovy @@ -0,0 +1,116 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.notifyMail + +import hudson.model.Result +import io.wcm.testing.jenkins.pipeline.LibraryIntegrationTestBase +import io.wcm.tooling.jenkins.pipeline.environment.EnvironmentConstants +import org.junit.Test + +import static io.wcm.testing.jenkins.pipeline.StepConstants.EMAILEXT +import static io.wcm.testing.jenkins.pipeline.recorder.StepRecorderAssert.assertNone +import static io.wcm.testing.jenkins.pipeline.recorder.StepRecorderAssert.assertOnce +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +import static org.junit.Assert.assertEquals + +class NotifyMailDefaultsIT extends LibraryIntegrationTestBase { + + @Override + void setUp() throws Exception { + super.setUp() + this.setEnv("BUILD_NUMBER", "2") + this.setEnv(EnvironmentConstants.GIT_BRANCH, "DETECTED_GIT_BRANCH") + } + + @Test + void shouldNotNotifyOnSuccess() { + this.runWrapper.setResult(Result.SUCCESS.toString()) + loadAndExecuteScript("vars/notifyMail/jobs/notifyMailDefaultsJob.groovy") + assertNone(EMAILEXT) + } + + @Test + void shouldNotNotifyOnAbort() { + this.runWrapper.setResult(Result.ABORTED.toString()) + loadAndExecuteScript("vars/notifyMail/jobs/notifyMailDefaultsJob.groovy") + assertNone(EMAILEXT) + } + + @Test + void shouldNotNotifyOnNotBuild() { + this.runWrapper.setResult(Result.NOT_BUILT.toString()) + loadAndExecuteScript("vars/notifyMail/jobs/notifyMailDefaultsJob.groovy") + assertNone(EMAILEXT) + } + + @Test + void shouldNotifyOnFixed() { + this.runWrapper.setResult(Result.SUCCESS.toString()) + this.runWrapper.setPreviousBuildResult(Result.FAILURE.toString()) + loadAndExecuteScript("vars/notifyMail/jobs/notifyMailDefaultsJob.groovy") + assertOnce(EMAILEXT) + } + + @Test + void shouldNotifyOnUnstable() { + this.runWrapper.setResult(Result.UNSTABLE.toString()) + loadAndExecuteScript("vars/notifyMail/jobs/notifyMailDefaultsJob.groovy") + LinkedHashMap extmailCall = assertOnce(EMAILEXT) + assertCorrectExtmailCall(extmailCall) + } + + @Test + void shouldNotifyOnStillUnstable() { + this.runWrapper.setResult(Result.UNSTABLE.toString()) + this.runWrapper.setPreviousBuildResult(Result.UNSTABLE.toString()) + loadAndExecuteScript("vars/notifyMail/jobs/notifyMailDefaultsJob.groovy") + assertOnce(EMAILEXT) + } + + @Test + void shouldNotifyOnFailure() { + this.runWrapper.setResult(Result.FAILURE.toString()) + loadAndExecuteScript("vars/notifyMail/jobs/notifyMailDefaultsJob.groovy") + assertOnce(EMAILEXT) + } + + @Test + void shouldNotifyOnStillFailing() { + this.runWrapper.setResult(Result.FAILURE.toString()) + this.runWrapper.setPreviousBuildResult(Result.FAILURE.toString()) + loadAndExecuteScript("vars/notifyMail/jobs/notifyMailDefaultsJob.groovy") + assertOnce(EMAILEXT) + } + + void assertCorrectExtmailCall(LinkedHashMap extmailCall) { + assertEquals("subject is wrong", '${PROJECT_NAME} - Build # ${BUILD_NUMBER} - UNSTABLE', extmailCall[NOTIFY_SUBJECT] ?: 'subjectNotSet') + assertEquals("body is wrong", '${DEFAULT_CONTENT}', extmailCall[NOTIFY_BODY] ?: 'bodyNotSet') + assertEquals("attachmentsPattern is wrong", '', extmailCall[NOTIFY_ATTACHMENTS_PATTERN] != null ? extmailCall.attachmentsPattern : 'attachmentsPatternNotSet') + assertEquals("attachLog is wrong", false, extmailCall[NOTIFY_ATTACH_LOG] != null ? extmailCall.attachLog : 'attachLogNotSet') + assertEquals("compressLog is wrong", false, extmailCall[NOTIFY_COMPRESS_LOG] != null ? extmailCall.compressLog : 'compressLogNotSet') + assertEquals("mimeType is wrong", null, extmailCall[NOTIFY_MIME_TYPE]) + assertEquals("to is wrong", null, extmailCall[NOTIFY_TO]) + + + String expectedRecipientProviderList = '[[$class:CulpritsRecipientProvider], [$class:DevelopersRecipientProvider], [$class:FirstFailingBuildSuspectsRecipientProvider], [$class:RequesterRecipientProvider], [$class:UpstreamComitterRecipientProvider]]' + + assertEquals("expectedRecipientProviderList is wrong", expectedRecipientProviderList, extmailCall[NOTIFY_RECIPIENT_PROVIDERS] ? extmailCall[NOTIFY_RECIPIENT_PROVIDERS].toString() : 'recipientProvidersNotSet') + } +} diff --git a/test/vars/notifyMail/jobs/notifyMailCustomJob.groovy b/test/vars/notifyMail/jobs/notifyMailCustomJob.groovy new file mode 100644 index 0000000..270fb31 --- /dev/null +++ b/test/vars/notifyMail/jobs/notifyMailCustomJob.groovy @@ -0,0 +1,54 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.notifyMail.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs notifyMail step with custom configuration (opposite of default configuration) + * + * @return The script + * @see vars.notifyMail.NotifyMailCustomIT + */ +def execute() { + notifyMail( + [ + (NOTIFY): [ + (NOTIFY_ON_SUCCESS) : true, + (NOTIFY_ON_FAILURE) : false, + (NOTIFY_ON_STILL_FAILING) : false, + (NOTIFY_ON_FIXED) : false, + (NOTIFY_ON_UNSTABLE) : false, + (NOTIFY_ON_STILL_UNSTABLE) : false, + (NOTIFY_ON_ABORT) : true, + (NOTIFY_TO) : 'test@test.com', + (NOTIFY_SUBJECT) : 'custom mail subject with trigger: ${NOTIFICATION_TRIGGER}', + (NOTIFY_BODY) : 'custom body with trigger: ${NOTIFICATION_TRIGGER}', + (NOTIFY_ATTACHMENTS_PATTERN): 'custom/pattern/**/*.txt', + (NOTIFY_ATTACH_LOG) : true, + (NOTIFY_COMPRESS_LOG) : true, + (NOTIFY_MIME_TYPE) : 'text/html' + ] + ] + + ) +} + +return this diff --git a/test/vars/notifyMail/jobs/notifyMailDefaultsJob.groovy b/test/vars/notifyMail/jobs/notifyMailDefaultsJob.groovy new file mode 100644 index 0000000..9154009 --- /dev/null +++ b/test/vars/notifyMail/jobs/notifyMailDefaultsJob.groovy @@ -0,0 +1,32 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.notifyMail.jobs + +/** + * Runs notifyMail step with default configuration + * + * @return The script + * @see vars.notifyMail.NotifyMailDefaultsIT + */ +def execute() { + notifyMail() +} + +return this diff --git a/test/vars/setBuildName/SetBuildNameIT.groovy b/test/vars/setBuildName/SetBuildNameIT.groovy new file mode 100644 index 0000000..b85ad57 --- /dev/null +++ b/test/vars/setBuildName/SetBuildNameIT.groovy @@ -0,0 +1,47 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.setBuildName + +import io.wcm.testing.jenkins.pipeline.LibraryIntegrationTestBase +import org.junit.Test + +import static org.junit.Assert.assertEquals + +class SetBuildNameIT extends LibraryIntegrationTestBase { + + @Test + void shouldUseGitBranch() { + this.setEnv("BUILD_NUMBER", "1") + this.setEnv("GIT_BRANCH", "I_AM_THE_GITBRANCH") + loadAndExecuteScript("vars/setBuildName/jobs/setBuildNameJob.groovy") + + assertEquals("#1_I_AM_THE_GITBRANCH", this.runWrapper.getDisplayName()) + } + + @Test + void shouldNotUseGitBranch() { + this.setEnv("BUILD_NUMBER", "1") + loadAndExecuteScript("vars/setBuildName/jobs/setBuildNameJob.groovy") + + assertEquals("#1", this.runWrapper.getDisplayName()) + } + + +} diff --git a/test/vars/setBuildName/jobs/setBuildNameJob.groovy b/test/vars/setBuildName/jobs/setBuildNameJob.groovy new file mode 100644 index 0000000..31a7ce3 --- /dev/null +++ b/test/vars/setBuildName/jobs/setBuildNameJob.groovy @@ -0,0 +1,32 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.setBuildName.jobs + +/** + * Runs the setBuildName step. Environment is manipulated by the integration test to test several variants + * + * @return The script + * @see vars.setBuildName.SetBuildNameIT + */ +def execute() { + setBuildName() +} + +return this diff --git a/test/vars/setGitBranch/SetGitBranchIT.groovy b/test/vars/setGitBranch/SetGitBranchIT.groovy new file mode 100644 index 0000000..6c884c8 --- /dev/null +++ b/test/vars/setGitBranch/SetGitBranchIT.groovy @@ -0,0 +1,75 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.setGitBranch + +import io.wcm.testing.jenkins.pipeline.LibraryIntegrationTestBase +import io.wcm.testing.jenkins.pipeline.StepConstants +import io.wcm.tooling.jenkins.pipeline.environment.EnvironmentConstants +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner + +import static org.junit.Assert.assertEquals + +class SetGitBranchIT extends LibraryIntegrationTestBase { + + @Test + void shouldUseLocalBranchName() { + helper.registerAllowedMethod(StepConstants.SH, [Map.class], shellMapCallback) + loadAndExecuteScript("vars/setGitBranch/jobs/setGitBranchTestJob.groovy") + + assertEquals("my-custom-branch-name", this.getEnv(EnvironmentConstants.GIT_BRANCH)) + } + + @Test + void shouldFallbackToHeadRev() { + loadAndExecuteScript("vars/setGitBranch/jobs/setGitBranchTestJob.groovy") + + assertEquals("0HFGC0", this.getEnv(EnvironmentConstants.GIT_BRANCH)) + } + + @Test + void shouldUseBranchNameEnvVar() { + this.setEnv(EnvironmentConstants.BRANCH_NAME, "VALUE_OF_BRANCH_NAME") + loadAndExecuteScript("vars/setGitBranch/jobs/setGitBranchTestJob.groovy") + assertEquals("VALUE_OF_BRANCH_NAME", this.getEnv(EnvironmentConstants.GIT_BRANCH)) + } + + @Test + void shouldUseGitBranchEnvVar() { + this.setEnv(EnvironmentConstants.BRANCH_NAME, "VALUE_OF_GIT_BRANCH") + loadAndExecuteScript("vars/setGitBranch/jobs/setGitBranchTestJob.groovy") + assertEquals("VALUE_OF_GIT_BRANCH", this.getEnv(EnvironmentConstants.GIT_BRANCH)) + } + + def shellMapCallback = { Map incomingCommand -> + stepRecorder.record(StepConstants.SH, incomingCommand) + Boolean returnStdout = incomingCommand.returnStdout ?: false + String script = incomingCommand.script ?: "" + // return default values for several commands + if (returnStdout) { + switch (script) { + case "git branch": return "* my-custom-branch-name" + break + default: return "" + } + } + } +} diff --git a/test/vars/setGitBranch/jobs/setGitBranchTestJob.groovy b/test/vars/setGitBranch/jobs/setGitBranchTestJob.groovy new file mode 100644 index 0000000..3b93b55 --- /dev/null +++ b/test/vars/setGitBranch/jobs/setGitBranchTestJob.groovy @@ -0,0 +1,32 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.setGitBranch.jobs + +/** + * Runs the setGitBranch task. Environment is manipulated by the integration test to test several variants + * + * @return The script + * @see vars.setGitBranch.SetGitBranchIT + */ +def execute() { + setGitBranch() +} + +return this diff --git a/test/vars/setScmUrl/SetScmUrlIT.groovy b/test/vars/setScmUrl/SetScmUrlIT.groovy new file mode 100644 index 0000000..3ae9c6f --- /dev/null +++ b/test/vars/setScmUrl/SetScmUrlIT.groovy @@ -0,0 +1,42 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.setScmUrl + +import io.wcm.testing.jenkins.pipeline.LibraryIntegrationTestBase +import io.wcm.tooling.jenkins.pipeline.environment.EnvironmentConstants +import org.junit.Test + +import static org.junit.Assert.assertEquals + +class SetScmUrlIT extends LibraryIntegrationTestBase { + + @Test + void shouldUseScmUrlFromConfig() { + loadAndExecuteScript("vars/setScmUrl/jobs/setScmUrlWithConfigJob.groovy") + assertEquals("http://domain.tld/group/project.git", this.getEnv(EnvironmentConstants.SCM_URL)) + } + + @Test + void shouldUseScmUrlFromShell() { + loadAndExecuteScript("vars/setScmUrl/jobs/setScmUrlFromShellJob.groovy") + assertEquals("http://remote.origin.url/group/project.git", this.getEnv(EnvironmentConstants.SCM_URL)) + } + +} diff --git a/test/vars/setScmUrl/jobs/setScmUrlFromShellJob.groovy b/test/vars/setScmUrl/jobs/setScmUrlFromShellJob.groovy new file mode 100644 index 0000000..4dff588 --- /dev/null +++ b/test/vars/setScmUrl/jobs/setScmUrlFromShellJob.groovy @@ -0,0 +1,32 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.setScmUrl.jobs + +/** + * Runs the setScmUrl step in auto detection mode where scm url is determined via command line + * + * @return The script + * @see vars.setScmUrl.SetScmUrlIT + */ +def execute() { + setScmUrl([:]) +} + +return this diff --git a/test/vars/setScmUrl/jobs/setScmUrlWithConfigJob.groovy b/test/vars/setScmUrl/jobs/setScmUrlWithConfigJob.groovy new file mode 100644 index 0000000..14dd155 --- /dev/null +++ b/test/vars/setScmUrl/jobs/setScmUrlWithConfigJob.groovy @@ -0,0 +1,32 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.setScmUrl.jobs + +/** + * Runs the setScmUrl step in default mode where scm url is provided by configuration + * + * @return The script + * @see vars.setScmUrl.SetScmUrlIT + */ +def execute() { + setScmUrl([scm: [url: "http://domain.tld/group/project.git"]]) +} + +return this diff --git a/test/vars/setupTools/SetupToolsIT.groovy b/test/vars/setupTools/SetupToolsIT.groovy new file mode 100644 index 0000000..f665617 --- /dev/null +++ b/test/vars/setupTools/SetupToolsIT.groovy @@ -0,0 +1,71 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.setupTools + +import hudson.AbortException +import io.wcm.testing.jenkins.pipeline.LibraryIntegrationTestBase +import io.wcm.tooling.jenkins.pipeline.model.Tool +import org.hamcrest.CoreMatchers +import org.junit.Test + +import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertThat + +class SetupToolsIT extends LibraryIntegrationTestBase { + + @Test + void shouldUseCustomEnvVars() { + String expectedMavenPath = TOOL_MAVEN_PREFIX.concat(TOOL_MAVEN) + String expectedJdkPath = TOOL_JDK_PREFIX.concat(TOOL_JDK) + loadAndExecuteScript("vars/setupTools/jobs/shouldUseCustomEnvVarsTestJob.groovy") + + assertEquals(expectedMavenPath, this.getEnv("customMavenEnvVar")) + assertEquals(expectedJdkPath, this.getEnv("customJdkEnvVar")) + assertThat(this.getEnv("PATH"), CoreMatchers.containsString(expectedMavenPath)) + assertThat(this.getEnv("PATH"), CoreMatchers.containsString(expectedJdkPath)) + } + + @Test + void shouldUseDefaultEnvVars() { + String expectedMavenPath = TOOL_MAVEN_PREFIX.concat(TOOL_MAVEN) + String expectedJdkPath = TOOL_JDK_PREFIX.concat(TOOL_JDK) + loadAndExecuteScript("vars/setupTools/jobs/shouldUseDefaultEnvVarsTestJob.groovy") + + assertEquals(expectedMavenPath, this.getEnv(Tool.MAVEN.getEnvVar())) + assertEquals(expectedJdkPath, this.getEnv(Tool.JDK.getEnvVar())) + assertThat(this.getEnv("PATH"), CoreMatchers.containsString(expectedMavenPath)) + assertThat(this.getEnv("PATH"), CoreMatchers.containsString(expectedJdkPath)) + } + + @Test(expected = AbortException.class) + void shouldFailWhenMavenNotFound() { + try { + def script = loadScript("vars/setupTools/jobs/shouldFailWhenToolNotFound.groovy") + script.execute() + } catch (AbortException e) { + assertThat(e.getMessage(), CoreMatchers.containsString('invalid-maven-tool')) + throw e + } catch (Exception e1) { + e1.printStackTrace() + dslMock.printLogMessages() + } + } + +} diff --git a/test/vars/setupTools/jobs/shouldFailWhenToolNotFound.groovy b/test/vars/setupTools/jobs/shouldFailWhenToolNotFound.groovy new file mode 100644 index 0000000..57c5794 --- /dev/null +++ b/test/vars/setupTools/jobs/shouldFailWhenToolNotFound.groovy @@ -0,0 +1,38 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.setupTools.jobs + +import io.wcm.tooling.jenkins.pipeline.model.Tool + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs the setupTools step with invalid tool to test failure when tool is not found + * + * @return The script + * @see vars.setupTools.SetupToolsIT + */ +def execute() { + setupTools((TOOLS): [ + [(TOOL_NAME): "invalid-maven-tool", (TOOL_TYPE): Tool.MAVEN] + ]) +} + +return this diff --git a/test/vars/setupTools/jobs/shouldUseCustomEnvVarsTestJob.groovy b/test/vars/setupTools/jobs/shouldUseCustomEnvVarsTestJob.groovy new file mode 100644 index 0000000..7a6f939 --- /dev/null +++ b/test/vars/setupTools/jobs/shouldUseCustomEnvVarsTestJob.groovy @@ -0,0 +1,40 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.setupTools.jobs + +import io.wcm.testing.jenkins.pipeline.LibraryIntegrationTestBase +import io.wcm.tooling.jenkins.pipeline.model.Tool + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs the setupTools step with custom environment variables + * + * @return The script + * @see vars.setupTools.SetupToolsIT + */ +def execute() { + setupTools((TOOLS): [ + [(TOOL_NAME): LibraryIntegrationTestBase.TOOL_JDK, (TOOL_TYPE): Tool.JDK, (TOOL_ENVVAR): "customJdkEnvVar"], + [(TOOL_NAME): LibraryIntegrationTestBase.TOOL_MAVEN, (TOOL_TYPE): Tool.MAVEN, (TOOL_ENVVAR): "customMavenEnvVar"], + ]) +} + +return this diff --git a/test/vars/setupTools/jobs/shouldUseDefaultEnvVarsTestJob.groovy b/test/vars/setupTools/jobs/shouldUseDefaultEnvVarsTestJob.groovy new file mode 100644 index 0000000..fe1545e --- /dev/null +++ b/test/vars/setupTools/jobs/shouldUseDefaultEnvVarsTestJob.groovy @@ -0,0 +1,40 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.setupTools.jobs + +import io.wcm.testing.jenkins.pipeline.LibraryIntegrationTestBase +import io.wcm.tooling.jenkins.pipeline.model.Tool + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs the setupTools step with default environment variables for JDK and Maven + * + * @return The script + * @see vars.setupTools.SetupToolsIT + */ +def execute() { + setupTools((TOOLS): [ + [(TOOL_NAME): LibraryIntegrationTestBase.TOOL_JDK, (TOOL_TYPE): Tool.JDK], + [(TOOL_NAME): LibraryIntegrationTestBase.TOOL_MAVEN, (TOOL_TYPE): Tool.MAVEN] + ]) +} + +return this diff --git a/test/vars/sshAgentWrapper/SSHAgentWrapperIT.groovy b/test/vars/sshAgentWrapper/SSHAgentWrapperIT.groovy new file mode 100644 index 0000000..0dad46d --- /dev/null +++ b/test/vars/sshAgentWrapper/SSHAgentWrapperIT.groovy @@ -0,0 +1,78 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.sshAgentWrapper + +import io.wcm.testing.jenkins.pipeline.LibraryIntegrationTestBase +import io.wcm.testing.jenkins.pipeline.StepConstants +import io.wcm.tooling.jenkins.pipeline.credentials.Credential +import io.wcm.tooling.jenkins.pipeline.credentials.CredentialAware +import io.wcm.tooling.jenkins.pipeline.ssh.SSHTarget +import org.junit.Test + +import static io.wcm.testing.jenkins.pipeline.recorder.StepRecorderAssert.assertOnce +import static io.wcm.testing.jenkins.pipeline.recorder.StepRecorderAssert.assertOneShellCommand +import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertNotNull + +class SSHAgentWrapperIT extends LibraryIntegrationTestBase { + + @Test + void shouldWrapWithoutCommandBuilder() { + String expectedCommand = "echo 'with string ssh target'" + loadAndExecuteScript("vars/sshAgentWrapper/jobs/shouldWrapWithStringSSHTarget.groovy") + assertOneShellCommand(expectedCommand) + + // assert that the sshagent step was called once + List keyAgentCredentialList = assertOnce(StepConstants.SSH_AGENT) + assertEquals("provided ssh credentials are wrong", ['ssh-key-for-testservers'], keyAgentCredentialList) + } + + @Test + void shouldWrapWithCommandBuilder() { + String expectedCommand = "echo 'with command builder'" + CredentialAware commandBuilder = loadAndExecuteScript("vars/sshAgentWrapper/jobs/shouldWrapWithCommandBuilderTestJob.groovy") + assertOneShellCommand(expectedCommand) + + // assert that the sshagent step was called once + List keyAgentCredentialList = assertOnce(StepConstants.SSH_AGENT) + assertEquals("provided ssh credentials are wrong", ['ssh-key-for-testservers'], keyAgentCredentialList) + + assertNotNull("command builder should not be null", commandBuilder) + Credential credential = commandBuilder.getCredential() + assertNotNull("Credentials should be stored in command builder", credential) + assertEquals('testserveruser', credential.getUserName()) + assertEquals('ssh-key-for-testservers', credential.getId()) + } + + @Test + void shouldWrapWithMultipleSSHTargets() { + String expectedCommand = "echo 'multiple ssh targets'" + List sshTargets = loadAndExecuteScript("vars/sshAgentWrapper/jobs/shouldWrapWithMultipleSSHTargetsTestJob.groovy") + assertOneShellCommand(expectedCommand) + + // assert that the sshagent step was called once + List keyAgentCredentialList = assertOnce(StepConstants.SSH_AGENT) + assertEquals("provided ssh credentials are wrong", ['domain-ssh-credential-id', 'host3-ssh-credential-id'], keyAgentCredentialList) + + assertEquals("domain-ssh-credential-id", sshTargets[0].getCredential().getId()) + assertEquals("domain-ssh-credential-id", sshTargets[1].getCredential().getId()) + assertEquals("host3-ssh-credential-id", sshTargets[2].getCredential().getId()) + } +} diff --git a/test/vars/sshAgentWrapper/jobs/shouldWrapWithCommandBuilderTestJob.groovy b/test/vars/sshAgentWrapper/jobs/shouldWrapWithCommandBuilderTestJob.groovy new file mode 100644 index 0000000..787c217 --- /dev/null +++ b/test/vars/sshAgentWrapper/jobs/shouldWrapWithCommandBuilderTestJob.groovy @@ -0,0 +1,44 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.sshAgentWrapper.jobs + +import io.wcm.tooling.jenkins.pipeline.shell.ScpCommandBuilderImpl +import io.wcm.tooling.jenkins.pipeline.ssh.SSHTarget +import org.jenkinsci.plugins.workflow.cps.DSL + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs the transferScp step with ssh credential auto lookup (key + username) + * + * @return The script + * @see vars.setScmUrl.SetScmUrlIT + */ +def execute() { + ScpCommandBuilderImpl commandBuilder = new ScpCommandBuilderImpl((DSL) this.steps) + SSHTarget target = new SSHTarget("testserver1.testservers.domain.tld") + sshAgentWrapper(target) { + commandBuilder.setCredential(target.getCredential()) + sh "echo 'with command builder'" + } + return commandBuilder +} + +return this diff --git a/test/vars/sshAgentWrapper/jobs/shouldWrapWithMultipleSSHTargetsTestJob.groovy b/test/vars/sshAgentWrapper/jobs/shouldWrapWithMultipleSSHTargetsTestJob.groovy new file mode 100644 index 0000000..e5c2467 --- /dev/null +++ b/test/vars/sshAgentWrapper/jobs/shouldWrapWithMultipleSSHTargetsTestJob.groovy @@ -0,0 +1,50 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.sshAgentWrapper.jobs + +import io.wcm.tooling.jenkins.pipeline.shell.ScpCommandBuilderImpl +import io.wcm.tooling.jenkins.pipeline.ssh.SSHTarget +import org.jenkinsci.plugins.workflow.cps.DSL + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs the transferScp step with ssh credential auto lookup (key + username) + * + * @return The script + * @see vars.setScmUrl.SetScmUrlIT + */ +def execute() { + + List sshTargets = [ + new SSHTarget("host1.domain.tld"), + new SSHTarget("host2.domain.tld"), + new SSHTarget("host3.domain.tld") + ] + + sshAgentWrapper(sshTargets) { + sh "echo 'multiple ssh targets'" + } + + return sshTargets +} + + +return this diff --git a/test/vars/sshAgentWrapper/jobs/shouldWrapWithStringSSHTarget.groovy b/test/vars/sshAgentWrapper/jobs/shouldWrapWithStringSSHTarget.groovy new file mode 100644 index 0000000..2410859 --- /dev/null +++ b/test/vars/sshAgentWrapper/jobs/shouldWrapWithStringSSHTarget.groovy @@ -0,0 +1,36 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.sshAgentWrapper.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs the transferScp step with ssh credential auto lookup (key + username) + * + * @return The script + * @see vars.setScmUrl.SetScmUrlIT + */ +def execute() { + sshAgentWrapper("testserver1.testservers.domain.tld") { + sh "echo 'with string ssh target'" + } +} + +return this diff --git a/test/vars/transferScp/TransferScpIT.groovy b/test/vars/transferScp/TransferScpIT.groovy new file mode 100644 index 0000000..0b4e700 --- /dev/null +++ b/test/vars/transferScp/TransferScpIT.groovy @@ -0,0 +1,60 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.transferScp + +import io.wcm.testing.jenkins.pipeline.LibraryIntegrationTestBase +import io.wcm.testing.jenkins.pipeline.StepConstants +import org.junit.Test + +import static io.wcm.testing.jenkins.pipeline.recorder.StepRecorderAssert.assertOnce +import static io.wcm.testing.jenkins.pipeline.recorder.StepRecorderAssert.assertOneShellCommand +import static org.junit.Assert.assertEquals + +class TransferScpIT extends LibraryIntegrationTestBase { + + + @Test + void shouldTransferSingleWithCredentials() { + String expectedCommand = 'scp -P 22 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null /path/to/source testserveruser@testserver1.testservers.domain.tld:"/path/to/destination"' + loadAndExecuteScript("vars/transferScp/jobs/transferScpSingleTestJob.groovy") + assertOneShellCommand(expectedCommand) + List keyAgentCredentialList = assertOnce(StepConstants.SSH_AGENT) + assertEquals("provided ssh credentials are wrong", ['ssh-key-for-testservers'], keyAgentCredentialList) + } + + @Test + void shouldTransferRecursiveWithoutCredentials() { + String expectedCommand = '/usr/bin/scp -C -4 -P 2222 -r /path/to/recursive\\ source/* testuser@subdomain.domain.tld:"/path/to/recursive\\ destination"' + loadAndExecuteScript("vars/transferScp/jobs/transferScpRecursiveTestJob.groovy") + assertOneShellCommand(expectedCommand) + List keyAgentCredentialList = assertOnce(StepConstants.SSH_AGENT) + assertEquals("provided ssh credentials are wrong", [], keyAgentCredentialList) + } + + @Test + void shouldTransferWithMinimalConfiguration() { + String expectedCommand = 'scp -P 22 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null /path/to/minimal\\ source minimal.domain.tld:"/path/to/minimal\\ destination"' + loadAndExecuteScript("vars/transferScp/jobs/transferScpWithMinimalConfiguration.groovy") + assertOneShellCommand(expectedCommand) + List keyAgentCredentialList = assertOnce(StepConstants.SSH_AGENT) + assertEquals("provided ssh credentials are wrong", [], keyAgentCredentialList) + } + +} diff --git a/test/vars/transferScp/jobs/transferScpRecursiveTestJob.groovy b/test/vars/transferScp/jobs/transferScpRecursiveTestJob.groovy new file mode 100644 index 0000000..b26a319 --- /dev/null +++ b/test/vars/transferScp/jobs/transferScpRecursiveTestJob.groovy @@ -0,0 +1,46 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.transferScp.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs the setScmUrl step in auto detection mode where scm url is determined via command line + * + * @return The script + * @see vars.setScmUrl.SetScmUrlIT + */ +def execute() { + transferScp( + (SCP): [ + (SCP_HOST) : "subdomain.domain.tld", + (SCP_PORT) : 2222, + (SCP_USER) : "testuser", + (SCP_ARGUMENTS) : ["-C", "-4"], + (SCP_RECURSIVE) : true, + (SCP_SOURCE) : "'/path/to/recursive source/*'", + (SCP_DESTINATION) : "'/path/to/recursive destination'", + (SCP_EXECUTABLE) : "/usr/bin/scp", + (SCP_HOST_KEY_CHECK): true + ] + ) +} + +return this diff --git a/test/vars/transferScp/jobs/transferScpSingleTestJob.groovy b/test/vars/transferScp/jobs/transferScpSingleTestJob.groovy new file mode 100644 index 0000000..ffaed31 --- /dev/null +++ b/test/vars/transferScp/jobs/transferScpSingleTestJob.groovy @@ -0,0 +1,46 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.transferScp.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs the transferScp step with ssh credential auto lookup (key + username) + * + * @return The script + * @see vars.setScmUrl.SetScmUrlIT + */ +def execute() { + transferScp( + (SCP): [ + (SCP_HOST) : "testserver1.testservers.domain.tld", + (SCP_PORT) : null, + (SCP_USER) : null, + (SCP_ARGUMENTS) : [], + (SCP_RECURSIVE) : false, + (SCP_SOURCE) : "/path/to/source", + (SCP_DESTINATION) : "/path/to/destination", + (SCP_EXECUTABLE) : null, + (SCP_HOST_KEY_CHECK): false + ] + ) +} + +return this diff --git a/test/vars/transferScp/jobs/transferScpWithMinimalConfiguration.groovy b/test/vars/transferScp/jobs/transferScpWithMinimalConfiguration.groovy new file mode 100644 index 0000000..d9c5ef3 --- /dev/null +++ b/test/vars/transferScp/jobs/transferScpWithMinimalConfiguration.groovy @@ -0,0 +1,40 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.transferScp.jobs + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs the setScmUrl step in auto detection mode where scm url is determined via command line + * + * @return The script + * @see vars.setScmUrl.SetScmUrlIT + */ +def execute() { + transferScp( + (SCP): [ + (SCP_HOST) : "minimal.domain.tld", + (SCP_SOURCE) : '"/path/to/minimal source"', + (SCP_DESTINATION): '"/path/to/minimal destination"' + ] + ) +} + +return this diff --git a/test/vars/wrap/WrapColorIT.groovy b/test/vars/wrap/WrapColorIT.groovy new file mode 100644 index 0000000..694e7ad --- /dev/null +++ b/test/vars/wrap/WrapColorIT.groovy @@ -0,0 +1,91 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.wrap + +import io.wcm.testing.jenkins.pipeline.LibraryIntegrationTestBase +import io.wcm.testing.jenkins.pipeline.StepConstants +import io.wcm.testing.jenkins.pipeline.recorder.StepRecorderAssert +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import org.junit.Assert +import org.junit.Test + +class WrapColorIT extends LibraryIntegrationTestBase { + + @Test + void shouldWrapColorMultiWithConfig() { + Logger.initialized = false + loadAndExecuteScript("vars/wrap/jobs/shouldWrapColorMultiWithConfigTestJob.groovy") + + List expectedLogOutputs = [ + "[INFO] vars.wrap.jobs.shouldWrapColorMultiWithConfigTestJob : non colorized output - 1", + "[DEBUG] wrap : Wrapping build with color scheme: 'gnome-terminal'", + "\u001B[1;38;5;0m[INFO]\u001B[0m vars.wrap.jobs.shouldWrapColorMultiWithConfigTestJob : first wrap env.TERM: gnome-terminal", + "\u001B[1;38;5;12m[DEBUG]\u001B[0m wrap : Wrapping build with color scheme: 'vga'", + "\u001B[1;38;5;0m[INFO]\u001B[0m vars.wrap.jobs.shouldWrapColorMultiWithConfigTestJob : second wrap env.TERM: vga", + "[INFO] vars.wrap.jobs.shouldWrapColorMultiWithConfigTestJob : non colorized output - 2" + ] + + StepRecorderAssert.assertTwice(StepConstants.ANSI_COLOR) + + List actualLogOutputs = this.dslMock.getLogMessages() + Assert.assertEquals(expectedLogOutputs, actualLogOutputs) + Assert.assertNull(null, this.getEnv('TERM')) + } + + @Test + void shouldWrapColor() { + Logger.initialized = false + loadAndExecuteScript("vars/wrap/jobs/shouldWrapColorTestJob.groovy") + List expectedLogOutputs = [ + "[INFO] vars.wrap.jobs.shouldWrapColorTestJob : non colorized output - 1", + "[DEBUG] wrap : Wrapping build with color scheme: 'xterm'", + "\u001B[1;38;5;0m[INFO]\u001B[0m vars.wrap.jobs.shouldWrapColorTestJob : colorized output", + "[INFO] vars.wrap.jobs.shouldWrapColorTestJob : non colorized output - 2", + ] + + StepRecorderAssert.assertOnce(StepConstants.ANSI_COLOR) + + List actualLogOutputs = this.dslMock.getLogMessages() + Assert.assertEquals(expectedLogOutputs, actualLogOutputs) + Assert.assertNull(this.getEnv('TERM')) + } + + @Test + void shouldWrapColorOnlyOnceWithSameColorMode() { + Logger.initialized = false + loadAndExecuteScript("vars/wrap/jobs/shouldWrapColorOnlyOnceWithSameColorModeTestJob.groovy") + + List expectedLogOutputs = [ + "[INFO] vars.wrap.jobs.shouldWrapColorOnlyOnceWithSameColorModeTestJob : non colorized output - 1", + "[DEBUG] wrap : Wrapping build with color scheme: 'vga'", + "\u001B[1;38;5;0m[INFO]\u001B[0m vars.wrap.jobs.shouldWrapColorOnlyOnceWithSameColorModeTestJob : first wrap env.TERM: vga", + "\u001B[1;38;5;12m[DEBUG]\u001B[0m wrap : Do not wrap with color scheme: 'vga' because wrapper with same color map is already active", + "\u001B[1;38;5;0m[INFO]\u001B[0m vars.wrap.jobs.shouldWrapColorOnlyOnceWithSameColorModeTestJob : second wrap env.TERM: vga", + "[INFO] vars.wrap.jobs.shouldWrapColorOnlyOnceWithSameColorModeTestJob : non colorized output - 2" + ] + + StepRecorderAssert.assertOnce(StepConstants.ANSI_COLOR) + + List actualLogOutputs = this.dslMock.getLogMessages() + Assert.assertEquals(expectedLogOutputs, actualLogOutputs) + Assert.assertNull(null, this.getEnv('TERM')) + } + +} diff --git a/test/vars/wrap/jobs/shouldWrapColorMultiWithConfigTestJob.groovy b/test/vars/wrap/jobs/shouldWrapColorMultiWithConfigTestJob.groovy new file mode 100644 index 0000000..1019b5c --- /dev/null +++ b/test/vars/wrap/jobs/shouldWrapColorMultiWithConfigTestJob.groovy @@ -0,0 +1,51 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.wrap.jobs + +import io.wcm.tooling.jenkins.pipeline.utils.logging.LogLevel +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs the wrap.color step + */ +def execute() { + Logger.init(this, LogLevel.DEBUG) + Logger log = new Logger(this) + + Map config = [ + (ANSI_COLOR): ANSI_COLOR_GNOME_TERMINAL + ] + + log.info("non colorized output - 1") + + wrap.color(config) { + config[ANSI_COLOR] = ANSI_COLOR_VGA + log.info("first wrap env.TERM: ${env.TERM}") + wrap.color(config) { + log.info("second wrap env.TERM: ${env.TERM}") + } + } + + log.info("non colorized output - 2") +} + +return this diff --git a/test/vars/wrap/jobs/shouldWrapColorOnlyOnceWithSameColorModeTestJob.groovy b/test/vars/wrap/jobs/shouldWrapColorOnlyOnceWithSameColorModeTestJob.groovy new file mode 100644 index 0000000..7bab6f9 --- /dev/null +++ b/test/vars/wrap/jobs/shouldWrapColorOnlyOnceWithSameColorModeTestJob.groovy @@ -0,0 +1,50 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.wrap.jobs + +import io.wcm.tooling.jenkins.pipeline.utils.logging.LogLevel +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs the wrap.color step + */ +def execute() { + Logger.init(this, LogLevel.DEBUG) + Logger log = new Logger(this) + + Map config = [ + (ANSI_COLOR): ANSI_COLOR_VGA + ] + + log.info("non colorized output - 1") + + wrap.color(config) { + log.info("first wrap env.TERM: ${env.TERM}") + wrap.color(config) { + log.info("second wrap env.TERM: ${env.TERM}") + } + } + + log.info("non colorized output - 2") +} + +return this diff --git a/test/vars/wrap/jobs/shouldWrapColorTestJob.groovy b/test/vars/wrap/jobs/shouldWrapColorTestJob.groovy new file mode 100644 index 0000000..07e84c8 --- /dev/null +++ b/test/vars/wrap/jobs/shouldWrapColorTestJob.groovy @@ -0,0 +1,43 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package vars.wrap.jobs + +import io.wcm.tooling.jenkins.pipeline.utils.logging.LogLevel +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Runs the wrap.color step + */ +def execute() { + Logger.init(this, LogLevel.DEBUG) + Logger log = new Logger(this) + + log.info("non colorized output - 1") + + wrap.color() { + log.info("colorized output") + } + + log.info("non colorized output - 2") +} + +return this diff --git a/vars/ansible.groovy b/vars/ansible.groovy new file mode 100644 index 0000000..3f4eeaf --- /dev/null +++ b/vars/ansible.groovy @@ -0,0 +1,218 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + + +import groovy.json.JsonOutput +import io.wcm.tooling.jenkins.pipeline.tools.ansible.Role +import io.wcm.tooling.jenkins.pipeline.tools.ansible.RoleRequirements +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import io.wcm.tooling.jenkins.pipeline.utils.maps.MapUtils + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Checks out ansible galaxy requirements based upon a provided + * path to a requirements YAML file. + * + * The scm uris for ansible galaxy roles will be looked up by using + * getGalaxyRoleInfo + * + * @see getGalaxyRoleInfo + */ +void checkoutRequirements(String requirementsYmlPath) { + Logger log = new Logger("ansible:checkoutRequirements -> ") + log.debug("loading yml") + List ymlContent = readYaml(file: requirementsYmlPath) + log.debug("create requirements object") + RoleRequirements roleRequirements = new RoleRequirements(ymlContent) + + // try to find github urls for ansible galaxy roles + List roles = roleRequirements.getRoles() + for (role in roles) { + if (role.isGalaxyRole()) { + Object roleApiInfo = getGalaxyRoleInfo(role) + if (roleApiInfo) { + log.debug("building scm url") + String githubUser = roleApiInfo['github_user'] + String githubRepo = roleApiInfo['github_repo'] + String githubBranch= roleApiInfo['github_branch'] + + String scmUrl = "https://github.com/${githubUser}/${githubRepo}.git" + // set values into role for checkout + role.setScm(Role.SCM_GIT) + role.setSrc(scmUrl) + + if (githubBranch != "") { + role.setVersion(githubBranch) + } + } + } + } + + List checkoutScmConfigs = roleRequirements.getCheckoutConfigs() + log.debug("checkoutConfigs: " + checkoutScmConfigs) + for (Map checkoutConfig in checkoutScmConfigs) { + checkoutScm(checkoutConfig) + } +} + +/** + * Executes a ansible playbook with the given configuration. + * Please refer to the documentation for details about the configuration options + * + * @param config The configuration used to execute the playbook + */ +void execPlaybook(Map config) { + Logger log = new Logger("ansible:execPlaybook -> ") + + Map ansibleCfg = config[ANSIBLE] ?: null + + if (ansibleCfg == null) { + log.fatal("provided ansible configuration is null, make sure to configure properly.") + error("provided ansible configuration is null, make sure to configure properly.") + } + + Boolean colorized = ansibleCfg[ANSIBLE_COLORIZED] != null ? ansibleCfg[ANSIBLE_COLORIZED] : true + + String installation = ansibleCfg[ANSIBLE_INSTALLATION] ?: null + Integer forks = ansibleCfg[ANSIBLE_FORKS] ?: 5 + String limit = ansibleCfg[ANSIBLE_LIMIT] ?: null + String playbook = ansibleCfg[ANSIBLE_PLAYBOOK] ?: null + String credentialsId = ansibleCfg[ANSIBLE_CREDENTIALS_ID] ?: null + String inventory = ansibleCfg[ANSIBLE_INVENTORY] ?: null + String skippedTags = ansibleCfg[ANSIBLE_SKIPPED_TAGS] ?: null + String startAtTask = ansibleCfg[ANSIBLE_START_AT_TASK] ?: null + Boolean sudo = ansibleCfg[ANSIBLE_SUDO] != null ? ansibleCfg[ANSIBLE_SUDO] : false + String sudoUser = ansibleCfg[ANSIBLE_SUDO_USER] ?: null + String tags = ansibleCfg[ANSIBLE_TAGS] ?: null + + List extraParameters = (List) ansibleCfg[ANSIBLE_EXTRA_PARAMETERS] ?: [] + Map extraVars = (Map) ansibleCfg[ANSIBLE_EXTRA_VARS] ?: [:] + Boolean injectParams = ansibleCfg[ANSIBLE_INJECT_PARAMS] != null ? ansibleCfg[ANSIBLE_INJECT_PARAMS] : false + + // create copies + Map internalExtraVars = MapUtils.merge(extraVars) + List internalExtraParameters = [] + for (extraParameter in extraParameters) { + internalExtraParameters.push(extraParameter) + } + + log.trace("debug: extraParameters.size: ${extraParameters.size()}") + log.trace("debug: extraVars.size: ${extraVars.size()}") + + if (injectParams == true) { + log.info("injecting build parameters as extra vars into playbook") + params.each { String k, Object v -> + log.debug("adding key '$k' with value '$v'") + internalExtraVars[k] = v + } + } + String extraVarsJson = JsonOutput.toJson(internalExtraVars) + // add extra vars to extraparameters + internalExtraParameters.push("--extra-vars '${extraVarsJson}'") + + // build extras string + String extras = internalExtraParameters.join(' ') + + log.trace("Calling ansiblePlaybook with:") + log.trace("colorized: $colorized") + log.trace("extras: $extras") + log.trace("forks: $forks") + log.trace("installation: $installation") + log.trace("inventory: $inventory") + log.trace("limit: $limit") + log.trace("playbook: $playbook") + log.trace("skippedTags: $skippedTags") + log.trace("startAtTask: $startAtTask") + log.trace("sudo: $sudo") + log.trace("sudoUser: $sudoUser") + log.trace("tags: $tags") + log.trace("credentialsId: $credentialsId") + + ansiblePlaybook( + colorized: colorized, + extras: extras, + forks: forks, + installation: installation, + inventory: inventory, + limit: limit, + playbook: playbook, + skippedTags: skippedTags, + startAtTask: startAtTask, + sudo: sudo, + sudoUser: sudoUser, + tags: tags, + credentialsId: credentialsId, + ) +} + +/** + * Calls the ansible galaxy API for role information + * + * @param role The role for which the ansible galaxy API info should be retrieved + * @return The API result or null when any error occurred + */ +Object getGalaxyRoleInfo(Role role) { + Logger log = new Logger("ansible:getGalaxyRoleInfo -> ") + if (role.isGalaxyRole() == false) { + log.debug("Role with name: " + role.getName() + " is not a galaxy role") + return null + } + log.info("Getting role info for ${role.getName()}") + String apiUrl = "https://galaxy.ansible.com/api/v1/roles/" + + //roles/?owner__username=tecris&name=maven" + + def matcher = role.getName() =~ /(.+)\.(.+)/ + log.debug("matcher: $matcher") + + if (!matcher) { + log.warn("unable to extract username name role name, return and to nothing") + return null + } + + String ownerUsername = matcher[0][1] + String name = matcher[0][2] + // directly reset matcher because it is not serializable + matcher = null + String roleApiUrl = "$apiUrl?owner__username=$ownerUsername&name=$name" + String apiResultStr + + // execute the shell + try { + apiResultStr = sh(returnStdout: true, script: "curl --silent '$roleApiUrl'") + } catch (Exception ex) { + log.error("Unable to get role info for ${role.getName()}") + return null + } + + log.trace("api curl result: $apiResultStr") + Object apiResultJson = readJSON(text: apiResultStr) + log.trace("api json result: $apiResultJson") + + Integer size = apiResultJson.results.size() + // we expect only one result here because username and role should only give one result + if (size != 1) { + log.warn("Expected one role result but found: $size") + return null + } + + return apiResultJson.results[0] +} \ No newline at end of file diff --git a/vars/ansible.md b/vars/ansible.md new file mode 100644 index 0000000..df0864f --- /dev/null +++ b/vars/ansible.md @@ -0,0 +1,412 @@ +# Ansible + +The ansible part of the library implements +* Ansible Playbook execution by providing a configuration object +* Checking out Ansible Galaxy requirements to track role changes +* Getting Ansible Galaxy role info from the galaxy API + +# Table of contents + +* [`checkoutRequirements(String requirementsYmlPath)`](#checkoutrequirementsstring-requirementsymlpath) + * [Example of a `requirements.yml`](#example-of-a-requirementsyml) + * [Process](#process) +* [`execPlaybook(Map config)`](#execplaybookmap-config) + * [Features](#features) + * [`--extra-vars` as JSON](#--extra-vars-as-json) + * [Inject Build parameters into `--extra-vars`](#inject-build-parameters-into---extra-vars) + * [Extra parameters](#extra-parameters) + * [Configuration Options](#configuration-options) + * [`colorized` (optional)](#colorized-optional) + * [`credentialsId` (optional)](#credentialsid-optional) + * [`extraParameters` (optional)](#extraparameters-optional) + * [`extraVars` (optional)](#extravars-optional) + * [`forks` (optional)](#forks-optional) + * [`injectParams` (optional)](#injectparams-optional) + * [`installation`](#installation) + * [`inventory`](#inventory) + * [`limit` (optional)](#limit-optional) + * [`skippedTags` (optional)](#skippedtags-optional) + * [`startAtTask` (optional)](#startattask-optional) + * [`tags` (optional)](#tags-optional) + * [`sudo` (optional)](#sudo-optional) + * [`sudoUser` (optional)](#sudouser-optional) + * [`playbook`](#playbook) +* [`getGalaxyRoleInfo(Role role)`](#getgalaxyroleinforole-role) + * [Example](#example) + +## `checkoutRequirements(String requirementsYmlPath)` + +This step checks out all ansible galaxy role requirements into subdirectories of the workspace to track SCM changes in the depending roles. +For Ansible Galaxy roles the `src` Attribute is used, for `scm` roles the `name` attribute is used + +This currently works for: + +* Ansible Galaxy roles (with and without version) +* Git scm Roles + +:bulb: The roles are checkout into subfolders using the `name` (`src` for Ansible Galaxy Roles) of the role. + +### Example of a `requirements.yml` +```yaml +- src: williamyeh.oracle-java +- src: tecris.maven + version: v3.5.2 +- src: https://github.com/wcm-io-devops/ansible-aem-cms.git + name: aem-cms + scm: git +- src: https://github.com/wcm-io-devops/ansible-aem-service.git + name: aem-service + scm: git + version: develop +``` + +This `requirements.yml` will result in a checkout of four reposities: +* https://github.com/William-Yeh/ansible-oracle-java.git (master) into folder "williamyeh.oracle-java" +* https://github.com/tecris/ansible-maven.git (tag v3.5.2) into folder "tecris.maven" +* https://github.com/wcm-io-devops/ansible-aem-cms.git (master) into folder "aem-cms" +* https://github.com/wcm-io-devops/ansible-aem-service.git (master) into folder "aem-service" + +### Process + +1. Load the provided yaml file +2. Parse the roles into [`Role`](../src/io/wcm/tooling/jenkins/pipeline/tools/ansible/Role.groovy) objects by using [`RoleRequirements`](../src/io/wcm/tooling/jenkins/pipeline/tools/ansible/RoleRequirements.groovy) +3. Get API info for each Ansible Galaxy Role by using [`getGalaxyRoleInfo`](#getgalaxyroleinfo) + 1. When API info is available set the github url and branch into the Role +4. Transform the roles into configurations for the [`checkoutScm`](checkoutScm.groovy) step +5. Checkout the SCM using the [`checkoutScm`](checkoutScm.groovy) step + +## `execPlaybook(Map config)` + +This step can be used to execute a Ansible Playbook. + +### Features +#### `--extra-vars` as JSON + +The step transforms all given extra vars into JSON before calling the Ansible Playbook. +This ensures that the types like `boolean` and `integer` are retained. + +:bulb: These extra vars are combined with build parameters when `ANSIBLE_INJECT_PARAMS` is enabled. + +**Example** + +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +Map config = [ + (ANSIBLE) : [ + (ANSIBLE_INSTALLATION) : '', + (ANSIBLE_PLAYBOOK) : 'path/to/playbook.yml', + (ANSIBLE_INVENTORY) : 'path/to/inventory', + (ANSIBLE_EXTRA_VARS) : [ + "string": "value", + "boolean" : true, + "integer" : 1, + "list" : [1,2,3,4] + ] + ] +] + +ansible.execPlaybook(config) +``` + +This config will execute the ansible playbook with the following `--extra-vars` parameter +``` +ansible-playbook --extra-vars '{"string":"value","boolean":true,"integer":1,"list":[1,2,3,4]}' [...] +``` + +#### Inject Build parameters into `--extra-vars` + +When enabled the step will automatically add all build parameters as extra variables an pass it to the playbook. +:bulb: These extra vars are combined with the variables defined via `ANSIBLE_EXTRA_VARS`. + +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +properties([ + parameters([ + booleanParam(defaultValue: false, description: '...', name: 'boolparam'), + choice(choices: 'choice1\nchoice2', description: '..', name: 'choiceparam'), + string(defaultValue: 'stringvalue', description: '..', name: 'stringparam')]), + ] +) + +Map config = [ + (ANSIBLE) : [ + (ANSIBLE_INSTALLATION) : '', + (ANSIBLE_PLAYBOOK) : 'path/to/playbook.yml', + (ANSIBLE_INVENTORY) : 'path/to/inventory', + (ANSIBLE_INJECT_PARAMS) : true + ] +] + +ansible.execPlaybook(config) +``` + +This config will execute the ansible playbook with the following `--extra-vars` parameter +``` +ansible-playbook --extra-vars '{"boolparam":false,"choiceparam":"choice1","stringparam":"defaultValue"}' [...] +``` + +:exclamation: Pleaes note that all parameters are injected. If you want to decide which parameters are added as extra params use the `ANSIBLE_EXTRA_VARS` configuration option. + +#### Extra parameters + +The step provides a convenient way to add extra parameters to the command line. + +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +Map config = [ + (ANSIBLE) : [ + (ANSIBLE_INSTALLATION) : '', + (ANSIBLE_PLAYBOOK) : 'path/to/playbook.yml', + (ANSIBLE_INVENTORY) : 'path/to/inventory', + (ANSIBLE_EXTRA_PARAMETERS) : ["-v"] + ] +] + +ansible.execPlaybook(config) +``` + +This config will execute the ansible playbook with the `-v` parameter +``` +ansible-playbook -v [...] +``` + +### Configuration Options + +Complete list of all configuration options. + +All configuration options must be inside the `ansible` +([`ConfigConstants.ANSIBLE`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)) +map element to be evaluated and used by the step. + +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +ansible.execPlaybook( + (ANSIBLE): [ + (ANSIBLE_COLORIZED) : true, + (ANSIBLE_EXTRA_PARAMETERS): ["-list","-of","-params"], + (ANSIBLE_EXTRA_VARS) : [ "" : "", "" : "" ], + (ANSIBLE_FORKS) : 5, + (ANSIBLE_INSTALLATION) : "", + (ANSIBLE_INVENTORY) : "", + (ANSIBLE_LIMIT) : "", + (ANSIBLE_PLAYBOOK) : "", + (ANSIBLE_CREDENTIALS_ID) : "", + (ANSIBLE_SKIPPED_TAGS) : "", + (ANSIBLE_START_AT_TASK) : "", + (ANSIBLE_SUDO) : false, + (ANSIBLE_SUDO_USER) : "", + (ANSIBLE_TAGS) : "", + (ANSIBLE_INJECT_PARAMS) : false + ] + ) +``` + +#### `colorized` (optional) + +||| +|---|---| +|Constant|[`ConfigConstants.ANSIBLE_COLORIZED`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Boolean`| +|Default|`true`| + +Controls the colorized output of ansible. Default is set to true. + +#### `credentialsId` (optional) + +||| +|---|---| +|Constant|[`ConfigConstants.ANSIBLE_CREDENTIALS_ID`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`null`| + +Use this option to pass SSH credentials. + +:bulb: It is recommended to use the [`sshAgentWrapper`](sshAgentWrapper.md) instead. + +#### `extraParameters` (optional) + +||| +|---|---| +|Constant|[`ConfigConstants.ANSIBLE_EXTRA_PARAMETERS`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`List` of `String`| +|Default|`[]`| + +Extra parameters that will be passed to ansible-playbook commandline + +**Example:** +This example will add `-v` to the command line. +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +Map config = { + (ANSIBLE) : [ + (ANSIBLE_EXTRA_PARAMETERS) : ["-v"] + // ... + ] +] +``` + +#### `extraVars` (optional) + +||| +|---|---| +|Constant|[`ConfigConstants.ANSIBLE_EXTRA_VARS`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Map`| +|Default|`[:]`| + +Can be used to define `--extra-vars` which will be passed in JSON format to the command line. + +:bulb: When [`injectParams`](#injectparams-optional) is used they will be combined with the injected build parameters. + +**Example:** +This example will add `--extra-vars '{"string":"value","boolean":true,"integer":1,"list":[1,2,3,4]}'` to the command line. +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +Map config = [ + (ANSIBLE) : [ + (ANSIBLE_INSTALLATION) : '', + (ANSIBLE_PLAYBOOK) : 'path/to/playbook.yml', + (ANSIBLE_INVENTORY) : 'path/to/inventory', + (ANSIBLE_EXTRA_VARS) : [ + "string": "value", + "boolean" : true, + "integer" : 1, + "list" : [1,2,3,4] + ] + ] +] +``` + +#### `forks` (optional) + +||| +|---|---| +|Constant|[`ConfigConstants.ANSIBLE_FORKS`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Integer`| +|Default|`5`| + +Controls how many forks will be used during Ansible Playbook execution. + +#### `injectParams` (optional) + +||| +|---|---| +|Constant|[`ConfigConstants.ANSIBLE_INJECT_PARAMS`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Boolean`| +|Default|`false`| + +When enabled **all** build parameters are injected into `--extra-vars`* +:bulb: When [`extraVars`](#extravars-optional) are defined they will be combined with the injected params. + +#### `installation` + +||| +|---|---| +|Constant|[`ConfigConstants.ANSIBLE_INSTALLATION`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`null`| + +Controls which ansible installation will be used for playbook execution. + +#### `inventory` + +||| +|---|---| +|Constant|[`ConfigConstants.ANSIBLE_INVENTORY`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`null`| + +Specifies the path to the Ansible Playbook inventory. + +#### `limit` (optional) + +||| +|---|---| +|Constant|[`ConfigConstants.ANSIBLE_LIMIT`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`null`| + +When set the configured value will be passed as `--limit ` to the Ansible Playbook. + +#### `playbook` + +||| +|---|---| +|Constant|[`ConfigConstants.ANSIBLE_PLAYBOOK`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`null`| + +Specifies the path to the Ansible Playbook. + +#### `skippedTags` (optional) + +||| +|---|---| +|Constant|[`ConfigConstants.ANSIBLE_SKIPPED_TAGS`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`null`| + +When set the configured value will be passed as `--skip-tags ` to the Ansible Playbook. + +#### `startAtTask` (optional) + +||| +|---|---| +|Constant|[`ConfigConstants.ANSIBLE_START_AT_TASK`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`null`| + +When set the configured value will be passed as `--start-at-task ` to the Ansible Playbook. + +#### `tags` (optional) + +||| +|---|---| +|Constant|[`ConfigConstants.ANSIBLE_TAGS`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`null`| + +When set the configured value will be passed as `--tags ` to the Ansible Playbook. + +#### `sudo` (optional) + +||| +|---|---| +|Constant|[`ConfigConstants.ANSIBLE_SUDO`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Boolean`| +|Default|`false`| + +When enabled sudo (become) will be used. Combined with [`sudoUser`](#sudouser-optional) setting. + +#### `sudoUser` (optional) + +||| +|---|---| +|Constant|[`ConfigConstants.ANSIBLE_SUDO_USER`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`null`| + +Specifies the sudo user to use (become_user) + +## `getGalaxyRoleInfo(Role role)` + +Utility function to the the Ansible Galaxy role info from the API. +:bulb: Works only for Ansible Galaxy Roles + +:bulb: This method will return `null` when no info was found. + +### Example + +This example will return the API role info for the role "tecris.maven" + +```groovy +import io.wcm.tooling.jenkins.pipeline.tools.ansible.Role +Role role = new Role("tecris.maven") + +Object apiInfo = ansible.getGalaxyRoleInfo(role) +``` \ No newline at end of file diff --git a/vars/checkoutScm.groovy b/vars/checkoutScm.groovy new file mode 100644 index 0000000..432ef48 --- /dev/null +++ b/vars/checkoutScm.groovy @@ -0,0 +1,170 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +import io.wcm.tooling.jenkins.pipeline.credentials.Credential +import io.wcm.tooling.jenkins.pipeline.credentials.CredentialConstants +import io.wcm.tooling.jenkins.pipeline.credentials.CredentialParser +import io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants +import io.wcm.tooling.jenkins.pipeline.utils.PatternMatcher +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import io.wcm.tooling.jenkins.pipeline.utils.resources.JsonLibraryResource +import net.sf.json.JSON +import org.jenkinsci.plugins.workflow.cps.DSL + +/** + * Step which takes care about checking out source code from git. Other SCMs are currently not supported. + * + * Variant 1: When scm variable is present and config scm.useScmVar is set to true the configured SCM from the job + * itself is used to checkout. + * + * Variant 2: When config scm.useScmVar is not set to true the value from config scm.url is used for checkout. In this + * mode the step tries to auto lookup the Jenkins credentials from resources/credentials/scm/credentials.json based on + * the url to checkout. + * + * See checkoutScm.md for detailed documentation + * + * Defaults: + * branches: master and develop + * extensions: LocalBranch + * + * @param config configuration object + */ +def call(Map config) { + Logger log = new Logger(this) + + // retrieve the configuration + Map scmCfg = (Map) config[ConfigConstants.SCM] ?: [:] + Boolean useScmVar = scmCfg[ConfigConstants.SCM_USE_SCM_VAR] ? scmCfg[ConfigConstants.SCM_USE_SCM_VAR] : false + + if (useScmVar && scm) { + // checkout by using provided scm, used when Jenkinsfile is in the project repository + log.info("Found configuration to use existing scm var, checking out with scm configuration from job") + checkoutWithScmVar() + } else { + // checkout by using the provided scm configuration, used when Jenkinsfile is not in the project repository + log.info("Checking out with provided configuration") + checkoutWithConfiguration(scmCfg, log) + } + + // try to retrieve the git branch and set it into environment variable GIT_BRANCH + setGitBranch() + // set the scm url to environment variable SCM_URL + setScmUrl(config) +} + +/** + * Runs the checkout by using the provided scm variable + * + * @return result of checkout step + */ +def checkoutWithScmVar() { + checkout scm +} + +/** + * Runs the checkout by using the provided scm configuration + * + * @param scmCfg The configuration used for checkout + * @param log The logger instance + * @return result of checkout step + */ +def checkoutWithConfiguration(Map scmCfg, Logger log) { + // parse the configuration with defaults + String credentialsId = scmCfg[ConfigConstants.SCM_CREDENTIALS_ID] + String url = scmCfg[ConfigConstants.SCM_URL] + List branches = (List) scmCfg[ConfigConstants.SCM_BRANCHES] ?: [[name: '*/master'], [name: '*/develop']] + List submoduleCfg = (List) scmCfg[ConfigConstants.SCM_SUBMODULE_CONFIG] ?: [] + List extensions = (List) scmCfg[ConfigConstants.SCM_EXTENSIONS] ?: [[$class: 'LocalBranch']] + Boolean doGenerateSubmoduleConfigurations = scmCfg[ConfigConstants.SCM_DO_GENERATE_SUBMODULE_CONFIGURATION] != null ? scmCfg[ConfigConstants.SCM_DO_GENERATE_SUBMODULE_CONFIGURATION] : false + Map userRemoteConfig = (Map) scmCfg[ConfigConstants.SCM_USER_REMOTE_CONFIG] ?: [:] + List userRemoteConfigs = (List) scmCfg[ConfigConstants.SCM_USER_REMOTE_CONFIGS] ?: [] + + log.debug("url: ", url) + log.debug("branches: ", branches) + + if (userRemoteConfigs.size() > 0) { + // use userRemoteConfigs when provided + log.info("userRemoteConfigs found in provided configuration, do not auto credential lookup", userRemoteConfigs) + } else if (userRemoteConfig.size() > 0) { + // use userRemoteConfig when provided + userRemoteConfigs.push(userRemoteConfig) + } else { + // since url is necessary for this part fail when not present + if (url == null) { + log.fatal("No scm url provided, aborting") + error("$this: No scm url provided, aborting") + } + + // do credential auto lookup + if (credentialsId == null) { + log.debug("no credentials id passed, try auto lookup") + Credential credential = autoLookupSCMCredentials(url) + if (credential != null) { + credentialsId = credential.getId() + } + } + + // add the url to the userRemoteConfigs + userRemoteConfig.put("url", url) + + // only add credentials when provided/found + if (credentialsId != null) { + userRemoteConfig.put("credentialsId", credentialsId) + } + + // prepare the userRemoteConfigs object + log.info("checkoutScm from $url, with credentials: $credentialsId") + userRemoteConfigs.add(userRemoteConfig) + } + + // call the checkout step + checkout( + [ + $class : 'GitSCM', + branches : branches, + doGenerateSubmoduleConfigurations: doGenerateSubmoduleConfigurations, + extensions : extensions, + submoduleCfg : submoduleCfg, + userRemoteConfigs : userRemoteConfigs + ] + ) +} + +/** + * Tries to retrieve credentials for the given scmUrl by using configurations provided in + * resources/credentials/scm/credentials.json + * + * @param scmUrl The url of the repository + * @return The found Credential object or null when no credential object was found during auto lookup + * @see Credential + * @see CredentialParser + * @see JsonLibraryResource + * @see CredentialConstants + */ +Credential autoLookupSCMCredentials(String scmUrl) { + // load the json + JsonLibraryResource jsonRes = new JsonLibraryResource((DSL) this.steps, CredentialConstants.SCM_CREDENTIALS_PATH) + JSON credentialJson = jsonRes.load() + // parse the credentials + CredentialParser parser = new CredentialParser() + List credentials = parser.parse(credentialJson) + // try to find matching credential and return the credential + PatternMatcher matcher = new PatternMatcher() + return (Credential) matcher.getBestMatch(scmUrl, credentials) +} diff --git a/vars/checkoutScm.md b/vars/checkoutScm.md new file mode 100644 index 0000000..fb1a523 --- /dev/null +++ b/vars/checkoutScm.md @@ -0,0 +1,324 @@ +# checkoutScm + +The main purpose of the checkoutScm step is to remove some complexity +from pipeline scripts and bring back some functionality not existing yet +in Jenkins pipeline. + +# Table of contents +* [Features](#features) + * [Credential auto lookup](#credential-auto-lookup) + * [GIT_BRANCH environment variable support](#git-branch-environment-variable-support) + * [SCM_URL environment variable support](#scm-url-environment-variable-support) +* [Modes](#modes) + * [Mode 1 (Configuration Mode)](#mode-1-configuration-mode) + * [Example 1: Simple checkout](#example-1-simple-checkout) + * [Example 2: Advanced checkout](#example-2-advanced-checkout) + * [Example 3: Checkout with url and credentialId](#example-3-checkout-with-url-and-credentialid) + * [Example 4: Checkout with userRemoteConfigs](#example-4-checkout-with-userremoteconfigs) + * [Mode 2 (Job SCM Configuration)](#mode-2-job-scm) +* [Configuration options](#configuration-options) + * [`branches`](#branches-optional) + * [`credentialsId`](#credentialsid-optional) + * [`doGenerateSubmoduleConfigurations`](#dogeneratesubmoduleconfigurations-optional) + * [`extensions`](#extensions-optional) + * [`submoduleCfg`](#submodulecfg--optional) + * [`url`](#url) + * [`userRemoteConfig`](#userremoteconfig-optional) + * [`userRemoteConfigs`](#userremoteconfigs-optional) + * [`useScmVar`](#usescmvar-optional) +* [Related classes](#related-classes) + +## Features +### Credential auto lookup + +Especially in company environments where you have one account for +Jenkins checkouts setting the correct credentials in each +project/pipeline script can be annoying. + +If you provide a JSON file at this location +`resources/credentials/scm/credentials.json` in the format described in +[Credentials](../docs/credentials.md) the step will +automatically try to lookup the credentials for the provided scm url and +use them + +This step uses the best match by using the +[PatternMatcher](../src/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcher.groovy) +so the Credential with the most matching characters will be used for checkout. + +### GIT_BRANCH environment variable support + +With Jenkins pipeline the support for the GIT_BRANCH environment +variable was removed. This step reenables this functionality by calling +the [setGitBranch](setGitBranch.md) step after checkout. + +:bulb: If you are using [Mode 2 (Job SCM)](#mode-2-job-scm-configuration) you have to +add the "Check out to specific local branch" extension with empty branch +name +![checkout-to-local-branch](../docs/assets/checkout-scm/checkout-to-local-branch.png) + +### SCM_URL environment variable support + +With pipeline also the SCM_URL environment variable disappeared. This +step reenables this functionality by calling the +[setScmUrl](setScmUrl.md) step after checkout + +## Modes + +The checkoutScm option run with two modes + +### Mode 1 (Configuration Mode) + +This mode is used when your Jenkinsfile and the project to build are not +in the same repository. + +You have to provide a configuration for this step. The configuration +uses the same format/syntax as the `checkout: General SCM` step for GIT. + +:bulb: See also +[How to Customize Checkout for Pipeline Multibranch](https://support.cloudbees.com/hc/en-us/articles/226122247-How-to-Customize-Checkout-for-Pipeline-Multibranch) + +You have to provide at least the repository `url` for the checkout or a +`userRemoteConfig` or `userRemoteConfigs` configuration. + +#### Examples + +##### Example 1: Simple checkout +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +checkoutScm( + (SCM) : [ + (SCM_URL) : "git@domain.tld/group/project.git", + ] +) +``` + +##### Example 2: Advanced checkout +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +checkoutScm( + (SCM) : [ + (SCM_URL) : "git@domain.tld/group/project.git", + (SCM_BRANCHES) : [[name: '*/master'], [name: '*/develop']], + (SCM_DO_GENERATE_SUBMODULE_CONFIGURATION) : false, + (SCM_EXTENSIONS) : [[$class: 'LocalBranch']] + ] +) +``` + +##### Example 3: Checkout with url and credentialId +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +checkoutScm( + (SCM) : [ + (SCM_URL) : "git@domain.tld/group/project.git", + (SCM_CREDENTIALS_ID) : "jenkins-credential-id", + (SCM_BRANCHES) : [[name: '*/master'], [name: '*/develop']], + ] +) +``` + +##### Example 4: Checkout with userRemoteConfigs +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +checkoutScm( + (SCM) : [ + (SCM_BRANCHES) : [[name: '*/master'], [name: '*/develop']], + (SCM_EXTENSIONS) : [[$class: 'LocalBranch']], + (SCM_USER_REMOTE_CONFIGS) : [[credentialsId: 'jenkins-credential-id', url: 'git@domain.tld/group/project.git']] + ] +) +``` + +### Mode 2 (Job SCM Configuration) + +This mode is used when your Jenkinsfile and the project to build are in +the same repository. In this case the pipeline has an `scm` variable +which contains the configuration made on the Job page. + +![mode-2](../docs/assets/checkout-scm/mode-2.png) + +To do a checkout within your pipeline script with this mode you have to +call the step in your pipeline as follows: + +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +checkoutScm( + (SCM): [ + (SCM_USE_SCM_VAR): true + ] +) +``` + +The scm checkout will be executed exactly with the options you specified +on the job page so you can for example to merges with branches, specifiy +advanced clone behaviors etc. + +The GIT_BRANCH and SCM_URL detection also works in this mode + +:bulb: In order to detect the branch name please add the "Checkout to +specific local branch" option + +:bulb: When `useScmVar` is set to true this will overwrite any other option specified + +## Configuration options + +Complete list of all configuration options. Please note that they can +not be used all at the same time. + +All configuration options must be inside the `scm` +([`ConfigConstants.SCM`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)) +map element to be evaluated and used by the step. + +:bulb: Use the "pipeline-syntax" helper which is available on pipeline +job configuration pages to generate the configuration options + +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +checkoutScm( + (SCM) : [ + (SCM_BRANCHES): [[name: 'branch identifier']], + (SCM_CREDENTIALS_ID): "jenkins credential id", + (SCM_DO_GENERATE_SUBMODULE_CONFIGURATION): false, + (SCM_EXTENSIONS): [[$class: 'Class of git extension']], + (SCM_SUBMODULE_CONFIG): [], + (SCM_URL): "repository-url", + (SCM_USER_REMOTE_CONFIG): [credentialsId: 'jenkins-credential-id', url: 'git@domain.tld/group/project.git'], + (SCM_USER_REMOTE_CONFIGS): [[credentialsId: 'jenkins-credential-id', url: 'git@domain.tld/group/project.git']], + (SCM_USE_SCM_VAR): true + ] +) +``` + +### `branches` (optional) + +||| +|---|---| +|Constant|[`ConfigConstants.SCM_BRANCHES`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`List` of Map `[name: 'name-of-branch']`| +|Default|`[[name:'*/master'], [name: '*/develop']]`| + +Use this configuration option to specify the branches you want to checkout + +:exclamation: This configuration option is not used when `useScmVar` is set to `true` + +### `credentialsId` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.SCM_CREDENTIALS_ID`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`null`| + +When provided this credential id is used to generate the +`userRemoteConfigs` for checkout. If not provided the step will try to +do a credential auto lookup by using +`resources/credentials/scm/credentials.json`. + +:exclamation: This configuration option is not used when +* `userRemoteConfig` or +* `userRemoteConfigs` or +* `useScmVar` + +are set + +### `doGenerateSubmoduleConfigurations` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.SCM_DO_GENERATE_SUBMODULE_CONFIGURATION`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Boolean`| +|Default|`false`| + +Set to either false or true. At the moment no use case was found where +true made sense. It is safe to omit this configuration option. + +:exclamation: This configuration option is not used when `useScmVar` is set to `true` + +### `extensions` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.SCM_EXTENSIONS`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`List` of `Map`| +|Default|`[[$class: 'LocalBranch']]`| + +Use this config option to specify your extensions like "Clean before +checkout" or "Checkout to specific local branch" + +To ensure that GIT_BRANCH is set correctly always add the `LocalBranch` extension. + +### `submoduleCfg` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.SCM_SUBMODULE_CONFIG`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`List`| +|Default|`[]`| + +For now no use case was found for this option but to make sure all +config options are supported this option was added + +:exclamation: This configuration option is not used when `useScmVar` is set to `true` + +### `url` +||| +|---|---| +|Constant|[`ConfigConstants.SCM_URL`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`null`| + +The url to the repository, e.g. `"git@domain.tld/group/project.git"` + +:exclamation: This configuration option is not used when +* `userRemoteConfig` or +* `userRemoteConfigs` or +* `useScmVar` + +are set + +### `userRemoteConfig` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.SCM_USER_REMOTE_CONFIG`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Map` with format `[credentialsId: 'jenkins-credential-id', url: 'git@domain.tld/group/project.git']`| +|Default|`null`| + +Can be used to define one `userRemoteConfig`. This option will be transformed into `userRemoteConfigs` by putting it in a `List` + +:exclamation: This configuration option is not used when +* `userRemoteConfigs` or +* `useScmVar` + +are set + +### `userRemoteConfigs` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.SCM_USER_REMOTE_CONFIGS`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|List of `Map` with format `[[credentialsId: 'jenkins-credential-id', url: 'git@domain.tld/group/project.git']]`| +|Default|`null`| + +:exclamation: This configuration option is not used when +* `userRemoteConfigs` or +* `useScmVar` + +are set + +### `useScmVar` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.SCM_USE_SCM_VAR`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Boolean`| +|Default|`false`| + +When set to `true` the step uses the `scm` variable of the pipeline to +checkout the scm. + +## Related classes +* [Credential](../src/io/wcm/tooling/jenkins/pipeline/credentials/Credential.groovy) +* [CredentialConstants](../src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialConstants.groovy) +* [CredentialParser](../src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialParser.groovy) +* [PatternMatchable](../src/io/wcm/tooling/jenkins/pipeline/model/PatternMatchable.groovy) +* [PatternMatcher](../src/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcher.groovy) diff --git a/vars/execManagedShellScript.groovy b/vars/execManagedShellScript.groovy new file mode 100644 index 0000000..5119338 --- /dev/null +++ b/vars/execManagedShellScript.groovy @@ -0,0 +1,73 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +import io.wcm.tooling.jenkins.pipeline.shell.CommandBuilderImpl +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import org.jenkinsci.plugins.workflow.cps.DSL + +/** + * Adapter when called with list of arguments + * + * @param fileId The id of the managed files + * @param args List of string arguments for the manages script + * @return the output of the executed shell script + */ +String call(String fileId, List args) { + return this.call(fileId, args.join(" ")) +} + +/** + * Executes a managed script identified by fileId with the given argLine. + * Since managed shell scripts are not executable by default when provided by the configFileProvider + * this step takes also care about the specific chmod command. + * + * @param fileId The id of the managed script + * @param argLine The argument line for the managed script to be executed + * @return the output of the executed shell script + */ +String call(String fileId, String argLine) { + Logger log = new Logger(this) + log.info("Executing managed script with id: '$fileId' and argLine: '$argLine'") + + // creating an environment variable + String envVar = "SCRIPT_$fileId" + + // get the managed file via the configFileProvider step + configFileProvider([configFile(fileId: fileId, variable: envVar)]) { + // retrieve the path to the provided managed script + String managedScriptPath = env.getProperty(envVar) + + // make script executable + CommandBuilderImpl chmodBuilder = new CommandBuilderImpl((DSL) steps, "chmod") + chmodBuilder.addArgument("+x") + chmodBuilder.addPathArgument(managedScriptPath) + String chmodCommand = chmodBuilder.build() + sh(chmodCommand) + + // build shell command for executing managed script + CommandBuilderImpl commandBuilder = new CommandBuilderImpl((DSL) steps) + commandBuilder.addPathArgument(managedScriptPath) + commandBuilder.addArgument(argLine) + String command = commandBuilder.build() + + // execute the command + log.info("Executing command: $command") + return sh(returnStdout: true, script: command).trim() + } +} diff --git a/vars/execManagedShellScript.md b/vars/execManagedShellScript.md new file mode 100644 index 0000000..ce45c90 --- /dev/null +++ b/vars/execManagedShellScript.md @@ -0,0 +1,65 @@ +# execManagedShellScript + +The purpose of this step is to make the execution of managed shell +scripts easier. + +This step takes care about +* providing the managed shell script via the + [Config File Provider Plugin](https://wiki.jenkins-ci.org/display/JENKINS/Config+File+Provider+Plugin) +* making the shell script executable +* building the command line +* executing the script + +# Table of contents +* [Example](#example) +* [Related classes](#related-classes) + +## Example + +Given a managed shell script with the id `exec-managed-script-demo` +which only echos the provided arguments: + +![demo-script](../docs/assets/exec-managed-shell-script/demo-script.png) + +and a pipeline script with this content + +```groovy +@Library('pipeline-library') pipelineLibrary + +node { + def result = execManagedShellScript('exec-managed-script-demo',["arg1","arg2","arg3=value"]) + echo "execManagedShellScript result: '$result'" +} +``` + +and you run the job there will be a console output like this: + +```text +[Pipeline] node +Running on some-slave in /some/workspace/Job +[Pipeline] { +[Pipeline] wrap +provisioning config files... +copy managed file [exec-managed-script-demo script] to file:/some/workspace/Job@tmp/config4116095771414952801tmp +[Pipeline] { +[Pipeline] sh +[Doku Test] Running shell script ++ chmod +x '/some/workspace/Job@tmp/config4116095771414952801tmp' +[Pipeline] sh +[Doku Test] Running shell script ++ '/some/workspace/Job@tmp/config4116095771414952801tmp' arg1 arg2 arg3=value +[Pipeline] } +Deleting 1 temporary files +[Pipeline] // wrap +[Pipeline] echo +execManagedShellScript result: 'arg1 arg2 arg3=value' +[Pipeline] } +[Pipeline] // node +[Pipeline] End of Pipeline +``` + +As you can the the provided arguments were returned by the +`execManagedShellScript` step + +## Related classes +* [`CommandBuilder`](../src/io/wcm/tooling/jenkins/pipeline/shell/CommandBuilderImpl.groovy) \ No newline at end of file diff --git a/vars/execMaven.groovy b/vars/execMaven.groovy new file mode 100644 index 0000000..15e2c8b --- /dev/null +++ b/vars/execMaven.groovy @@ -0,0 +1,166 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +import hudson.AbortException +import io.wcm.tooling.jenkins.pipeline.environment.EnvironmentConstants +import io.wcm.tooling.jenkins.pipeline.managedfiles.ManagedFile +import io.wcm.tooling.jenkins.pipeline.managedfiles.ManagedFileConstants +import io.wcm.tooling.jenkins.pipeline.managedfiles.ManagedFileParser +import io.wcm.tooling.jenkins.pipeline.model.PatternMatchable +import io.wcm.tooling.jenkins.pipeline.shell.MavenCommandBuilderImpl +import io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants +import io.wcm.tooling.jenkins.pipeline.utils.PatternMatcher +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import io.wcm.tooling.jenkins.pipeline.utils.resources.JsonLibraryResource +import net.sf.json.JSON +import org.jenkinsci.plugins.workflow.cps.DSL + +/** + * Executes maven with the given configuration options inside the "maven" element. + * This step implements + * - auto lookup for global maven settings + * - auto lookup for local maven settings + * - auto lookup for NPMRC and NPM_CONFIG_USERCONFIG + * - auto lookup for BUNDLE_CONFIG + * + * @param config Configuration options for the step + */ +void call(Map config = null) { + config = config ?: [:] + Logger log = new Logger(this) + + // retrieve the configuration and set defaults + + // retrieve scm url via utility step + String scmUrl = getScmUrl(config) + + // initialize the command builder + MavenCommandBuilderImpl commandBuilder = new MavenCommandBuilderImpl((DSL) steps, params) + commandBuilder.applyConfig(config) + + // initialize the configuration files + List configFiles = [] + + // retrieve global settingsId + if (commandBuilder.getGlobalSettingsId() == null) { + // use autolookup for maven global settingsId + ManagedFile mavenGlobalSettingsManagedFile = autoLookupMavenSettings(ManagedFileConstants.GLOBAL_MAVEN_SETTINGS_PATH, scmUrl) + if (mavenGlobalSettingsManagedFile) { + commandBuilder.setGlobalSettingsId(mavenGlobalSettingsManagedFile.getId()) + } + log.info("mavenGlobalSettings was null, result from autolookup: ", commandBuilder.getGlobalSettingsId()) + } + + // retrieve settingsId + if (commandBuilder.getSettingsId() == null) { + // use autolookup for maven global settingsId + ManagedFile mavenSettingsManagedFile = autoLookupMavenSettings(ManagedFileConstants.MAVEN_SETTINS_PATH, scmUrl) + if (mavenSettingsManagedFile) { + commandBuilder.setSettingsId(mavenSettingsManagedFile.getId()) + } + log.info("mavenSettings was null, result from autolookup: ", commandBuilder.getSettingsId()) + } + + // check if global maven settingsId were found during auto lookup and add them + if (commandBuilder.getGlobalSettingsId() != null) { + configFiles.push(configFile(fileId: commandBuilder.getGlobalSettingsId(), targetLocation: '', variable: ManagedFileConstants.GLOBAL_MAVEN__SETTINGS_ENV)) + } + + // check if local maven settingsId were found during auto lookup and add them + if (commandBuilder.getSettingsId() != null) { + configFiles.push(configFile(fileId: commandBuilder.getSettingsId(), targetLocation: "", variable: ManagedFileConstants.MAVEN_SETTING_ENV)) + } + + // add config file for NPM_CONFIG_USERCONFIG if defined + addManagedFile(log, scmUrl, ManagedFileConstants.NPM_CONFIG_USERCONFIG_PATH, ManagedFileConstants.NPM_CONFIG_USERCONFIG_ENV, configFiles) + // add config file for NPMRC if defined + addManagedFile(log, scmUrl, ManagedFileConstants.NPMRC_PATH, ManagedFileConstants.NPMRC_ENV, configFiles) + // add config file for ruby + addManagedFile(log, scmUrl, ManagedFileConstants.BUNDLE_CONFIG_PATH, ManagedFileConstants.BUNDLE_CONFIG_ENV, configFiles) + + configFileProvider(configFiles) { + // add global settingsId + if (commandBuilder.getGlobalSettingsId() != null) { + String path = env.getProperty(ManagedFileConstants.GLOBAL_MAVEN__SETTINGS_ENV) + commandBuilder.setGlobalSettings(path) + } + + // add local settingsId + if (commandBuilder.getSettingsId() != null) { + String path = env.getProperty(ManagedFileConstants.MAVEN_SETTING_ENV) + commandBuilder.setSettings(path) + } + + // build the command line + command = commandBuilder.build() + log.info("executing maven with: $command") + + // execute the maven command + sh(command) + } +} + +/** + * Searches for a matching maven setting for the scmUrl in the provided json + * + * @param jsonPath Path to the json conaining configurations for managed files + * @param scmUrl The url of the used scm + * @return A found Managed file, or null + */ +ManagedFile autoLookupMavenSettings(String jsonPath, String scmUrl) { + // load and parse the json + JsonLibraryResource jsonLibraryResource = new JsonLibraryResource(steps, jsonPath) + JSON managedFilesJson = jsonLibraryResource.load() + ManagedFileParser parser = new ManagedFileParser() + List managedFiles = parser.parse(managedFilesJson) + // match the scmUrl against the parsed mangedFiles and get the best match + PatternMatcher matcher = new PatternMatcher() + return (ManagedFile) matcher.getBestMatch(scmUrl, managedFiles) +} + +/** + * Searches for a managed file in the json from jsonPath by using the scmUrl for matching and adds the file + * to the provided configFiles object when a result was found. + * + * @param log Instance of the execMaven logger + * @param scmUrl The scm url of the current job + * @param jsonPath Path to the json containing configurations for managed files + * @param envVar The environment variable where the configFileProvider should store the path in + * @param configFiles List of config files where the found file has to be added + */ +void addManagedFile(Logger log, String scmUrl, String jsonPath, String envVar, List configFiles) { + try { + // load and parse the json + JsonLibraryResource jsonLibraryResource = new JsonLibraryResource(steps, jsonPath) + JSON managedFilesJson = jsonLibraryResource.load() + ManagedFileParser parser = new ManagedFileParser() + List managedFiles = parser.parse(managedFilesJson) + PatternMatcher matcher = new PatternMatcher() + // match the scmUrl against the parsed mangedFiles and get the best match + PatternMatchable managedFile = matcher.getBestMatch(scmUrl, managedFiles) + // when a file was found add it to the configFiles + if (managedFile) { + log.info("Found managed file for env var '$envVar' with id: '${managedFile.id}', adding to provided config files") + configFiles.push(configFile(fileId: managedFile.getId(), targetLocation: "", variable: envVar)) + } + } catch (AbortException ex) { + log.debug("Unable to load resource from $jsonPath") + } + +} diff --git a/vars/execMaven.md b/vars/execMaven.md new file mode 100644 index 0000000..01197a2 --- /dev/null +++ b/vars/execMaven.md @@ -0,0 +1,498 @@ +# execMaven + +Especially in company environments where you have your own artifact +managers like [Nexus](http://www.sonatype.org/nexus/) or +[Artifactory](https://www.jfrog.com/artifactory/) for caching and +storage you want to provide project based global and local settings. + +To make this easier the `execMaven` step provides autolookup based on +* [ManagedFiles](../docs/managed-files.md) and the +* [PatternMatching](../docs/pattern-matching.md) algorithm + +This can of course be done by wrapping the `sh` step inside a +`configFileProvider` step and define all necessary managed files here +but this can be quite anoying and it makes it difficult to maintain the +scripts and configurations in a large CI environment. + +This step removes some complexity from your scripty providing automatically +* global maven settings +* local maven settings +* NPM configuration +* Ruby Bundler configuration + +It also takes care about the command line building by transforming the +given configuration into a `sh` step call. + +# Table of contents +* [Managed file auto lookup](#managed-file-auto-lookup) + * [Global Maven Settings](#global-maven-settings) + * [Example for global Maven settings auto lookup](#example-for-global-maven-settings-auto-lookup) + * [Local Maven settings](#local-maven-settings) + * [Example for local Maven settings auto lookup](#example-for-local-maven-settings-auto-lookup) + * [NPM/node.js environment configuration](#npmnodejs-environment-configuration) + * [Example for `NPM_CONFIG_USER_CONFIG` and `NPMRC` auto lookup](#example-for-npm-config-user-config-and-npmrc-auto-lookup) + * [Bundler environment configuration](#bundler-environment-configuration) + * [Example for `BUNDLE_CONFIG` auto lookup](#example-for-bundle-config-auto-lookup) +* [Examples](#examples) + * [Example 1: All configuration options used](#example-1-all-configuration-options-used) + * [Example 2: Simple maven call](#example-2-simple-maven-call) + * [Example 3: Just maven](#example-3-just-maven) + * [Example 4: Maven version](#example-4-maven-version) +* [Configuration options](#configuration-options) + * [`arguments` (optional)](#arguments-optional) + * [`defines` (optional)](#defines-optional) + * [`executable` (optional)](#executable-optional) + * [`globalSettings` (optional)](#globalsettings-optional) + * [`goals` (optional)](#goals-optional) + * [`pom` (optional)](#pom-optional) + * [`profiles` (optional)](#profiles-optional) + * [`settings` (optional)](#settings-optional) +* [Related classes](#related-classes) + +## Managed file auto lookup + +The managed file auto lookup is the core functionality of the +`execMaven` step. It reduces the amount of time that must be spend for +configuring maven builds in larger environments to a minimum. + +### Global Maven Settings + +If you provide a JSON file at this location +`resources/managedfiles/maven/global-settings.json` in the format +described in [ManagedFiles](../docs/managed-files.md) the step will +automatically try to lookup the global settings for the provided scm url +and use them + +This step uses the best match by using the +[PatternMatcher](../src/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcher.groovy) +so the `ManagedFile` with the most matching characters will be used as +global setting. + +When no global setting was found the command line parameter will be omitted. + +#### Example for global Maven settings auto lookup + +Given a company with a GIT server at `https://git.company.tld`, a global +maven setting with id `company-global-maven-setting` stored inside +Jenkins as ManagedFile and we assume that all projects should use this +per default. + +When you setup your own pipeline library which uses the pipeline-library +all you have to do is to create +`resources/managedfiles/maven/global-settings.json` with this content: + +```json +[ + { + "pattern": "git.company.tld", + "id": "company-global-maven-setting", + "name": "Company global maven settings", + "comment": "Global maven settings for nexus.company.tld" + } +] + +``` + +When you now execute the `execMaven` step with +```groovy +execMaven( + scm : [ url: 'https://git.company.tld/group/project.git' ], + maven : [ goals: ['clean', 'install'] ] +) +``` + +Maven will be executed with this commandline: `mvn clean +install --global-settings +'/path/to/temporary/managed-global-settings-file'` + +### Local Maven settings + +The local settings mechanism works the same way as the global maven +settings but with an other json file containing the definitions. + +Local Maven settings are useful when you have project specific +credentials on your artifact server. + +If you provide a JSON file at this location +`resources/managedfiles/maven/settings.json` in the format +described in [ManagedFiles](../docs/managed-files.md) the step will +automatically try to lookup the settings for the provided scm url +and use them + +This step uses the best match by using the +[PatternMatcher](../src/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcher.groovy) +so the `ManagedFile` with the most matching characters will be used as +global setting. + +When no local setting was found the command line parameter will be +omitted. + +#### Example for local Maven settings auto lookup + +Given a company with a GIT server at `https://git.company.tld`, a local +maven setting with id `group1-project1-local-maven-setting` stored +inside Jenkins as ManagedFile. + +When you setup your own pipeline library which uses the pipeline-library +all you have to do is to create +`resources/managedfiles/maven/settings.json` with this content: + +```json +[ + { + "pattern": "git.company.tld/group1/project1", + "id": "group1-project1-local-maven-setting", + "name": "group1, project1 local maven msettings", + "comment": "Local maven settings to deploy group1/project1 artifacts to nexus.company.tld" + } +] + +``` + +When you now execute the `execMaven` Step with +```groovy +execMaven( + scm : [ url: 'https://git.company.tld/group1/project1.git' ], + maven : [ goals: ['clean', 'install'] ] +) +``` + +Maven will be executed with this commandline: `mvn clean +install --settings +'/path/to/temporary/managed-group1-project1-settings-file'` + +### NPM/node.js environment configuration + +If you are using node.js/NPM to build frontend stuff within your maven +projects you can use the `execMaven` step to automatically provide +* managed configuration file to `NPM_CONFIG_USER_CONFIG` environment + variable +* managed configuration file to `NPMRC` environment variable + +#### Example for `NPM_CONFIG_USER_CONFIG` and `NPMRC` auto lookup + +Given a company with a GIT server at `https://git.company.tld`, a npmrc +setting with id `group1-project1-npmrc` and a npm config with id +`group1-project1-npm-config` stored inside Jenkins as ManagedFile. + +When you setup your own pipeline library which uses the pipeline-library +all you have to do is to create the file +`resources/managedfiles/npm/npmrc.json` with this content: + +```json +[ + { + "pattern": "git.company.tld/group1/project1", + "id": "group1-project1-npmrc", + "name": "group1, project1 npmrc", + "comment": "npmrc for group1/project1" + } +] + +``` + +and a file `resources/managedfiles/npm/npm-config-userconfig.json` with +this content: + +```json +[ + { + "pattern": "git.company.tld/group1/project1", + "id": "group1-project1-npm-config", + "name": "group1, project1 npm config", + "comment": "npmrc for group1/project1" + } +] + +``` + +When you now execute the `execMaven` Step with +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +execMaven( + (SCM) : [ (SCM_URL) : 'https://git.company.tld/group1/project1.git' ], + (MAVEN) : [ (MAVEN_GOALS) : ['clean', 'install'] ] +) +``` + +Maven will be executed with this commandline: `mvn clean +install` and for the duration of the execution the managed files are +available in these environment variables +* `NPMRC` +* `NPM_CONFIG_USER_CONFIG` + +### Bundler environment configuration + +The `execMaven` is also able to provide npm bundler configuration as +environment variable `BUNDLE_CONFIG` during the execution of maven. + +#### Example for `BUNDLE_CONFIG` auto lookup + +Given a company with a GIT server at `https://git.company.tld` and a ruby +bundler setting with id `group1-project1-bundle-config` stored inside Jenkins as ManagedFile. + +When you setup your own pipeline library which uses the pipeline-library +all you have to do is to create the file +`resources/managedfiles/ruby/bundle-config.json` with this content: + +```json +[ + { + "pattern": "git.company.tld/group1/project1", + "id": "group1-project1-bundle-config", + "name": "group1, project1 ruby bundler config", + "comment": "ruby bundler config for group1/project1" + } +] + +``` + +When you now execute the `execMaven` Step with +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +execMaven( + (SCM) : [ (SCM_URL) : 'https://git.company.tld/group1/project1.git' ], + (MAVEN) : [ (MAVEN_GOALS): ['clean', 'install'] ] +) +``` + +Maven will be executed with this commandline: `mvn clean +install` and for the duration of the execution the managed file is +available in this environment variable +* `BUNDLE_CONFIG` + +## Build parameter injection + +The UI version of the maven execution step supports the use of build +parameters as defines. + +The `execMaven` also supports this functionality by simply enabling this functionality. + +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +execMaven( + (MAVEN): [ + (MAVEN_GOALS) : ["clean", "install"], + (MAVEN_DEFINES) : ["continuousIntegration": true, "flag": null], + (MAVEN_INJECT_PARAMS) : true, + (MAVEN_ARGUMENTS) : ["-B", "-U"] + ] +) +``` + +Enabling `MAVEN_INJECT_PARAMS` will add all existing builds parameters +from the global `params` Map object to the maven defines. + +## Examples + +### Example 1: All configuration options used + +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +execMaven( + (MAVEN): [ + (MAVEN_POM) : "path/to/customPom1.xml", + (MAVEN_GOALS) : ["clean", "install"], + (MAVEN_DEFINES) : ["continuousIntegration": true, "flag": null], + (MAVEN_GLOBAL_SETTINGS) : "global-settings-id", + (MAVEN_SETTINGS) : "local-settings-id", + (MAVEN_ARGUMENTS) : ["-B", "-U"] + ] +) +``` +The resulting `shell` command will look like: + + mvn -f path/to/customPom1.xml clean install -B -U + -Dcontinuous-integration=true -Dflag --global-settings + /path/to/job@tmp/config4417403508849619324tmp --settings + /path/to/job@tmp/config838306283686660309tmp + +:bulb: In this example the auto lookup for global and local maven +settings is omitted because `globalSettings` and `localSettings` were provided + +### Example 2: Simple maven call + +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +execMaven( + (MAVEN) : [ + (MAVEN_GOALS) : ["clean", "install"] + ] +) +``` +Assuming that no global and local settings were provided for auto lookup +mechanism The resulting `shell` command will look like: + + mvn clean install + +### Example 3: Just maven + +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +execMaven( + (MAVEN) : [:] +) +``` +Assuming that no global and local settings were provided for auto lookup +mechanism The resulting `shell` command will look like: + + mvn + +### Example 4: Maven version + +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +execMaven( + (MAVEM): [ + (MAVEN_ARGUMENTS) : ["--version"] + ] +) +``` +Assuming that no global and local settings were provided for auto lookup +mechanism The resulting `shell` command will look like: + + mvn --version + +## Configuration options + +Complete list of all configuration options. + +All configuration options must be inside the `maven` +([`ConfigConstants.MAVEN`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)) +map element to be evaluated and used by the step. + +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +execMaven( + (MAVEN) : [ + (MAVEN_ARGUMENTS): [ "-B", "-U" ], + (MAVEN_DEFINES): ["name": "value", "flag": null], + (MAVEN_EXECUTABLE): "/path/to/maven/bin", + (MAVEN_GLOBAL_SETTINGS): "managed-file-id", + (MAVEN_GOALS): ["goal1", "goal2"], + (MAVEN_INJECT_PARAMS): false, + (MAVEN_POM): "/path/to/pom.xml", + (MAVEN_PROFILES): ["profile1", "profile2"], + (MAVEN_SETTINGS): "managed-file-id", + ] +) +``` + +### `arguments` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.MAVEN_ARGUMENTS`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`List` of `String` or `String`| +|Default|`null`| + +Additional arguments for maven. Can be a `List of `String` like + + [ "-B", "-U" ] + +or a `String` like: + + "-B -U" + +### `defines` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.MAVEN_DEFINES`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Map` or `String`| +|Default|`null`| + +Defines for maven. Can be a `Map` like + + ["name": "value", "flag": null] + +or a `String` like: + + "-Dname=value -Dflag" + +### `executable` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.MAVEN_EXECUTABLE`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`mvn`| + +Defines the command for maven. + +You can specify the path to a custom mavn installation with this option like + + [ maven: [ executable: "/path/to/maven" ] ] + +### `globalSettings` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.MAVEN_GLOBAL_SETTINGS`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`null`| + +When provided the auto lookup mechanism for global maven settings is omitted +and the step tries to retrieve a managed file with the provided value. + +### `goals` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.MAVEN_GOALS`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`List` of `String`, or `String`| +|Default|`null`| + +The maven goals. Can be a `List of `String` like + + [ "goal1", "goal2" ] + +or a `String` like: + + "goal1 goal2" + +### `injectParams` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.MAVEN_INJECT_PARAMS`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Boolean`| +|Default|`false`| + +When set to true the current build parameters are injected as defines to +the maven command line. + +:bulb: The defines defined by `MAVEN_DEFINES` will not be overwritten +when using `MAVEN_INJECT_PARAMS` + +### `pom` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.MAVEN_POM`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`null`| + +Path to maven pom. When configuration is provided maven will be executed +without a path to a pom, so maven will look for a `pom.xml` in the +current working directory + +### `profiles` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.MAVEN_PROFILES`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String` or `List`| +|Default|`[]`| + +Maven profiles to use. + +### `settings` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.MAVEN_SETTINGS`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`null`| + +When provided the auto lookup mechanism for local maven settings is omitted +and the step tries to retrieve a managed file with the provided value. + +## Related classes +* [`ManagedFile`](../src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFile.groovy) +* [`ManagedFileParser`](../src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFileParser.groovy) +* [`CommandBuilder`](../src/io/wcm/tooling/jenkins/pipeline/shell/CommandBuilderImpl.groovy) +* [`MavenCommandBuilder`](../src/io/wcm/tooling/jenkins/pipeline/shell/MavenCommandBuilderImpl.groovy) +* [`PatternMatcher`](../src/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcher.groovy) diff --git a/vars/execMavenRelease.groovy b/vars/execMavenRelease.groovy new file mode 100644 index 0000000..f0d4506 --- /dev/null +++ b/vars/execMavenRelease.groovy @@ -0,0 +1,137 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +import io.wcm.tooling.jenkins.pipeline.environment.EnvironmentConstants +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import io.wcm.tooling.jenkins.pipeline.utils.maps.MapUtils +import io.wcm.tooling.jenkins.pipeline.versioning.ComparableVersion +import org.apache.maven.model.Model +import org.apache.maven.model.Plugin + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Utility step for performing a release with maven + * This step implements + * - check for prerequisites (git ssh scm url, correct maven release plugin version) + * - enable ssh agent with credential auto lookup + * - calling execMaven with the appropriate params + * + * @param config Configuration options from pipeline + */ +void call(Map config = null) { + config = config ?: [:] + Logger log = new Logger(this) + + // retrieve the configuration and set defaults + Map defaultConfig = [ + (MAVEN): [ + (MAVEN_GOALS) : ["release:prepare", "release:perform"], + (MAVEN_ARGUMENTS): ["-B", "-U"] + ] + ] + // merge the configuration + config = MapUtils.merge(defaultConfig, config) + + // retrieve scm url via utility step + String scmUrl = getScmUrl(config) + String scmBranch = env.getProperty(EnvironmentConstants.GIT_BRANCH) + + // check for correct scm configuration + checkScm(scmUrl, scmBranch) + + // check for correct maven release plugin versionNumber + checkMavenReleasePluginVersion(config) + + // wrap mvn commands into ssh agent to allow git commit and push actions + + sshAgentWrapper(scmUrl) { + // execute maven release + execMaven(config) + } +} + +/** + * Checks if the maven release plugin has the required minimum version. + * When the minimum version requirement is not met the step will exit with an error + * + * @param config The pipeline library configuration + */ +void checkMavenReleasePluginVersion(config) { + String effectivePomTmp = "effective-pom.tmp" + ComparableVersion minimalReleasePluginVersion = new ComparableVersion("2.5.3") + + Map effectivePomConfig = [ + (MAVEN): [ + (MAVEN_GOALS) : "help:effective-pom", + (MAVEN_DEFINES): [ + "output": effectivePomTmp + ] + ] + ] + config = MapUtils.merge(config, effectivePomConfig) + // call the effective pom step + execMaven(config) + + // read the maven pom + def mavenModel = readMavenPom(file: effectivePomTmp) + Map map = mavenModel.getBuild().getPluginManagement().getPluginsAsMap() + + def mavenReleasePlugin = map.get("org.apache.maven.plugins:maven-release-plugin") + if (!mavenReleasePlugin) { + error("No maven deploy plugin found in effective pom!") + } + String version = mavenReleasePlugin.getVersion() + ComparableVersion actualReleasePluginVersion = new ComparableVersion(version) + + if (actualReleasePluginVersion < minimalReleasePluginVersion) { + error("org.apache.maven.plugins:maven-release-plugin version requirement not met. Expected minimal version: '${minimalReleasePluginVersion}', found: '${actualReleasePluginVersion}' ") + } + + // set the maven model to null to avoid serialization issues + mavenModel = null +} + +/** + * Checks if url is a git ssh url and git branch is master, otherwise the step will exit with an error + * + * @param scmUrl The current scm url + * @param scmBranch The current scm branch + */ +void checkScm(String scmUrl, String scmBranch) { + // check if scm url is available + if (scmUrl == null) { + error("Unable to retrieve SCM url. Make sure to either provide the url by configuration or via `SCM_URL` envvar. Refer to getScmUrl and setScmUrl documentation.") + } + + // check if scm url is a git ssh url, otherwise fail since releasing via http(s) is not supported due to security reasons + if (!(scmUrl =~ '^git@.+:.+.git$')) { + error("Invalid SCM url. Make sure to either provide the url by configuration or via `SCM_URL` envvar. Refer to getScmUrl and setScmUrl documentation.") + } + + // check scm branch + if (scmBranch == null) { + error("Unable to retrieve 'GIT_BRANCH' environment variable. Make sure to checkout with 'checkout to local branch' extension enabled and call the setGitBranch step before.") + } + + // check for correct branch + if (scmBranch != 'master') { + error("Not allowed branch detected. You are only able to release from 'master' branch. Detected branch: '$scmBranch'. If you are seeing a commit hash make sure to checkout with 'checkout to local branch'.") + } +} \ No newline at end of file diff --git a/vars/execMavenRelease.md b/vars/execMavenRelease.md new file mode 100644 index 0000000..fe85169 --- /dev/null +++ b/vars/execMavenRelease.md @@ -0,0 +1,85 @@ +# execMavenRelease + +This step can be used to automate releases using Jenkins. + +Basically the `execMavenRelease` step calls the +[`execMaven`](execMaven.md) step with the goals `release:prepare` and +`release:perform`, but with a little bit of magic around it. + +# Table of contents +* [Prerequisites](#prerequisites) + * [SCM (Git+SSH)](#scm-gitssh) + * [Supported branches](#supported-branches) + * [Git configuration](#git-configuration) + * [maven-release-plugin version >= 2.5.3](#maven-release-plugin-version--253) +* [Workflow](#workflow) +* [Configuration options](#configuration-options) + +## Prerequisites + +### SCM (Git+SSH) + +The step only allows releases via git+ssh! Release via http(s) is +currently not supported. + +#### Supported branches + +You can use this step only from the `master` branch! The step will fail +for any other branch! + +#### Git configuration + +1. Create/configure a user with the appropriate rights got the branch (master) +2. Create/configure a ssh key pair for this user and configure that key + in Jenkins +3. Configure the ssh credential auto lookup (see + [`sshAgentWrapper`](sshAgentWrapper.md) for details) + +### maven-release-plugin version >= 2.5.3 + +You need at least the version 2.5.3 of the maven release plugin. +Versions prior 2.5.3 had some problems with committing to git. + +```xml + + + + + maven-release-plugin + 2.5.3 + + + + +``` + +## Workflow + +The step performs some steps to ensure that everything is configured and +used correctly to save you some time deleting wrong scm tags and +artifacts from your artifact manager + +1. Check SCM + 1. Check if scm url is a git ssh url + (`git@someserver:group/project.git`) + 2. Check if scm branch is `master` . + 3. fail if there is any error +2. Check plugin versions + 1. Generating the effective pom by using the execMaven step with the + goals `help:effective-pom` with output to + `effective-pom.tmp` + 2. Read the effective pom and checking for the correct + maven-release-plugin version + 3. fail if there is any error +3. Wrap the execMaven step by using the + [`sshAgentWrapper`](sshAgentWrapper.md) step +3. Call `execMaven` with `release:prepare release:perform` + +## Configuration options + +The `execMavenRelease` step has no dedicated configuration options. Have +a look at the configuration options for the +[`execMaven` configuration options](execMaven.md#configuration-options) for more information + +## Related classes +* [`ComparableVersion`](../src/io/wcm/tooling/jenkins/pipeline/versioning/ComparableVersion.groovy) \ No newline at end of file diff --git a/vars/execNpm.groovy b/vars/execNpm.groovy new file mode 100644 index 0000000..f408f77 --- /dev/null +++ b/vars/execNpm.groovy @@ -0,0 +1,121 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import hudson.AbortException +import io.wcm.tooling.jenkins.pipeline.managedfiles.ManagedFileConstants +import io.wcm.tooling.jenkins.pipeline.managedfiles.ManagedFileParser +import io.wcm.tooling.jenkins.pipeline.model.PatternMatchable +import io.wcm.tooling.jenkins.pipeline.shell.CommandBuilderImpl +import io.wcm.tooling.jenkins.pipeline.utils.PatternMatcher +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import io.wcm.tooling.jenkins.pipeline.utils.resources.JsonLibraryResource +import net.sf.json.JSON +import org.jenkinsci.plugins.workflow.cps.DSL + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Executes npm + * This step implements + * - auto lookup for NPMRC and NPM_CONFIG_USERCONFIG + * + * @param config Configuration options for the step + */ +void call(Map config = null) { + config = config ?: [:] + Logger log = new Logger(this) + + // retrieve the configuration and set defaults + Map npmConfig = (Map) config[NPM] ?: [:] + String npmExecutable = npmConfig[NPM_EXECUTABLE] ?: "npm" + List arguments = npmConfig[NPM_ARGUMENTS] ?: [] + + log.trace("NPM config: ", npmConfig) + + // retrieve scm url via utility step + String scmUrl = getScmUrl(config) + + // initialize the command builder + CommandBuilderImpl commandBuilder = new CommandBuilderImpl((DSL) steps, npmExecutable) + commandBuilder.addArguments(arguments) + + // initialize the configuration files + List configFiles = [] + + // add config file for NPM_CONF_USERCONFIG if defined + addManagedFile(log, scmUrl, ManagedFileConstants.NPM_CONFIG_USERCONFIG_PATH, ManagedFileConstants.NPM_CONF_USERCONFIG_ENV, configFiles) + + // add config file for NPM_CONF_GLOBALCONFIG if defined + addManagedFile(log, scmUrl, ManagedFileConstants.NPMRC_PATH, ManagedFileConstants.NPM_CONF_GLOBALCONFIG_ENV, configFiles) + + log.debug("configFiles", configFiles) + + // run in config file provider wrapper + configFileProvider(configFiles) { + // check if npm user config was provided + if (env.getProperty(ManagedFileConstants.NPM_CONF_USERCONFIG_ENV) != null) { + log.debug("found environment variable ${ManagedFileConstants.NPM_CONF_USERCONFIG_ENV}, value: ${env.getProperty(ManagedFileConstants.NPM_CONF_USERCONFIG_ENV)}") + commandBuilder.addPathArgument("--userconfig", (String) env.getProperty(ManagedFileConstants.NPM_CONF_USERCONFIG_ENV)) + } + // check if npm global config was provided + if (env.getProperty(ManagedFileConstants.NPM_CONF_GLOBALCONFIG_ENV) != null) { + log.debug("found environment variable ${ManagedFileConstants.NPM_CONF_GLOBALCONFIG_ENV}, value: ${env.getProperty(ManagedFileConstants.NPM_CONF_GLOBALCONFIG_ENV)}") + commandBuilder.addPathArgument("--globalconfig", (String) env.getProperty(ManagedFileConstants.NPM_CONF_GLOBALCONFIG_ENV)) + } + + // build the command line + command = commandBuilder.build() + log.info("executing npm with: $command") + + // execute the maven command + sh(command) + } +} + +/** + * Searches for a managed file in the json from jsonPath by using the scmUrl for matching and adds the file + * to the provided configFiles object when a result was found. + * + * @param log Instance of the execNpm logger + * @param scmUrl The scm url of the current job + * @param jsonPath Path to the json containing configurations for managed files + * @param envVar The environment variable where the configFileProvider should store the path in + * @param configFiles List of config files where the found file has to be added + */ +void addManagedFile(Logger log, String scmUrl, String jsonPath, String envVar, List configFiles) { + try { + // load and parse the json + JsonLibraryResource jsonLibraryResource = new JsonLibraryResource(steps, jsonPath) + JSON managedFilesJson = jsonLibraryResource.load() + ManagedFileParser parser = new ManagedFileParser() + List managedFiles = parser.parse(managedFilesJson) + PatternMatcher matcher = new PatternMatcher() + // match the scmUrl against the parsed mangedFiles and get the best match + PatternMatchable managedFile = matcher.getBestMatch(scmUrl, managedFiles) + // when a file was found add it to the configFiles + if (managedFile) { + log.info("Found managed file for env var '$envVar' with id: '${managedFile.id}', adding to provided config files") + configFiles.push(configFile(fileId: managedFile.getId(), targetLocation: "", variable: envVar)) + } + } catch (AbortException ex) { + log.debug("Unable to load resource from $jsonPath") + } + +} diff --git a/vars/execNpm.md b/vars/execNpm.md new file mode 100644 index 0000000..0e91bc8 --- /dev/null +++ b/vars/execNpm.md @@ -0,0 +1,137 @@ +# execNpm + +The `execNpm` steps enables you to NPM builds within your pipeline +scripts. The step supports the managed file auto lookup mechanism so you +don't have to configure your own npm artifact manager in every job. + +# Table of contents +* [Managed file auto lookup](#managed-file-auto-lookup) + * [NPM/node.js environment configuration](#npmnodejs-environment-configuration) + * [Example for `NPM_CONFIG_USER_CONFIG` and `NPMRC` auto lookup](#example-for-npm-config-user-config-and-npmrc-auto-lookup) +* [Examples]() +* [Configuration options](#configuration-options) + * [`arguments` (optional)](#arguments-optional) + * [`executable` (optional)](#executable-optional) +* [Related classes](#related-classes) + +## Managed file auto lookup + +The managed file auto lookup is the core functionality of the +`execNpm` step. It reduces the amount of time that must be spend for +configuring npm builds in larger environments to a minimum. + +### NPM/node.js environment configuration + +If you are using node.js/NPM to build frontend stuff within your ci +environment you can use the `execNpm` step to automatically provide +* managed configuration file to `NPM_CONFIG_USER_CONFIG` environment + variable +* managed configuration file to `NPMRC` environment variable + +#### Example for `NPM_CONFIG_USER_CONFIG` and `NPMRC` auto lookup + +Given a company with a GIT server at `https://git.company.tld`, a npmrc +setting with id `group1-project1-npmrc` and a npm config with id +`group1-project1-npm-config` stored inside Jenkins as ManagedFile. + +When you setup your own pipeline library which uses the pipeline-library +all you have to do is to create the file +`resources/managedfiles/npm/npmrc.json` with this content: + +```json +[ + { + "pattern": "git.company.tld/group1/project1", + "id": "group1-project1-npmrc", + "name": "group1, project1 npmrc", + "comment": "npmrc for group1/project1" + } +] + +``` + +and a file `resources/managedfiles/npm/npm-config-userconfig.json` with +this content: + +```json +[ + { + "pattern": "git.company.tld/group1/project1", + "id": "group1-project1-npm-config", + "name": "group1, project1 npm config", + "comment": "npmrc for group1/project1" + } +] + +``` + +When you now execute the `execNpm` Step with +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +execNpm( + (SCM) : [ (SCM_URL) : 'https://git.company.tld/group1/project1.git' ], + (NPM) : [ (NPM_ARGUMENTS): ['run', 'build'] ] +) +``` + +Npm will be executed with this commandline: + +`npm run build --userconfig /path/to/user/config --globalconfig / +path/to/global/config` + +## Examples + + + +## Configuration options + +Complete list of all configuration options. + +All configuration options must be inside the `npm` +([`ConfigConstants.NPM`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)) +map element to be evaluated and used by the step. + +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +execNpm( + (NPM) : [ + (NPM_ARGUMENTS): [ "run", "build" ], + (NPM_EXECUTABLE): "npm", + ] +) +``` + +### `arguments` +||| +|---|---| +|Constant|[`ConfigConstants.NPM_ARGUMENTS`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`List` of `String` or `String`| +|Default|`[]`| + +The arguments which will be placed after the `npm` command. + +```groovy +(NPM_ARGUMENTS) : [ "run", "build" ] +``` + +or a `String` like: + +```groovy +(NPM_ARGUMENTS) : [ "run build" ] +``` + +### `executable` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.NPM_EXECUTABLE`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Map` or `String`| +|Default|`null`| + +Defines the executable to use. Per default `execNpm` expects the executable `npm` to be in the `PATH` + +## Related classes +* [`ManagedFile`](../src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFile.groovy) +* [`ManagedFileParser`](../src/io/wcm/tooling/jenkins/pipeline/managedfiles/ManagedFileParser.groovy) +* [`CommandBuilder`](../src/io/wcm/tooling/jenkins/pipeline/shell/CommandBuilderImpl.groovy) +* [`PatternMatcher`](../src/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcher.groovy) diff --git a/vars/getScmUrl.groovy b/vars/getScmUrl.groovy new file mode 100644 index 0000000..64a57a4 --- /dev/null +++ b/vars/getScmUrl.groovy @@ -0,0 +1,43 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +import io.wcm.tooling.jenkins.pipeline.environment.EnvironmentConstants +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Tries to retrieve the current scm url by using some fallback steps + * + * @param config Configuration options for pipeline library + */ +String call(Map config = [:]) { + Logger log = new Logger(this) + Map scmConfig = (Map) config[SCM] ?: [:] + // try to retrieve scm url from config constants, otherwise do fallback to SCM_URL environment variable + String detectedScmUrl = scmConfig[SCM_URL] ?: null + if (detectedScmUrl == null) { + detectedScmUrl = env.getProperty(EnvironmentConstants.SCM_URL) ?: null + } + // log a warning when scm url is still null + if (detectedScmUrl == null) { + log.warn("Unable to detect scm url from config or environment variable!") + } + return detectedScmUrl +} \ No newline at end of file diff --git a/vars/getScmUrl.md b/vars/getScmUrl.md new file mode 100644 index 0000000..433e95b --- /dev/null +++ b/vars/getScmUrl.md @@ -0,0 +1,14 @@ +# getScmUrl + +The `getScmUrl` is a utility step which will return the url of the current SCM. + +The step tries to retrieve the scm url from the config object first and +does then a fallback to the `SCM_URL` environment variable. + +When no scm url is determined the utility step wil return `null` + +This step is used for example by: +* [`execMaven`](execMaven.md) +* [`execNpm`](execNpm.md) + + diff --git a/vars/integrationTestUtils.groovy b/vars/integrationTestUtils.groovy new file mode 100644 index 0000000..7be6cb6 --- /dev/null +++ b/vars/integrationTestUtils.groovy @@ -0,0 +1,154 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + + +import io.wcm.tooling.jenkins.pipeline.utils.IntegrationTestHelper +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger + +/** + * Assert utitlity function + * + * @param expected Expected object/value + * @param actual Actual object/value + */ +void assertEquals(expected, actual) { + if (expected != actual) { + error("Assertion error -> expected: '$expected', got '$actual'") + } +} + +/** + * Logs the test result + * + * @param results The test results as map + */ +void logTestResults(Map results) { + Logger log = new Logger(this) + List lines = [] + String separator = "##############################################################" + + lines.push("") + lines.push(separator) + lines.push("package test results") + lines.push(separator) + Integer maxLength = 0 + results.each { + String k, List v -> + v.each { + Map item -> + if (item.name.length() > maxLength) { + maxLength = item.name.length(); + } + } + } + results.each { + String k, List v -> + lines.push("Results for package: '$k'") + + v.each { + Map item -> + String result = "SUCCESS" + if (item.exception != null) { + result = "FAILURE" + } + lines.push(" - ${item.name.padRight(maxLength)} : ${result}") + } + } + log.info(lines.join('\n')) +} + +/** + * Processes the test results and fails when an error is found + * + * @param results The test results as map + */ +void processFailedTests(Map results) { + Logger log = new Logger(this) + List lines = [] + Integer maxLength = 0 + List failureItems = [] + // check if there are any errors: + results.each { + String k, List v -> + v.each { + Map item -> + if (item.exception != null) { + maxLength = item.name.length(); + failureItems.push(item) + } + } + } + + // return when there are no test failures + if (failureItems.size() == 0) { + log.info("no test failures found") + return + } + + String separator = "##############################################################" + + lines.push("") + lines.push(separator) + lines.push("package test failures") + lines.push(separator) + + failureItems.each { + Map item -> + lines.push(" - ${item.name.padRight(maxLength)} exception: ${item.exception}") + } + String message = lines.join('\n') + log.fatal(message) + error(message) +} + +/** + * Utility function for running a test + * + * @param className The name of the Class to run the test for + * @param testClosure Contains the test code + */ +void runTest(String className, Closure testClosure) { + Map result = [ + "name" : className, + "exception": null + ] + try { + testClosure() + } catch (Exception ex) { + result.exception = ex + } + IntegrationTestHelper.addTestResult(result) +} + +/** + * Wrapper function for reporting the test results for a package + * + * @param results The results object + * @param packageName The name of the package used for display in Stage View + * @param packageTestClosure The closure containing the code to be executed + */ +void runTestsOnPackage(String packageName, Closure packageTestClosure) { + // reset package test results + IntegrationTestHelper.addTestPackage(packageName) + String displayName = packageName.replace("io.wcm.tooling.jenkins.pipeline.", "") + stage(displayName) { + packageTestClosure() + } +} \ No newline at end of file diff --git a/vars/integrationTestUtils.md b/vars/integrationTestUtils.md new file mode 100644 index 0000000..e1f2f94 --- /dev/null +++ b/vars/integrationTestUtils.md @@ -0,0 +1,18 @@ +# integrationTestUtils + +Due to the constantly changing sandbox and CPS implementation +integration tests were introduced in version 0.9. + +The target is to offer test stuff to just test if the Classes and utils +work after a update of the Jenkins and the pipeline plugins + +To enable the resuse of some of the integration test functionalities +some of the test utils were moved to this file. + +These utils are used by: +* [integration-tests](../jenkinsfiles/integration-tests.groovy) + +## Related classes +* [`IntegrationTestHelper`](../src/io/wcm/tooling/jenkins/pipeline/utils/IntegrationTestHelper.groovy) + + diff --git a/vars/notifyMail.groovy b/vars/notifyMail.groovy new file mode 100644 index 0000000..456839b --- /dev/null +++ b/vars/notifyMail.groovy @@ -0,0 +1,139 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import io.wcm.tooling.jenkins.pipeline.utils.NotificationTriggerHelper +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +/** + * Used to send notifications at the end of a build. + * This step brings back the "still failing", "still unstable" and "fixed" + * functionality which is currently missing in the extmail step. + * + * @param config Configuration options for the step + * @see email-ext step + */ +void call(Map config = [:]) { + Logger log = new Logger(this) + // retrieve the configuration and set defaults + Map notifyConfig = (Map) config[NOTIFY] ?: [:] + + String subject = notifyConfig[NOTIFY_SUBJECT] ?: '${PROJECT_NAME} - Build # ${BUILD_NUMBER} - ${NOTIFICATION_TRIGGER}' + String body = notifyConfig[NOTIFY_BODY] ?: '${DEFAULT_CONTENT}' + String to = notifyConfig[NOTIFY_TO] + + String attachmentsPattern = notifyConfig[NOTIFY_ATTACHMENTS_PATTERN] ?: '' + Boolean attachLog = notifyConfig[NOTIFY_ATTACH_LOG] != null ? notifyConfig[NOTIFY_ATTACH_LOG] : false + Boolean compressLog = notifyConfig[NOTIFY_COMPRESS_LOG] != null ? notifyConfig[NOTIFY_COMPRESS_LOG] : false + String mimeType = notifyConfig[NOTIFY_MIME_TYPE] != null ? notifyConfig[NOTIFY_MIME_TYPE] : null + + Boolean onSuccess = notifyConfig[NOTIFY_ON_SUCCESS] != null ? notifyConfig[NOTIFY_ON_SUCCESS] : false + Boolean onUnstable = notifyConfig[NOTIFY_ON_UNSTABLE] != null ? notifyConfig[NOTIFY_ON_UNSTABLE] : true + Boolean onStillUnstable = notifyConfig[NOTIFY_ON_STILL_UNSTABLE] != null ? notifyConfig[NOTIFY_ON_STILL_UNSTABLE] : true + Boolean onFixed = notifyConfig[NOTIFY_ON_FIXED] != null ? notifyConfig[NOTIFY_ON_FIXED] : true + Boolean onFailure = notifyConfig[NOTIFY_ON_FAILURE] != null ? notifyConfig[NOTIFY_ON_FAILURE] : true + Boolean onStillFailing = notifyConfig[NOTIFY_ON_STILL_FAILING] != null ? notifyConfig[NOTIFY_ON_STILL_FAILING] : true + Boolean onAbort = notifyConfig[NOTIFY_ON_ABORT] != null ? notifyConfig[NOTIFY_ON_ABORT] : false + + // configure the recipient providers + // see https://jenkins.io/doc/pipeline/steps/email-ext/ + List recipientProviders = notifyConfig[NOTIFY_RECIPIENT_PROVIDERS] != null ? notifyConfig[NOTIFY_RECIPIENT_PROVIDERS] : [ + // list of users who committed change since last non broken build till now + [$class: 'CulpritsRecipientProvider'], + + // Sends email to all the people who caused a change in the change set. + [$class: 'DevelopersRecipientProvider'], + + // Sends email to the list of users suspected of causing a unit test to begin failing + //[$class: 'FailingTestSuspectsRecipientProvider'], + + // Sends email to the list of users suspected of causing the build to begin failing. + [$class: 'FirstFailingBuildSuspectsRecipientProvider'], + + // Sends email to the list of recipients defined in the "Project Recipient List." + // seems to work only when project based ACLs are present + //[$class: 'ListRecipientProvider'], + + // Sends email to the user who initiated the build. + [$class: 'RequesterRecipientProvider'], + + // Sends email to the list of users who committed changes in upstream builds that triggered this build. + [$class: 'UpstreamComitterRecipientProvider'] + ] + + // retrieve the current and previous build result + String currentBuildResult = currentBuild.result + String previousBuildResult = null + previousBuild = currentBuild.getPreviousBuild() + if (previousBuild) { + previousBuildResult = previousBuild.result + } + + log.trace("currentBuildResult", currentBuildResult) + log.trace("previousBuildResult", previousBuildResult) + + // calculate the notification trigger + NotificationTriggerHelper triggerHelper = new NotificationTriggerHelper(currentBuildResult, previousBuildResult) + String trigger = triggerHelper.getTrigger().toString() + + // set the environment variable + env.setProperty(NotificationTriggerHelper.ENV_TRIGGER, trigger) + + // replace notification trigger variable because extmail step does not know about it + subject = triggerHelper.replaceEnvVar(subject, trigger) + body = triggerHelper.replaceEnvVar(body, trigger) + + log.trace("value of envVar ${env.NOTIFICATION_TRIGGER}") + + // check if notification is configured for trigger + switch (true) { + case triggerHelper.isSuccess() && onSuccess: + case triggerHelper.isFixed() && onFixed: + case triggerHelper.isUnstable() && onUnstable: + case triggerHelper.isStillUnstable() && onStillUnstable: + case triggerHelper.isFailure() && onFailure: + case triggerHelper.isStillFailing() && onStillFailing: + case triggerHelper.isAborted() && onAbort: + // no nothing + break + default: + // return by default when previous block was not evaluated as true + log.info("Notification not enabled for: " + trigger) + return + break + } + + log.info("Sending notification for: " + trigger) + + // send the notification + emailext( + subject: subject, + body: body, + attachLog: attachLog, + attachmentsPattern: attachmentsPattern, + compressLog: compressLog, + mimeType: mimeType, + recipientProviders: recipientProviders, + to: to + ) + + +} diff --git a/vars/notifyMail.md b/vars/notifyMail.md new file mode 100644 index 0000000..9f0a78a --- /dev/null +++ b/vars/notifyMail.md @@ -0,0 +1,280 @@ +# notifyMail + +With jenkins pipeline the sending of mail notification lost some +functionality. For example the +* Still Failing +* Still Unstable and +* Fixed + +results are no longer available (at the moment) + +The `notifyMail` step brings back parts of this convenience. + +# Table of contents +* [Examples](#examples) + * [Default triggers with attached log and to-recipients](#default-triggers-with-attached-log-and-to-recipients) + * [Send only on first failure all participating developers](#send-only-on-first-failure-all-participating-developers) + * [Custom Subject](#custom-subject) +* [Configuration Options](#configuration-options) + * [attachLog](#attachlog-optional) + * [attachmentsPattern](#attachmentspattern-optional) + * [body](#body-optional) + * [compressLog](#compresslog-optional) + * [mimeType](#mimetype-optional) + * [onAbort](#onabort-optional) + * [onFailure](#onfailure-optional) + * [onStillFailing](#onstillfailing-optional) + * [onFixed](#onfixed-optional) + * [onSuccess](#onsuccess-optional) + * [onUnstable](#onunstable-optional) + * [onStillUnstable](#onstillunstable-optional) + * [recipientProviders](#recipientproviders-optional) + * [subject](#subject-optional) + * [to](#to-optional) +* [Related classes](checkoutScm.md#related-classes) + +## Examples + +### Default triggers with attached log and to-recipients + +``` groovy +notifyMail( + notify : [ + attachLog: true, + compressLog : false, + to: "recipient1@domain.tld,recipient2@domain.tld" + ] +) +``` + +### Send only on first failure all participating developers + +``` groovy +notifyMail( + notify : [ + attachLog: true, + compressLog : false, + onAbort : false, + onFailure : true, + onStillFailing : false, + onFixed : false, + onSuccess : false, + onUnstable : false, + onStillUnstable : false, + recipientProviders: [[$class: 'DevelopersRecipientProvider']] + ] +) +``` + +### Custom Subject + +``` groovy +notifyMail( + notify : [ + 'Custom notification for ${PROJECT_NAME} with status: ${NOTIFICATION_TRIGGER}', + ] +) +``` + +:exclamation: Make sure to use single quotes here because environment +variables would otherwise be directly evaluated! + +## Configuration options + +Complete list of all configuration options. + +All configuration options must be inside the `notify` ([`ConfigConstants.NOTIFY`](https://github.com/wcm-io-devops/jenkins-pipeline-library/blob/master/src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)) map element to be +evaluated and used by the step. + +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +notifyMail( + (NOTIFY) : [ + (NOTIFY_ATTACH_LOG): false, + (NOTIFY_ATTACHMENTS_PATTERN): '', + (NOTIFY_BODY): null, + (NOTIFY_COMPRESS_LOG): false, + (NOTIFY_MIME_TYPE): null, + (NOTIFY_ON_ABORT): true, + (NOTIFY_ON_FAILURE): true, + (NOTIFY_ON_STILL_FAILING): true, + (NOTIFY_ON_FIXED): true, + (NOTIFY_ON_SUCCESS): false, + (NOTIFY_ON_UNSTABLE): true, + (NOTIFY_ON_STILL_UNSTABLE): true, + + (NOTIFY_RECIPIENT_PROVIDERS) : null, + (NOTIFY_SUBJECT): '${PROJECT_NAME} - Build # ${BUILD_NUMBER} - ${NOTIFICATION_TRIGGER}', + (NOTIFY_TO): "recipient@domain.tld" + ] +) +``` + +### `attachLog` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.NOTIFY_ATTACH_LOG`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Boolean`| +|Default|`false`| + +Controls if the log should be attached to the mail. + +:bulb: See [Email Extension Plugin](https://jenkins.io/doc/pipeline/steps/email-ext/) + + +### `attachmentsPattern` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.NOTIFY_ATTACHMENTS_PATTERN`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`, comma separated list of ANT patterns| +|Default|`''`| + +The pattern(s) for the attachments which should be send along with the email. + +:bulb: See [Email Extension Plugin](https://jenkins.io/doc/pipeline/steps/email-ext/) + +### `body` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.NOTIFY_BODY`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`${DEFAULT_CONTENT}`| + +The body of the mail. The pipeline script assumes that you have a configured email template in place so default values is used `${DEFAULT_CONTENT}` + +:bulb: See [Email Extension Plugin](https://jenkins.io/doc/pipeline/steps/email-ext/) + +### `compressLog` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.NOTIFY_COMPRESS_LOG`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Boolean`| +|Default|`false`| + +When set to `true` the log is attached to the mail as compressed zip. + +:bulb: See [Email Extension Plugin](https://jenkins.io/doc/pipeline/steps/email-ext/) + +### `mimeType` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.NOTIFY_MIME_TYPE`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`null`| + +The mimeType of the mail. The pipeline script assumes that you have +configured the mimeType in the "Extended E-mail Notification" section of +your Jenkins instance. + +:bulb: See [Email Extension Plugin](https://jenkins.io/doc/pipeline/steps/email-ext/) + +### `onAbort` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.NOTIFY_ON_ABORT`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Boolean`| +|Default|`false`| + +When set to `true` a notification is send when the job is aborted. + +### `onFailure` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.NOTIFY_ON_FAILURE`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Boolean`| +|Default|`true`| + +When set to `true` a notification is send when the job swiches to +failure (first failure) + +:exclamation: For controlling the behavior when a job failes more than +one time in a row see [onStillFailing](#onstillfailing-optional) + +### `onStillFailing` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.NOTIFY_ON_STILL_FAILING`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Boolean`| +|Default|`true`| + +When set to `true` a notification is send when the job failed and the +previous build failed. + +### `onFixed` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.NOTIFY_ON_FIXED`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Boolean`| +|Default|`true`| + +When set to `true` a notification is send when the job status switches +from a non successful to successful. + +### `onSuccess` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.NOTIFY_ON_SUCCESS`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Boolean`| +|Default|`false`| + +When set to `true` a notification is send every time a job is +successful. + +### `onUnstable` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.NOTIFY_ON_UNSTABLE`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Boolean`| +|Default|`true`| + +When set to `true` a notification is send when the job switches to unstable. + +:exclamation: For controlling the behavior when a job is unstable more than +one time in a row see [onStillUnstable](#onstillunstable-optional) + +### `onStillUnstable` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.NOTIFY_ON_STILL_UNSTABLE`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Boolean`| +|Default|`true`| + +### `recipientProviders` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.NOTIFY_RECIPIENT_PROVIDERS`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`List` of `Map` with `RecipientProvider` classes| +|Default|`[[$class: 'CulpritsRecipientProvider'],[$class: 'DevelopersRecipientProvider'],[$class: 'FailingTestSuspectsRecipientProvider'], [$class: 'FirstFailingBuildSuspectsRecipientProvider'],[$class: 'RequesterRecipientProvider'][$class: 'UpstreamComitterRecipientProvider']]`| + +The list of recipient providers used to determine who should receive a +notification. Per default all recipent providers (except `ListProvider`) +are used. + +:bulb: See [Email Extension Plugin](https://jenkins.io/doc/pipeline/steps/email-ext/) + +### `subject` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.NOTIFY_SUBJECT`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`${PROJECT_NAME} - Build # ${BUILD_NUMBER} - ${NOTIFICATION_TRIGGER}`| + +The subject for the mail. + +:bulb: See [Email Extension Plugin](https://jenkins.io/doc/pipeline/steps/email-ext/) + +### `to` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.NOTIFY_TO`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`, comma separated list of email adresses| +|Default|`null`| + +Recipients that should always get a notification. This list has to be a +comma separated String of mail adresses. + +:bulb: See [Email Extension Plugin](https://jenkins.io/doc/pipeline/steps/email-ext/) + +## Related classes +* [`NotificationTriggerHelper`](../src/io/wcm/tooling/jenkins/pipeline/utils/NotificationTriggerHelper.groovy) diff --git a/vars/setBuildName.groovy b/vars/setBuildName.groovy new file mode 100644 index 0000000..f9d7943 --- /dev/null +++ b/vars/setBuildName.groovy @@ -0,0 +1,40 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +import io.wcm.tooling.jenkins.pipeline.environment.EnvironmentConstants +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger + +/** + * Sets the build name depending on the availability of the GIT_BRANCH environment variable. + * + */ +void call() { + Logger log = new Logger(this) + // set default versions + String versionNumberString = '#${BUILD_NUMBER}' + // check if GIT_BRANCH env var is available + if (env.getProperty(EnvironmentConstants.GIT_BRANCH) != null) { + versionNumberString = '#${BUILD_NUMBER}_${' + EnvironmentConstants.GIT_BRANCH + '}' + } + // create the versionNumber string + def version = VersionNumber(projectStartDate: '1970-01-01', versionNumberString: versionNumberString, versionPrefix: '') + log.info("created versionNumber number", version) + // set the builds display name + currentBuild.setDisplayName(version) +} diff --git a/vars/setBuildName.md b/vars/setBuildName.md new file mode 100644 index 0000000..9e5be17 --- /dev/null +++ b/vars/setBuildName.md @@ -0,0 +1,19 @@ +# setBuildName + +This step will create a versionNumberString and set it as display name +for the build. + +At the moment there are no configuration options for this step + +:bulb: If you want to make the branch name appear in your build call the +[`setGitBranch`](setGitBranch.groovy) step before calling this step + +## `GIT_BRANCH` environment variable available + +When the `GIT_BRANCH` environment variable is present this will be used format: + +`#${BUILD_NUMBER}_${GIT_BRANCH}` + +## `GIT_BRANCH` environment variable not available + +`#${BUILD_NUMBER}` \ No newline at end of file diff --git a/vars/setGitBranch.groovy b/vars/setGitBranch.groovy new file mode 100644 index 0000000..6266449 --- /dev/null +++ b/vars/setGitBranch.groovy @@ -0,0 +1,81 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +import io.wcm.tooling.jenkins.pipeline.environment.EnvironmentConstants +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import org.jenkinsci.plugins.scriptsecurity.sandbox.RejectedAccessException + +import java.util.regex.Matcher +import java.util.regex.Pattern + +/** + * This step brings back the GIT_BRANCH variable by trying several methods to determine the current git branch. + * The most reliably way is configure the scm checkout to use the "LocalBranch" extension so the step will be able to + * read it via shell command. + */ +String call() { + Logger log = new Logger(this) + + String result = null + + // try to retrieve via existing GIT_BRANCH env var + try { + result = env.getProperty(EnvironmentConstants.GIT_BRANCH) + } catch (e) { + log.trace("Tried to retrieve GIT_BRANCH from environment variable GIT_BRANCH but got exception", e) + } + if (result == null) { + // try to retrieve via existing BRANCH_NAME env var when running in multibranch pipeline builds + try { + result = env.getProperty(EnvironmentConstants.BRANCH_NAME) + } catch (e) { + log.trace("Tried to retrieve GIT_BRANCH from environment variable BRANCH_NAME but got exception", e) + } + } + + // if no result found try to retrieve it via git command line + if (result == null) { + log.debug("no git branch determined via log analysis, using fallback to shell") + try { + // call git branch command (make sure to enable "LocalBranch" extension during checkout to make this work + String localGitBranchResult = sh(returnStdout: true, script: 'git branch').trim() + // try to retieve pattern like "* develop" + def matcher = (localGitBranchResult =~ /\*\s([^(].*)/) + result = matcher ? matcher[0][1] : null + // reset matcher since matcher is not serializable! + matcher = null + } catch (Exception ex) { + // catch exception when checkout to subfolder + // TODO: Add support for checking out into subfolder + // setting empty result since following call will also fail + result = "" + } + + // when result is still null get the commit hash + if (result == null) { + gitCommit = sh(returnStdout: true, script: 'git rev-parse HEAD').trim() + result = gitCommit.take(6) + } + } + + env.setProperty(EnvironmentConstants.GIT_BRANCH, result) + log.info "set environment var GIT_BRANCH to '${env.getProperty(EnvironmentConstants.GIT_BRANCH)}'" + + return result +} diff --git a/vars/setGitBranch.md b/vars/setGitBranch.md new file mode 100644 index 0000000..a63c8af --- /dev/null +++ b/vars/setGitBranch.md @@ -0,0 +1,32 @@ +# setGitBranch + +With jenkins pipeline the `GIT_BRANCH` environment variable disappeared. +One the one hand this is obvious since you can work with several scms in +one pipeline, on the other hand this functionality is handy when you are +working with one scm. + +Calling this step will bring back this functionality by setting the +`GIT_BRANCH` to the best available value. + +:bulb: This step works best when you add the `LocalBranch` extension +during scm checkout: + +![checkout-to-local-branch](../docs/assets/checkout-scm/checkout-to-local-branch.png) + +or via [`checkoutScm`](checkoutScm.groovy) step. See +[`Example 4`](checkoutScm.groovy#example-4-checkout-with-userremoteconfigs) + +:bulb: If you are using the [`checkoutScm`](checkoutScm.groovy) step from +the library this step will be automatically called for your convenience. + +## How does it work? + +These steps are executed by the step in order to detect/retrieve the +`GIT_BRANCH` + +1. Check if `GIT_BRANCH` is already available +2. Check if `BRANCH_NAME` environment variable is set (this is the case + in Multibranch pipeline builds) +3. Try to retrieve via executing `git branch` (Therefore the + `LocalBranch` extension has to be enabled) +4. Fallback: Use the short git commit hash diff --git a/vars/setScmUrl.groovy b/vars/setScmUrl.groovy new file mode 100644 index 0000000..d3ea17a --- /dev/null +++ b/vars/setScmUrl.groovy @@ -0,0 +1,47 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +import io.wcm.tooling.jenkins.pipeline.environment.EnvironmentConstants +import io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger + +/** + * Utility step to retrieve scm url when checkout was done via default scm variable (e.g. checkout scm) + * + * @param config + */ +void call(Map config = [:]) { + // set default versions + Logger log = new Logger(this) + Map scmConfig = config[ConfigConstants.SCM] ?: [:] + String scmUrl = scmConfig[ConfigConstants.SCM_URL] ?: null + if (!scmUrl) { + // scm config has no url property, assuming multibranch build and try to detect with git from command line + try { + scmUrl = sh(returnStdout: true, script: 'git config remote.origin.url').trim() + } catch (Exception ex) { + // catch exception when checkout to subfolder + // TODO: Add support for checking out into subfolder + } + } + if (scmUrl) { + log.info("Setting environment variable " + EnvironmentConstants.SCM_URL + " to $scmUrl") + env.setProperty(EnvironmentConstants.SCM_URL, scmUrl) + } +} diff --git a/vars/setScmUrl.md b/vars/setScmUrl.md new file mode 100644 index 0000000..3f247c7 --- /dev/null +++ b/vars/setScmUrl.md @@ -0,0 +1,30 @@ +# setScmUrl + +With jenkins pipeline the `SCM_URL` environment variable disappeared. +One the one hand this is obvious since you can work with several scms in +one pipeline, on the other hand this functionality is handy when you are +working with one scm. + +Calling this step will bring back this functionality by setting the +`SCM_URL` to the best available value. + +## Variant 1 (using SCM config) +Assuming you are using the [`checkoutScm`](checkoutScm.groovy) step in +your project the `SCM_URL` will be automatically set by this step. + +In this case the step tries to retrieve the `SCM_URL` from +the config object: + +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +setScmUrl( + (SCM): [ + (SCM_URL): "git@domain.tld/group/project.git" + ] +) +``` + +## Variant 2 (GIT command line) +This variant is the fallback. By calling `git config remote.origin.url` +the remote URL is retrieved and set to the environment variable \ No newline at end of file diff --git a/vars/setupTools.groovy b/vars/setupTools.groovy new file mode 100644 index 0000000..4b1a330 --- /dev/null +++ b/vars/setupTools.groovy @@ -0,0 +1,79 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +import io.wcm.tooling.jenkins.pipeline.model.Tool +import io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger + +/** + * Main function to setup tools. Takes a list of + * + * [ name: 'STRING', type: 'Tool', envVar: 'STRING' ] + * + * This step automatically adds the tool path to the PATH environment so it will be available for later usage + * + * @param config The config containing the tools to be setup inside tools node + */ +void call(Map config) { + Logger log = new Logger(this) + List toolsConfig = (List) config[ConfigConstants.TOOLS] ? config[ConfigConstants.TOOLS] : [] + + for (Map toolConfig in toolsConfig) { + doSetupTool(toolConfig, log) + } +} + +/** + * Setups a tool based on the provided configuration. The path of the tool is automatically added to the PATH + * environment for later usage. + * + * @param toolConfig A map with this structure: [ name: 'STRING', type: 'STRING', envVar: 'STRING' ] + * @param log The logger instance from the main function + */ +void doSetupTool(Map toolConfig, Logger log) { + // retrieve the configuration variables + String toolName = toolConfig[ConfigConstants.TOOL_NAME] + Tool toolType = (Tool) toolConfig[ConfigConstants.TOOL_TYPE] + String toolEnvVar = toolConfig[ConfigConstants.TOOL_ENVVAR] + + // when no environment variable was provided do auto detection by using Tool enum + if (toolEnvVar == null && toolType != null) { + toolEnvVar = toolType.getEnvVar() + } + + log.debug("Setting up '$toolName' with type '$toolType' to environment variable '$toolEnvVar'") + + // call the Jenkins pipeline method to install the tool and get back the path + String retrievedTool = "${tool toolName}" + + // check of tool was retrieved correctly, otherwise abort + if (retrievedTool == null || retrievedTool == "") { + error("Tool '$toolName' not found, aborting") + } + + // if environment variable is present, set the environment variable to the path of the tool + if (toolEnvVar != null) { + env.setProperty(toolEnvVar, retrievedTool) + log.info "set environment var '$toolEnvVar' to: '${env.getProperty(toolEnvVar)}'" + } + + // add the tool path to the PATH variable for later usage + env.setProperty("PATH", "${retrievedTool}/bin:${env.PATH}") + log.info "set environment var PATH to: ${env.PATH}" +} diff --git a/vars/setupTools.md b/vars/setupTools.md new file mode 100644 index 0000000..f8cc6f0 --- /dev/null +++ b/vars/setupTools.md @@ -0,0 +1,160 @@ +# setupTools + +Despite the fact that setting up tools is one of the more easier steps +in pipeline there is always the hassle with providing the path to the +tool for `sh` access. + +This step takes care about this issue and ensures that the initialized +tool is also available in the `PATH` environment variable + +# Table of contents +* [Examples](#examples) + * [Setting up Maven and Jdk](#setting-up-maven-and-jdk) + * [Setting up two Jdk's](#setting-up-two-jdks) + * [Setting up tool without environment variable](#setting-up-tool-without-environment-variable) +* [Supported tools](#supported-tools) +* [Configuration options](#configuration-options) + * [`envVar` (optional)](#envvar-optional) + * [`name`](#name) + * [`type` (optional)](#type-optional) +* [Related classes](#related-classes) + +## Examples + +### Setting up Maven and Jdk + +``` groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +import io.wcm.tooling.jenkins.pipeline.model.Tool + +setupTools( + (TOOLS): [ + [ (TOOL_NAME): 'apache-maven3', (TOOL_TYPE): Tool.MAVEN ], + [ (TOOL_NAME): 'jdk8', (TOOL_TYPE): Tool.JDK ] + ] +) +``` + +After execution there will be two environment variables: +* `MAVEN_HOME=/path/to/maven/installation` +* `JAVA_HOME=/path/to/java/installation` + +The `PATH` environment variable will be adjusted and also contains the +paths to the tools. + +### Setting up two Jdk's + +``` groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +import io.wcm.tooling.jenkins.pipeline.model.Tool + +setupTools([ + (TOOLS): [ + [ (TOOL_NAME): 'jdk8', (TOOL_TYPE): Tool.JDK ], + [ (TOOL_NAME): 'jdk7', (TOOL_TYPE): Tool.JDK, (TOOL_ENVVAR): 'JAVA_HOME7' ] + ] +]) +``` + +After execution there will be two environment variables: +* `JAVA_HOME=/path/to/java8/installation` +* `JAVA_HOME7=/path/to/java7/installation` + +The `PATH` environment variable will be adjusted and also contains the +paths to the tools. + +:bulb: Use the environment variables in this case for executing since +there now will be two java binaries in the paths + +### Setting up tool without environment variable + +``` groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +import io.wcm.tooling.jenkins.pipeline.model.Tool +setupTools([ + (TOOLS): [ + [ (TOOL_NAME): 'jdk8' ] + ] +]) +``` + +After execution there will be **no** specific environment variable for +the tool since no `type` and no `envVar` was specified + +However, the `PATH` environment variable was adjusted and contains the +path to the tool. + +## Supported tools + +At the moment the following tools are supported for auto lookup the +environment variables. + +* MAVEN (environment variable: `MAVEN_HOME`) +* JDK (environment variable: `JAVA_HOME`) +* ANSIBLE (environment variable: `ANSIBLE_HOME`) +* GIT (environment variable: `GIT_HOME`) +* GROOVY (environment variable: `GROOVY_HOME`) +* MSBUILD (environment variable: `MSBUILD_HOME`) +* ANT (environment variable: `ANT_HOME`) +* PYTHON (environment variable: `PYTHON_HOME`) +* DOCKER (environment variable: `DOCKER_HOME`) +* NODEJS (environment variable: `NPM_HOME`) + +:bulb: See +[`Tool`](../src/io/wcm/tooling/jenkins/pipeline/model/Tool.groovy) + +## Configuration options + +Complete list of all configuration options. + +All configuration options must be inside the `tools` ([`ConfigConstants.TOOLS`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)) map element to be +evaluated and used by the step. + +The `tools` element must be a `List`. +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +setupTools([ + (TOOLS): [ + [ + (TOOL_ENVVAR): 'the-name-of-the-environemt-variable', + (TOOL_NAME): 'tool-name-defined-in-jenkins', + (TOOL_TYPE): "the-type-of-the.tool" + ], + // more tool definitions + ] +]) +``` + +Each tool definition has three properties: + +### `envVar` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.TOOL_ENVVAR`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String` The name of the environment variable where the path will be set after initialisation| +|Default|-| + +If set this environment variable will be used to store the path the tool + +### `name` +||| +|---|---| +|Constant|[`ConfigConstants.TOOL_NAME`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|-| + +The name of the tool configured in the Jenkins instance + +### `type` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.TOOL_TYPE`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|-| + +The type of the tool. [Supported tools](#supported-tools) +If provided the environment variable will be automatically set based on the type. + +## Related classes: +* [`Tool`](../src/io/wcm/tooling/jenkins/pipeline/model/Tool.groovy) \ No newline at end of file diff --git a/vars/sshAgentWrapper.groovy b/vars/sshAgentWrapper.groovy new file mode 100644 index 0000000..8ffd089 --- /dev/null +++ b/vars/sshAgentWrapper.groovy @@ -0,0 +1,115 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +import io.wcm.tooling.jenkins.pipeline.credentials.Credential +import io.wcm.tooling.jenkins.pipeline.credentials.CredentialConstants +import io.wcm.tooling.jenkins.pipeline.credentials.CredentialParser +import io.wcm.tooling.jenkins.pipeline.credentials.CredentialAware +import io.wcm.tooling.jenkins.pipeline.shell.ScpCommandBuilderImpl +import io.wcm.tooling.jenkins.pipeline.ssh.SSHTarget +import io.wcm.tooling.jenkins.pipeline.utils.PatternMatcher +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import io.wcm.tooling.jenkins.pipeline.utils.resources.JsonLibraryResource +import net.sf.json.JSON +import org.jenkinsci.plugins.workflow.cps.DSL + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCP + +/** + * Adapter step for one ssh target without credential aware parameter + * + * @param sshTarget the target to connect to + * @param body the closure to execute inside the wrapper + */ +void call(String sshTarget, Closure body) { + this.call([new SSHTarget(sshTarget)], body) +} + +/** + * Adapter step for one ssh target without credential aware parameter + * + * @param sshTarget the target to connect to as value object + * @param body the closure to execute inside the wrapper + */ +void call(SSHTarget sshTarget, Closure body) { + this.call([sshTarget], body) +} + +/** + * Step for encapsulating the provided body into a sshagent step with ssh credential autolookup + * + * @param sshTargets the targets to connect to + * @param credentialAware The credential aware object where the step should set the found credentials for the first target + * @param body the closure to execute inside the wrapper + */ +void call(List sshTargets, Closure body) { + Logger log = new Logger(this) + + Map foundCredentials = [:] + for (int i = 0; i < sshTargets.size(); i++) { + SSHTarget sshTarget = sshTargets[i] + + // auto lookup ssh credentials + log.trace("auto lookup credentials for : '${sshTarget.getHost()}'") + Credential sshCredential = autoLookupSSHCredentials(sshTarget.getHost()) + if (sshCredential != null) { + log.debug("auto lookup found the following credential for '${sshTarget.getHost()}' : '${sshCredential.id}'") + foundCredentials[sshCredential.id] = sshCredential + sshTarget.setCredential(sshCredential) + } else { + log.warn("No ssh credential was found for '$sshTarget' during auto lookup. Make sure to configure the credentials! See sshAgentWrapper.md for details.") + } + } + + // only use unique credentials + List sshCredentials = [] + foundCredentials.each { + String k, Credential v -> + sshCredentials.push(v.getId()) + } + + + log.trace("start ssh agent") + sshagent(sshCredentials) { + body() + } +} + +/** + * Tries to retrieve credentials for the given host by using configurations provided in + * resources/credentials/ssh/credentials.json + * + * @param host The host to connect to + * @return The found Credential object or null when no credential object was found during auto lookup + * @see io.wcm.tooling.jenkins.pipeline.credentials.Credential + * @see io.wcm.tooling.jenkins.pipeline.credentials.CredentialParser + * @see JsonLibraryResource + * @see io.wcm.tooling.jenkins.pipeline.credentials.CredentialConstants + */ +Credential autoLookupSSHCredentials(String host) { + // load the json + JsonLibraryResource jsonRes = new JsonLibraryResource((DSL) this.steps, CredentialConstants.SSH_CREDENTIALS_PATH) + JSON credentialJson = jsonRes.load() + // parse the credentials + CredentialParser parser = new CredentialParser() + List credentials = parser.parse(credentialJson) + // try to find matching credential and return the credential + PatternMatcher matcher = new PatternMatcher() + return (Credential) matcher.getBestMatch(host, credentials) +} \ No newline at end of file diff --git a/vars/sshAgentWrapper.md b/vars/sshAgentWrapper.md new file mode 100644 index 0000000..9385995 --- /dev/null +++ b/vars/sshAgentWrapper.md @@ -0,0 +1,104 @@ +# sshAgentWrapper + +This step provides an easy way to enable ssh keyagent support for your +shell commands. + +When you use this wrapper the step will try to automatically provide the +correct key for the given targetUrl. + +# Table of contents +* [Features](#features) + * [SSH Credential auto lookup](#ssh-credential-auto-lookup) +* [Examples](#examples) + * [Example 1: Simple usage](#example-1-simple-usage) + * [Example 2: Use a credential aware command builder](#example-2-use-a-credential-aware-command-builder) +* [Related classes](#related-classes) + +## Features +### SSH Credential auto lookup + +Especially in company environments where you may have one SSH account +for your testing environments the `scpTransfer` task makes your life a +lot easier by automatically setting up the ssh keyagent. + +If you provide a JSON file at this location +`resources/credentials/ssh/credentials.json` in the format described in +[Credentials](https://github.com/wcm-io-devops/jenkins-pipeline-library/blob/master/docs/credentials.md) the step will +automatically try to lookup the SSH credentials for the target host and +uses the credentials for authentication. + +This step uses the best match by using the +[PatternMatcher](https://github.com/wcm-io-devops/jenkins-pipeline-library/blob/master/src/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcher.groovy) +so the SSH credentials with the most matching characters will be used +for the sshagent. + +:bulb: At the moment only authentication via SSH keys stored in the +Jenkins instance are supported. + +## Examples + +### Example 1: Simple usage + +```groovy + +String targetHost = "testserver.yourcompany.de" + +sshAgentWrapper(targetHost) + sh "ssh testuser@testserver.yourcompany.de 'pwd'" +} +``` + +### Example 2: Use a credential aware command builder + +In this example we are providing the +[`CredentialAware`](../src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialAware.groovy) +[`ScpCommandBuilderImpl`](../src/io/wcm/tooling/jenkins/pipeline/shell/ScpCommandBuilderImpl.groovy). +During the ssh credential autolookup the found credentials are +automatically added to the `SSHTarget` object. With this information we are +able to use the username configured in the credential for building the command. + +```groovy +import io.wcm.tooling.jenkins.pipeline.shell.ScpCommandBuilderImpl +import io.wcm.tooling.jenkins.pipeline.ssh.SSHTarget + +ScpCommandBuilderImpl commandBuilder = new ScpCommandBuilderImpl((DSL) this.steps) +commandBuilder.setHost("testserver.yourcompany.de") +commandBuilder.setSourcePath("/path/to/source") +commandBuilder.setDestinationPath("/path/to/destination/") +commandBuilder.addArgument("-r") + +SSHTarget sshTarget = new SSHTarget(host) + +// use the sshAgentWrapper for ssh credential auto lookup +sshAgentWrapper([sshTarget]) { + commandBuilder.setCredential(sshTarget.getCredential()) + // build the command + command = commandBuilder.build() + // execute the command + sh(command) +} +``` + +### Example 3: Multipe SSH targets + +```groovy +import io.wcm.tooling.jenkins.pipeline.ssh.SSHTarget + +List sshTargets = [ + new SSHTarget("testserver1.yourcompany.de"), + new SSHTarget("testserver2.yourcompany.de"), +] + +sshAgentWrapper(sshTargets) + sh "ssh testuser@testserver1.yourcompany.de 'pwd'" + sh "ssh testuser@testserver2.yourcompany.de 'pwd'" +} +``` + +# Related classes +* [`Credential`](../src/io/wcm/tooling/jenkins/pipeline/credentials/Credential.groovy) +* [`CredentialAware`](../src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialAware.groovy) +* [`CredentialConstants`](../src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialConstants.groovy) +* [`CredentialParser`](../src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialParser.groovy) +* [`PatternMatcher`](../src/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcher.groovy) +* [`SSHTarget`](../src/io/wcm/tooling/jenkins/pipeline/ssh/SSHTarget.groovy) diff --git a/vars/transferScp.groovy b/vars/transferScp.groovy new file mode 100644 index 0000000..7f48215 --- /dev/null +++ b/vars/transferScp.groovy @@ -0,0 +1,63 @@ + /*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +import io.wcm.tooling.jenkins.pipeline.credentials.Credential +import io.wcm.tooling.jenkins.pipeline.credentials.CredentialConstants +import io.wcm.tooling.jenkins.pipeline.credentials.CredentialParser +import io.wcm.tooling.jenkins.pipeline.shell.ScpCommandBuilderImpl + import io.wcm.tooling.jenkins.pipeline.ssh.SSHTarget + import io.wcm.tooling.jenkins.pipeline.utils.PatternMatcher +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger +import io.wcm.tooling.jenkins.pipeline.utils.resources.JsonLibraryResource +import net.sf.json.JSON +import org.jenkinsci.plugins.workflow.cps.DSL + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.SCP + +/** + * Utility step to transfer files via scp. + * This step uses the sshAgentWrapper for ssh credential auto lookup + * + * @param config Configuration options for the step + */ +void call(Map config = null) { + config = config ?: [:] + Logger log = new Logger(this) + + // retrieve the configuration and set defaults + Map scpConfig = (Map) config[SCP] ?: [:] + + log.trace("SCP config: ", scpConfig) + + // initialize the command builder + ScpCommandBuilderImpl commandBuilder = new ScpCommandBuilderImpl((DSL) this.steps) + commandBuilder.applyConfig(scpConfig) + + SSHTarget sshTarget = new SSHTarget(commandBuilder.getHost()) + + // use the sshAgentWrapper for ssh credential auto lookup + sshAgentWrapper([sshTarget]) { + // provide credentials from sshAgentWrapper to commandbuilder + commandBuilder.setCredential(sshTarget.getCredential()) + command = commandBuilder.build() + log.info("The following scp command will be executed", command) + // execute the command + sh(command) + } +} \ No newline at end of file diff --git a/vars/transferScp.md b/vars/transferScp.md new file mode 100644 index 0000000..3cddde7 --- /dev/null +++ b/vars/transferScp.md @@ -0,0 +1,204 @@ +# transferScp + +This step provides an easy way to transfer files via SCP. + + +# Table of contents +* [Features](#features) + * [SSH Credential auto lookup](#ssh-credential-auto-lookup) + * [Path escaping](#path-escaping) +* [Examples](#examples) + * [Example 1: Transfer a single file](#example-1-transfer-a-single-file) + * [Example 2: Transfer multiple files (recursively)](#example-2-transfer-multiple-files-recursively) +* [Configuration options](#configuration-options) + * [`arguments` (optional)](#arguments-optional) + * [`destination`](#destination) + * [`executable` (Optional)](#executable-optional) + * [`host`](#host) + * [`hostKeyCheck` (Optional)](#hostkeycheck-optional) + * [`port` (Optional)](#port-optional) + * [`recursive` (Optional)](#recursive-optional) + * [`source`](#source) + * [`user` (Optional)](#user-optional) +* [Related classes](#related-classes) + +## Features +### SSH Credential auto lookup + +This step is using the [`sshAgentWrapper`](sshAgentWrapper.md) to wrap +the shell command. + +So if you configured the ssh credentials then the key is automatically +provided. + +### Path escaping +You don't have to take care about path escaping! This step uses the +[ScpCommandBuilderImpl](../src/io/wcm/tooling/jenkins/pipeline/shell/ScpCommandBuilderImpl.groovy) +which used the +[ShellUtils](../src/io/wcm/tooling/jenkins/pipeline/shell/ShellUtils.groovy) +to escape the paths for you. + +```groovy + (SCP) : [ + (SCP_HOST) : "somehost", + (SCP_RECURSIVE) : true, + (SCP_SOURCE) : "path to source/*", + (SCP_DESTINATION): "/var/www/target path with spaces/", + ] +``` + +Will be escaped into +* `SCP_SOURCE` = `path\ to\ source/*` +* `SCP_DESTINATION` = `/var/www/target\ path\ with\ spaces/` + +## Examples + +### Example 1: Transfer a single file +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +Map config = [ + // configure scp transport + (SCP) : [ + (SCP_HOST) : "your-target-host", + (SCP_SOURCE) : "target/index.html", + (SCP_DESTINATION): "/var/www/your-target-folder/", + ] +] + +transferScp(config) +``` + +### Example 2: Transfer multiple files (recursively) +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +Map config = [ + // configure scp transport + (SCP) : [ + (SCP_HOST) : "your-target-host", + (SCP_RECURSIVE) : true, + (SCP_SOURCE) : "target/*", + (SCP_DESTINATION): "/var/www/your-target-folder/", + ] +] + +transferScp(config) +``` + +## Configuration options + +Complete list of all configuration options. + +All configuration options must be inside the `scp` ([`ConfigConstants.SCP`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)) map element to be +evaluated and used by the step. + +The `scp` element must be a `Map`. +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +transferScp([ + (SCP): [ + [ + (SCP_ARGUMENTS): [], + (SCP_DESTINATION): '/path/to/target/dir-or-file', + (SCP_EXECUTABLE): 'scp', + (SCP_HOST): 'scp-target-host', + (SCP_HOST_KEY_CHECK): false, + (SCP_PORT): 22, + (SCP_RECURSIVE): true, + (SCP_SOURCE): '/path/to/source/dir-or-file', + (SCP_USER): "scp-user", + ], + // more tool definitions + ] +]) +``` + +### `arguments` (optional) +||| +|---|---| +|Constant|[`ConfigConstants.SCP_ARGUMENTS`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`List` of `String`| +|Default|`[]`| + +Additional arguments for SCP like `-v` for verbose output. + +`(SCP_ARGUMENTS): [ "-v" ]` + +### `destination` +||| +|---|---| +|Constant|[`ConfigConstants.SCP_DESTINATION`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`null`| + +### `executable` (Optional) +||| +|---|---| +|Constant|[`ConfigConstants.SCP_EXECUTABLE`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`scp`| + +Defines the executable to use. Per default `transferScp` expects the executable `scp` to be in the `PATH` + +### `host` +||| +|---|---| +|Constant|[`ConfigConstants.SCP_HOST`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`null`| + +The scp destination host. + +### `hostKeyCheck` (Optional) +||| +|---|---| +|Constant|[`ConfigConstants.SCP_HOST_KEY_CHECK`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Boolean`| +|Default|`false` + +Controls the host key check behavior. Per default the host key checking is disabled. +When set to `false` (default) ssh arguments are automatically added to the command line. + +`-o "StrictHostKeyChecking=no" -o "UserKnownHostsFile=/dev/null"` + +### `port` (Optional) +||| +|---|---| +|Constant|[`ConfigConstants.SCP_PORT`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Integer`| +|Default|`22`| + +### `recursive` (Optional) +||| +|---|---| +|Constant|[`ConfigConstants.SCP_RECURSIVE`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`Boolean`| +|Default|`false`| + +When set to true the `-r` argument is added to the command line and SCP will transfer in recursive mode. + +### `source` +||| +|---|---| +|Constant|[`ConfigConstants.SCP_SOURCE`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`null`| + +Path to the source which should be transferred. + +### `user` (Optional) +||| +|---|---| +|Constant|[`ConfigConstants.SCP_USER`](../src/io/wcm/tooling/jenkins/pipeline/utils/ConfigConstants.groovy)| +|Type|`String`| +|Default|`null`| + +The name of the user to use. Per default the user is determined during SSH credential auto lookup. + +## Related classes: +* [`Credential`](../src/io/wcm/tooling/jenkins/pipeline/credentials/Credential.groovy) +* [`CredentialConstants`](../src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialConstants.groovy) +* [`CredentialParser`](../src/io/wcm/tooling/jenkins/pipeline/credentials/CredentialParser.groovy) +* [`PatternMatcher`](../src/io/wcm/tooling/jenkins/pipeline/utils/PatternMatcher.groovy) +* [`ScpCommandBuilderImpl`](../src/io/wcm/tooling/jenkins/pipeline/shell/ScpCommandBuilderImpl.groovy) +* [`ShellUtils`](../src/io/wcm/tooling/jenkins/pipeline/shell/ShellUtils.groovy) diff --git a/vars/wrap.groovy b/vars/wrap.groovy new file mode 100644 index 0000000..10666b2 --- /dev/null +++ b/vars/wrap.groovy @@ -0,0 +1,51 @@ +/*- + * #%L + * wcm.io + * %% + * Copyright (C) 2017 wcm.io DevOps + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import io.wcm.tooling.jenkins.pipeline.environment.EnvironmentConstants +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.ANSI_COLOR +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.ANSI_COLOR_XTERM + +/** + * Enables color output in Jenkins console by using the ansiColor step + * Please refer to the documentation for details about the configuration options + * + * @param config The configuration options + * @param body The closure to be executed + */ +void color(Map config = [:], Closure body) { + Logger log = new Logger(this) + String ansiColorMap = (String) config[ANSI_COLOR] ?: ANSI_COLOR_XTERM + + String currentAnsiColorMap = env.getProperty(EnvironmentConstants.TERM) + if (currentAnsiColorMap == ansiColorMap) { + log.debug("Do not wrap with color scheme: '${ansiColorMap}' because wrapper with same color map is already active") + // current ansi color map is new color map, do not wrap again + body() + } else { + log.debug("Wrapping build with color scheme: '${ansiColorMap}'") + ansiColor(ansiColorMap) { + body() + } + } + + +} \ No newline at end of file diff --git a/vars/wrap.md b/vars/wrap.md new file mode 100644 index 0000000..7a4d732 --- /dev/null +++ b/vars/wrap.md @@ -0,0 +1,57 @@ +# Wrap + +This part of the library contains utilites for wrappers such as +* ansiColor + +# Table of contents + +* [`color(Map config, Closure body)`](#colormap-config-closure-body) + * [`color` Example](#color-example) + +## `color(Map config, Closure body)` + +This step is just a small adapter for the `ansiColor` step. +It uses the pipeline configuration to set the color mode + +### `color` Example + +```groovy +import io.wcm.tooling.jenkins.pipeline.utils.logging.LogLevel +import io.wcm.tooling.jenkins.pipeline.utils.logging.Logger + +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* +Map config = [ + (ANSI_COLOR) : ANSI_COLOR_XTERM +] + +Logger.init(this, LogLevel.INFO) +Logger log = new Logger(this) +wrap.color(config) { + log.info("i have a colorized loglevel!") +} + +``` + +### Configuration options + +The step has only one configuration option: `ConfigConstants.ANSI_COLOR`. +The value used in this configuration option is the color mode provided to the +`ansiColor` Step + +```groovy +import static io.wcm.tooling.jenkins.pipeline.utils.ConfigConstants.* + +wrap.color( + (ANSI_COLOR): ANSI_COLOR_XTERM + ) +``` + +Available values: +* `ConfigConstants.ANSI_COLOR_XTERM` +* `ConfigConstants.ANSI_COLOR_GNOME_TERMINAL` +* `ConfigConstants.ANSI_COLOR_VGA` +* `ConfigConstants.ANSI_COLOR_CSS` + +## Related classes: +* [`Logger`](../src/io/wcm/tooling/jenkins/pipeline/utils/logging/Logger.groovy) +* [`LogLevel`](../src/io/wcm/tooling/jenkins/pipeline/utils/logging/LogLevel.groovy)