From dbbdf58a6c235035c35f88de16872059a1813e67 Mon Sep 17 00:00:00 2001 From: Sergei Parshev Date: Wed, 4 Mar 2020 15:32:03 -0800 Subject: [PATCH] MPL-32 Initial working implementation of the JenkinsRule base class (#32) --- pom.xml | 108 +++++++-- .../modules/Build/BuildJenkinsRuleTest.groovy | 142 ++++++++++++ .../mpl/testing/MPLDefaultInvoker.groovy | 43 ++++ .../devops/mpl/testing/MPLInterceptor.groovy | 174 ++++++++++++++ .../testing/MPLLocalLibraryRetriever.groovy | 61 +++++ .../devops/mpl/testing/MPLMethodCall.groovy | 55 +++++ .../MPLMethodNotAllowedException.groovy | 43 ++++ .../mpl/testing/MPLSandboxInvoker.groovy | 43 ++++ .../mpl/testing/MPLTestBaseJenkinsRule.groovy | 219 ++++++++++++++++++ .../groovy/cps/impl/FunctionCallEnv.java | 69 ++++++ .../cloudbees/groovy/cps/sandbox/Invoker.java | 65 ++++++ 11 files changed, 1005 insertions(+), 17 deletions(-) create mode 100644 test/groovy/com/griddynamics/devops/mpl/modules/Build/BuildJenkinsRuleTest.groovy create mode 100644 test/groovy/com/griddynamics/devops/mpl/testing/MPLDefaultInvoker.groovy create mode 100644 test/groovy/com/griddynamics/devops/mpl/testing/MPLInterceptor.groovy create mode 100644 test/groovy/com/griddynamics/devops/mpl/testing/MPLLocalLibraryRetriever.groovy create mode 100644 test/groovy/com/griddynamics/devops/mpl/testing/MPLMethodCall.groovy create mode 100644 test/groovy/com/griddynamics/devops/mpl/testing/MPLMethodNotAllowedException.groovy create mode 100644 test/groovy/com/griddynamics/devops/mpl/testing/MPLSandboxInvoker.groovy create mode 100644 test/groovy/com/griddynamics/devops/mpl/testing/MPLTestBaseJenkinsRule.groovy create mode 100644 test/java/com/cloudbees/groovy/cps/impl/FunctionCallEnv.java create mode 100644 test/java/com/cloudbees/groovy/cps/sandbox/Invoker.java diff --git a/pom.xml b/pom.xml index 19d7205..a83d90b 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,13 @@ 1.8 1.8 UTF-8 + 2.204.2 + ${jenkins.version} + ${jenkins.version} + 2.62 + 2.80 + -Xms768M -Xmx768M -Djava.awt.headless=true -XX:+HeapDumpOnOutOfMemoryError -XX:+TieredCompilation -XX:TieredStopAtLevel=1 + 1.7.25 @@ -46,17 +53,30 @@ org.jenkins-ci.main jenkins-core - 2.73.3 + ${jenkins-core.version} org.jenkins-ci.plugins.workflow - workflow-cps-global-lib - 2.8 + workflow-cps + ${workflow-cps.version} org.jenkins-ci.plugins.workflow - workflow-cps - 2.44 + workflow-cps-global-lib + 2.15 + + + + junit + junit + 4.12 + test + + + org.assertj + assertj-core + 3.14.0 + test @@ -65,12 +85,63 @@ 1.1 test + + - junit - junit - 4.12 + org.jenkins-ci.main + jenkins-test-harness + ${jenkins-test-harness.version} + test + + + org.jenkins-ci.main + jenkins-war + ${jenkins-war.version} + war + test + + + org.jenkins-ci.plugins.workflow + workflow-job + 2.36 test + + + + org.jenkins-ci.plugins.workflow + workflow-basic-steps + 2.19 + test + + + + org.slf4j + slf4j-api + ${slf4jVersion} + test + + true + + + + org.slf4j + log4j-over-slf4j + ${slf4jVersion} + test + + + org.slf4j + jcl-over-slf4j + ${slf4jVersion} + test + + + org.slf4j + slf4j-jdk14 + ${slf4jVersion} + test + @@ -103,19 +174,21 @@ - org.codehaus.gmavenplus gmavenplus-plugin - 1.7.1 + 1.8.1 - addSources - addTestSources - generateStubs + + + + generateTestStubs compile compileTests + + removeTestStubs groovydoc groovydocTests @@ -124,9 +197,9 @@ - ${project.basedir} + ${project.basedir}/src - src/**/*.groovy + **/*.groovy @@ -144,12 +217,13 @@ - + ${project.basedir}/test **/*.groovy + **/*.java - + diff --git a/test/groovy/com/griddynamics/devops/mpl/modules/Build/BuildJenkinsRuleTest.groovy b/test/groovy/com/griddynamics/devops/mpl/modules/Build/BuildJenkinsRuleTest.groovy new file mode 100644 index 0000000..1cc8ac4 --- /dev/null +++ b/test/groovy/com/griddynamics/devops/mpl/modules/Build/BuildJenkinsRuleTest.groovy @@ -0,0 +1,142 @@ +// +// Copyright (c) 2020 Grid Dynamics International, Inc. All Rights Reserved +// https://www.griddynamics.com +// +// Classification level: Public +// +// 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. +// +// $Id: $ +// @Project: MPL +// @Description: Shared Jenkins Modular Pipeline Library +// + +import org.junit.Before +import org.junit.Test + +import static com.lesfurets.jenkins.unit.global.lib.LibraryConfiguration.library +import static com.lesfurets.jenkins.unit.global.lib.LocalSource.localSource + +import static org.assertj.core.api.Assertions.assertThat + +import com.griddynamics.devops.mpl.Helper +import com.griddynamics.devops.mpl.testing.MPLTestBaseJenkinsRule + +/** + * Same tests using JenkinsRule + * + * @author Sergei Parshev + */ +public class BuildJenkinsRuleTest extends MPLTestBaseJenkinsRule { + @Override + @Before + void setUp() throws Exception { + //setScriptRoots([ 'vars' ] as String[]) + //setScriptExtension('groovy') + + super.setUp() + + String sharedLibs = this.class.getResource('.').getFile() + helper.registerSharedLibrary(library() + .name('mpl') + .allowOverride(false) + .retriever(localSource(sharedLibs)) + .targetPath(sharedLibs) + .defaultVersion('snapshot') + .implicit(true) + .build() + ) + + binding.setVariable('env', [:]) + + // Shared lib requirements + helper.registerAllowedMethod('MPLModule', [], null) + helper.registerAllowedMethod('MPLModule', [String.class], null) + helper.registerAllowedMethod('MPLModule', [String.class, Object.class], null) + helper.registerAllowedMethod('call', [String.class, Object.class], null) + + // Test requirements + helper.registerAllowedMethod('fileExists', [String.class], { return false }) + helper.registerAllowedMethod('tool', [String.class], { name -> "${name}_HOME" }) + helper.registerAllowedMethod('withEnv', [List.class, Closure.class], null) + helper.registerAllowedMethod('sh', [String.class], {}) + } + + + @Test + void default_run() { + runMPLModule('Build') + + printCallStack() + + assertThat(helper.callStack) + .filteredOn { c -> c.methodName == 'tool' } + .filteredOn { c -> c.argsToString().contains('Maven 3') } + .as('Maven 3 tool used') + .isNotEmpty() + + assertThat(helper.callStack) + .filteredOn { c -> c.methodName == 'sh' } + .filteredOn { c -> c.argsToString().startsWith('mvn') } + .filteredOn { c -> c.argsToString().contains('clean install') } + .as('Shell execution should contain mvn command and default clean install') + .isNotEmpty() + + assertThat(helper.callStack) + .filteredOn { c -> c.methodName == 'sh' } + .filteredOn { c -> c.argsToString().startsWith('mvn') } + .filteredOn { c -> ! c.argsToString().contains('-s ') } + .as('Default mvn run without settings provided') + .isNotEmpty() + + assertJobStatusSuccess() + } + + @Test + void change_tool() { + runMPLModule('Build', [ + maven: [ + tool_version: 'Maven 2', + ], + ]) + + printCallStack() + + assertThat(helper.callStack) + .filteredOn { c -> c.methodName == 'tool' } + .filteredOn { c -> c.argsToString().contains('Maven 2') } + .as('Changing maven tool name') + .isNotEmpty() + + assertJobStatusSuccess() + } + + @Test + void change_settings() { + runMPLModule('Build', [ + maven: [ + settings_path: '/test-settings.xml', + ], + ]) + + printCallStack() + + assertThat(helper.callStack) + .filteredOn { c -> c.methodName == 'sh' } + .filteredOn { c -> c.argsToString().contains("-s '/test-settings.xml'") } + .as('Providing settings file should set the maven operation') + .isNotEmpty() + + assertJobStatusSuccess() + } +} diff --git a/test/groovy/com/griddynamics/devops/mpl/testing/MPLDefaultInvoker.groovy b/test/groovy/com/griddynamics/devops/mpl/testing/MPLDefaultInvoker.groovy new file mode 100644 index 0000000..c8009c0 --- /dev/null +++ b/test/groovy/com/griddynamics/devops/mpl/testing/MPLDefaultInvoker.groovy @@ -0,0 +1,43 @@ +// +// Copyright (c) 2020 Grid Dynamics International, Inc. All Rights Reserved +// https://www.griddynamics.com +// +// Classification level: Public +// +// 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. +// +// $Id: $ +// @Project: MPL +// @Description: Shared Jenkins Modular Pipeline Library +// + +package com.griddynamics.devops.mpl.testing + +import com.cloudbees.groovy.cps.sandbox.DefaultInvoker +import com.griddynamics.devops.mpl.testing.MPLInterceptor + +/** + * Default invoker override for intercepting the methodCall execution + * + * @author Sergei Parshev + */ +public class MPLDefaultInvoker extends DefaultInvoker { + @Override + public Object methodCall(Object receiver, String method, Object[] args) throws Throwable { + return MPLInterceptor.instance.processMethodCall(super.&doMethodCall, 'Default', receiver, method, args) + } + + public Object doMethodCall(Object receiver, String method, Object[] args) throws Throwable { + return super.methodCall(receiver, method, args) + } +} diff --git a/test/groovy/com/griddynamics/devops/mpl/testing/MPLInterceptor.groovy b/test/groovy/com/griddynamics/devops/mpl/testing/MPLInterceptor.groovy new file mode 100644 index 0000000..e51b553 --- /dev/null +++ b/test/groovy/com/griddynamics/devops/mpl/testing/MPLInterceptor.groovy @@ -0,0 +1,174 @@ +// +// Copyright (c) 2020 Grid Dynamics International, Inc. All Rights Reserved +// https://www.griddynamics.com +// +// Classification level: Public +// +// 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. +// +// $Id: $ +// @Project: MPL +// @Description: Shared Jenkins Modular Pipeline Library +// + +package com.griddynamics.devops.mpl.testing + +import com.griddynamics.devops.mpl.testing.MPLMethodCall +import com.griddynamics.devops.mpl.testing.MPLMethodNotAllowedException +import com.lesfurets.jenkins.unit.MethodSignature +import static com.lesfurets.jenkins.unit.MethodSignature.method + +import org.codehaus.groovy.runtime.MetaClassHelper + +import com.cloudbees.groovy.cps.SerializableScript +import com.cloudbees.groovy.cps.impl.CpsClosure +import org.jenkinsci.plugins.workflow.cps.CpsThread +import org.jenkinsci.plugins.workflow.cps.CpsThreadGroup +import org.jenkinsci.plugins.workflow.graph.BlockStartNode + +/** + * Stores all the logic to intercept groovy-cps Invoker methods + * Allows to trace the calls, replace arguments of specified functions + * + * @author Sergei Parshev + */ +@Singleton +class MPLInterceptor { + public boolean DEBUG_SHOW_METHODCALL_SKIPPING = false + + protected List allowed_scopes = [SerializableScript.class, CpsClosure.class] + + protected Map allowed_methods = [:] + + protected List registered_elements = [] + List call_stack = [] + + protected Map arguments_replaces = [:] + + public boolean checkReceiverScope(Object receiver) { + if( receiver instanceof Class ) + return receiver in allowed_scopes || receiver.getSuperclass() in allowed_scopes + for( def clazz : allowed_scopes ) { + if( clazz.isInstance(receiver) ) + return true + } + return false + } + + public void registerAllowedMethod(String name, List args = [], Closure closure) { + allowed_methods.put(method(name, args.toArray(new Class[args?.size()])), closure) + } + + public void registerAllowedMethod(MethodSignature methodSignature, Closure closure) { + allowed_methods.put(methodSignature, closure) + } + + public Object processMethodCall(Closure callback, String invoker, Object receiver, String name, Object... args) { + // The receiver should be in the testing scope + if( ! checkReceiverScope(receiver) ) { + if( Boolean.parseBoolean(System.getProperty('mpl.interceptor.show_methodcall_skipping')) ) { + if( receiver instanceof Class ) + System.out.println("----> skipping ${invoker} MethodCall: static ${receiver}.${name} (${receiver.getSuperclass()})") + else + System.out.println("----> skipping ${invoker} MethodCall: ${receiver.class}.${name} (${receiver.class?.getSuperclass()})") + } + return callback(receiver, name, args) + } + + // Put the method into the unit test stack + args = registerMethodCall(invoker, receiver, name, args) + + // Check if it is in the allowed list + def intercepted = getAllowedMethodEntry(name, args) + if( intercepted == null ) // Method is not allowed + throw new MPLMethodNotAllowedException(name, receiver.class, args) + + if( intercepted.value != null ) { // Allowed method closure specified + intercepted.value.delegate = receiver instanceof CpsClosure ? receiver.delegate : receiver + return callClosure(intercepted.value, args) + } + + // Executing the original allowed method + return callback(receiver, name, args) + } + + public List registerMethodCall(String invoker, Object receiver, String name, Object... args) { + if( Boolean.parseBoolean(System.getProperty('mpl.interceptor.show_methodcall_registering')) ) { + if( receiver instanceof Class ) + System.out.println("----> registering ${invoker} MethodCall: static ${receiver}.${name} (${receiver.getSuperclass()})") + else + System.out.println("----> registering ${invoker} MethodCall: ${receiver.class}.${name} (${receiver.class?.getSuperclass()})") + } + + // To produce better stacktrace view - using FlowNode blocks + int depth = CpsThread.current().head.get().getEnclosingBlocks().size() + // Adding the current block if it's a start node + depth += CpsThread.current().head.get() instanceof BlockStartNode ? 1 : 0 + + // This ways to determine the depth is not working correctly, but probably + // better if we need to test deeper than CpsScript / CpsClosure + // * Thread will not wirk well with the block steps closures + // int depth = CpsThread.current().getStackTrace().size() + // * Using Group is better - but still not working with MPLModule well + // and adding an extra depth level after each MPLModule call... + // int depth = CpsThreadGroup.current().getThreadDump().getThreads() + // .collect { it.getStackTrace() }.flatten().size() + + def call = new MPLMethodCall() + call.invoker = invoker + call.target = receiver instanceof CpsClosure ? receiver.delegate : receiver + call.methodName = name + call.args = args + call.args = pullArgumentsReplace(call.toString(), args) + call.stackDepth = depth + call_stack.add(call) + + return call.args + } + + public void addArgumentsReplace(String method_call_id, Object... args) { + arguments_replaces[method_call_id] = args + } + + public List pullArgumentsReplace(String method_call_id, Object... orig_args) { + if( Boolean.parseBoolean(System.getProperty('mpl.interceptor.show_methodcall_to_replace')) ) + System.out.println("----> Method to replace: '${method_call_id}'") + return arguments_replaces.remove(method_call_id) ?: orig_args + } + + public Map.Entry getAllowedMethodEntry(String name, Object... args) { + Class[] paramTypes = MetaClassHelper.castArgumentsToClassArray(args) + MethodSignature signature = method(name, paramTypes) + return allowed_methods.find { k, v -> k == signature } + } + + public Object callClosure(Closure closure, Object[] args = null) { + if( !args ) + return closure.call() + else if( args.size() > closure.maximumNumberOfParameters ) + return closure.call(args) + else + return closure.call(*args) + } + + public void printCallStack() { + if( !Boolean.parseBoolean(System.getProperty('mpl.interceptor.printstack', 'true')) ) + return + + call_stack.each { println(it) } + } + + public void clear() { + call_stack.clear() + } +} diff --git a/test/groovy/com/griddynamics/devops/mpl/testing/MPLLocalLibraryRetriever.groovy b/test/groovy/com/griddynamics/devops/mpl/testing/MPLLocalLibraryRetriever.groovy new file mode 100644 index 0000000..12eec74 --- /dev/null +++ b/test/groovy/com/griddynamics/devops/mpl/testing/MPLLocalLibraryRetriever.groovy @@ -0,0 +1,61 @@ +// +// Copyright (c) 2020 Grid Dynamics International, Inc. All Rights Reserved +// https://www.griddynamics.com +// +// Classification level: Public +// +// 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. +// +// $Id: $ +// @Project: MPL +// @Description: Shared Jenkins Modular Pipeline Library +// + +package com.griddynamics.devops.mpl.testing + +import hudson.FilePath +import hudson.model.Run +import hudson.model.TaskListener +import org.jenkinsci.plugins.workflow.libs.LibraryRetriever + +/** + * Simple global library local retreiver + * + * @author Sergei Parshev + */ +public class MPLLocalLibraryRetriever extends LibraryRetriever { + private final File local_directory; + + public MPLLocalLibraryRetriever() { + this(System.getProperty('user.dir')) + } + + public MPLLocalLibraryRetriever(String path) { + local_directory = new File(path) + } + + @Override + public void retrieve(String name, String version, boolean changelog, FilePath target, Run run, TaskListener listener) { + doRetrieve(target, listener, "${name}@${version}") + } + + @Override + public void retrieve(String name, String version, FilePath target, Run run, TaskListener listener) { + doRetrieve(target, listener, "${name}@${version}") + } + + private void doRetrieve(FilePath target, TaskListener listener, String libversion) { + final FilePath localFilePath = new FilePath(local_directory.toPath().resolve(libversion).toFile()) + localFilePath.copyRecursiveTo("src/**/*.groovy,vars/*.groovy,vars/*.txt,resources/", null, target) + } +} diff --git a/test/groovy/com/griddynamics/devops/mpl/testing/MPLMethodCall.groovy b/test/groovy/com/griddynamics/devops/mpl/testing/MPLMethodCall.groovy new file mode 100644 index 0000000..10463e9 --- /dev/null +++ b/test/groovy/com/griddynamics/devops/mpl/testing/MPLMethodCall.groovy @@ -0,0 +1,55 @@ +// +// Copyright (c) 2020 Grid Dynamics International, Inc. All Rights Reserved +// https://www.griddynamics.com +// +// Classification level: Public +// +// 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. +// +// $Id: $ +// @Project: MPL +// @Description: Shared Jenkins Modular Pipeline Library +// + +package com.griddynamics.devops.mpl.testing + +import com.lesfurets.jenkins.unit.MethodCall + +/** + * Extended MethodCall class to store the current invoker of the method + * + * @author Sergei Parshev + */ +class MPLMethodCall extends MethodCall { + String invoker + + @Override + String toString() { + return "${invoker} ${super.toString()}" + } + + @Override + boolean equals(o) { + if( ! super.equals(0) ) return false + if( invoker != that.invoker ) return false + + return true + } + + @Override + int hashCode() { + int result = super.hashCode() + result = 31 * result + invoker.hashCode() + return result + } +} diff --git a/test/groovy/com/griddynamics/devops/mpl/testing/MPLMethodNotAllowedException.groovy b/test/groovy/com/griddynamics/devops/mpl/testing/MPLMethodNotAllowedException.groovy new file mode 100644 index 0000000..8b1fee1 --- /dev/null +++ b/test/groovy/com/griddynamics/devops/mpl/testing/MPLMethodNotAllowedException.groovy @@ -0,0 +1,43 @@ +// +// Copyright (c) 2020 Grid Dynamics International, Inc. All Rights Reserved +// https://www.griddynamics.com +// +// Classification level: Public +// +// 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. +// +// $Id: $ +// @Project: MPL +// @Description: Shared Jenkins Modular Pipeline Library +// + +package com.griddynamics.devops.mpl.testing + +/** + * Exception to handle module execution errors + * + * @author Sergei Parshev + */ +class MPLMethodNotAllowedException extends MissingMethodException { + public MPLMethodNotAllowedException(String method, Class type, Object[] arguments) { + super(method, type, arguments, false) + } + + public MPLMethodNotAllowedException(String method, Class type, Object[] arguments, boolean isStatic) { + super(method, type, arguments, isStatic) + } + + public String getMessage() { + return "Method not registred in the list of allowed methods: ${super.getMessage()}" + } +} diff --git a/test/groovy/com/griddynamics/devops/mpl/testing/MPLSandboxInvoker.groovy b/test/groovy/com/griddynamics/devops/mpl/testing/MPLSandboxInvoker.groovy new file mode 100644 index 0000000..8cdef80 --- /dev/null +++ b/test/groovy/com/griddynamics/devops/mpl/testing/MPLSandboxInvoker.groovy @@ -0,0 +1,43 @@ +// +// Copyright (c) 2020 Grid Dynamics International, Inc. All Rights Reserved +// https://www.griddynamics.com +// +// Classification level: Public +// +// 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. +// +// $Id: $ +// @Project: MPL +// @Description: Shared Jenkins Modular Pipeline Library +// + +package com.griddynamics.devops.mpl.testing + +import com.cloudbees.groovy.cps.sandbox.SandboxInvoker +import com.griddynamics.devops.mpl.testing.MPLInterceptor + +/** + * Sandbox invoker override for intercepting the methodCall execution + * + * @author Sergei Parshev + */ +public class MPLSandboxInvoker extends SandboxInvoker { + @Override + public Object methodCall(Object receiver, String method, Object[] args) throws Throwable { + return MPLInterceptor.instance.processMethodCall(super.&doMethodCall, 'Sandbox', receiver, method, args) + } + + public Object doMethodCall(Object receiver, String method, Object[] args) throws Throwable { + return super.methodCall(receiver, method, args) + } +} diff --git a/test/groovy/com/griddynamics/devops/mpl/testing/MPLTestBaseJenkinsRule.groovy b/test/groovy/com/griddynamics/devops/mpl/testing/MPLTestBaseJenkinsRule.groovy new file mode 100644 index 0000000..24e32cb --- /dev/null +++ b/test/groovy/com/griddynamics/devops/mpl/testing/MPLTestBaseJenkinsRule.groovy @@ -0,0 +1,219 @@ +// +// Copyright (c) 2020 Grid Dynamics International, Inc. All Rights Reserved +// https://www.griddynamics.com +// +// Classification level: Public +// +// 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. +// +// $Id: $ +// @Project: MPL +// @Description: Shared Jenkins Modular Pipeline Library +// + +package com.griddynamics.devops.mpl.testing + +import org.junit.rules.TemporaryFolder +import org.jvnet.hudson.test.JenkinsRule +import org.junit.After +import org.junit.AfterClass +import org.junit.Before +import org.junit.Rule +import org.junit.ClassRule +import hudson.model.Queue +import hudson.Functions +import java.util.function.Consumer +import java.util.function.Function + +import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner +import org.jenkinsci.plugins.workflow.flow.FlowExecution + +import org.jenkinsci.plugins.workflow.cps.CpsFlowExecution +import org.jenkinsci.plugins.workflow.graph.FlowGraphWalker +import org.jenkinsci.plugins.workflow.graph.FlowNode +import org.jenkinsci.plugins.workflow.actions.ErrorAction + +import com.lesfurets.jenkins.unit.MethodCall +import com.lesfurets.jenkins.unit.MethodSignature +import com.griddynamics.devops.mpl.testing.MPLInterceptor + +import org.jenkinsci.plugins.workflow.libs.GlobalLibraries +import org.jenkinsci.plugins.workflow.libs.LibraryConfiguration +import com.griddynamics.devops.mpl.testing.MPLLocalLibraryRetriever + +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition +import org.jenkinsci.plugins.workflow.job.WorkflowJob +import org.jenkinsci.plugins.workflow.job.WorkflowRun + +import java.util.logging.Logger +import java.util.logging.Level +import java.util.logging.LogManager + +import com.griddynamics.devops.mpl.Helper + +/** + * Base class for unit tests using JenkinsRule + * + * @author Sergei Parshev + */ +abstract class MPLTestBaseJenkinsRule { + @Rule + public TemporaryFolder tmp = new TemporaryFolder() + + @ClassRule + public static JenkinsRule jenkins = new JenkinsRule() { + @Override + public void before() throws Throwable { + // TODO: Not working as expected - there is ton of logs from jenkins... + println("DEBUG: jenkins: init") + // Disable ExtensionFinder SSH and JNLP4 exceptions + def logger = Logger.getLogger('hudson') + logger.setLevel(Level.OFF) + LogManager.getLogManager().addLogger(logger) + super.before() + } + } + + /** Currently executing flow. */ + protected CpsFlowExecution exec + + /** Directory to put {@link #exec} in. */ + protected File rootDir + + List libraries = [] + + String[] scriptRoots = ["src/main/jenkins", "./."] + + String scriptExtension = "jenkins" + + Binding binding = new Binding() + + def workflow_job = null + def job_result = null + + MPLTestBaseJenkinsRule() { + helper = this + } + + public void setUp() throws Exception { + rootDir = tmp.newFolder() + workflow_job = jenkins.createProject(WorkflowJob) + } + + @After + public void clearTest() { + MPLInterceptor.instance.clear() + libraries.clear() + } + + @Before + public void setupOverrides() { + // Show the dump of the configuration during unit tests execution + Helper.metaClass.static.configEntrySet = { Map config -> config.entrySet() } + // TODO: fix this + org.jenkinsci.plugins.workflow.cps.CpsVmExecutorService.FAIL_ON_MISMATCH = false + } + + @AfterClass + public static void cleanOverrides() { + org.jenkinsci.plugins.workflow.cps.CpsVmExecutorService.FAIL_ON_MISMATCH = true + GroovySystem.metaClassRegistry.removeMetaClass(Helper.class) + } + + protected String dumpError() { + StringBuilder msg = new StringBuilder() + FlowGraphWalker walker = new FlowGraphWalker(exec) + for (FlowNode n : walker) { + ErrorAction e = n.getAction(ErrorAction.class) + if (e != null) { + msg.append(Functions.printThrowable(e.getError())) + } + } + return msg.toString() + } + + public static class FlowExecutionOwnerImpl extends FlowExecutionOwner { + @Override public FlowExecution get() { return helper.exec } + @Override public File getRootDir() { return helper.rootDir } + @Override public Queue.Executable getExecutable() { return null } + @Override public String getUrl() { return "TODO" } + @Override public boolean equals(Object o) { return this==o } + @Override public int hashCode() { return 0 } + } + + protected static MPLTestBaseJenkinsRule helper + + void registerAllowedMethod(String name, List args = [], Closure closure) { + MPLInterceptor.instance.registerAllowedMethod(name, args, closure) + } + + void registerAllowedMethod(MethodSignature methodSignature, Closure closure) { + MPLInterceptor.instance.registerAllowedMethod(methodSignature, closure) + } + + void registerAllowedMethod(MethodSignature methodSignature, Function callback) { + MPLInterceptor.instance.registerAllowedMethod(methodSignature, + callback != null ? { params -> return callback.apply(params) } : null) + } + + void registerAllowedMethod(MethodSignature methodSignature, Consumer callback) { + MPLInterceptor.instance.registerAllowedMethod(methodSignature, + callback != null ? { params -> return callback.accept(params) } : null) + } + + void printCallStack() { + MPLInterceptor.instance.printCallStack() + } + + List getCallStack() { + return MPLInterceptor.instance.call_stack + } + + void runMPLModule(Object... args) { + // Here we starting the job and replacing arguments during execution + workflow_job.setDefinition(new CpsFlowDefinition('''MPLModule('TO_REPLACE_321314')''', true)) + MPLInterceptor.instance.addArgumentsReplace('Sandbox WorkflowScript.MPLModule(TO_REPLACE_321314)', args) + job_result = jenkins.buildAndAssertSuccess(workflow_job) + } + + void assertJobStatusFailure() { + assertJobStatus('FAILURE') + } + + void assertJobStatusUnstable() { + assertJobStatus('UNSTABLE') + } + + void assertJobStatusSuccess() { + assertJobStatus('SUCCESS') + } + + private assertJobStatus(String status) { + //assertThat(binding.getVariable('currentBuild').result).isEqualTo(status) + } + + void registerSharedLibrary(Object libraryDescription) { + //println("DEBUG: ${libraryDescription}") + Objects.requireNonNull(libraryDescription) + Objects.requireNonNull(libraryDescription.name) + LibraryConfiguration lib = new LibraryConfiguration( + libraryDescription.name, + new MPLLocalLibraryRetriever(libraryDescription.retriever.sourceURL) + ) + lib.setDefaultVersion(libraryDescription.defaultVersion) + lib.setImplicit(libraryDescription.implicit) + lib.setAllowVersionOverride(libraryDescription.allowOverride) + //libraries.add(lib) + GlobalLibraries.get().setLibraries(Collections.singletonList(lib)) + } +} diff --git a/test/java/com/cloudbees/groovy/cps/impl/FunctionCallEnv.java b/test/java/com/cloudbees/groovy/cps/impl/FunctionCallEnv.java new file mode 100644 index 0000000..1cacfdb --- /dev/null +++ b/test/java/com/cloudbees/groovy/cps/impl/FunctionCallEnv.java @@ -0,0 +1,69 @@ +package com.cloudbees.groovy.cps.impl; + +import com.cloudbees.groovy.cps.Continuation; +import com.cloudbees.groovy.cps.Env; +import com.google.common.collect.Maps; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import com.cloudbees.groovy.cps.sandbox.Invoker; +import com.cloudbees.groovy.cps.sandbox.DefaultInvoker; +import com.cloudbees.groovy.cps.sandbox.SandboxInvoker; +import com.griddynamics.devops.mpl.testing.MPLDefaultInvoker; +import com.griddynamics.devops.mpl.testing.MPLSandboxInvoker; + +/** + * @author Kohsuke Kawaguchi + */ +// TODO: should be package local once all the impls move into this class +public class FunctionCallEnv extends CallEnv { + /** To conserve memory, lazily declared using {@link Collections#EMPTY_MAP} until we declare variables, then converted to a (small) {@link HashMap} */ + Map locals; + + /** + * @param caller + * The environment of the call site. Can be null but only if the caller is outside CPS execution. + */ + public FunctionCallEnv(Env caller, Continuation returnAddress, SourceLocation loc, Object _this) { + this(caller, returnAddress, loc, _this, 0); + } + + public FunctionCallEnv(Env caller, Continuation returnAddress, SourceLocation loc, Object _this, int localsCount) { + super(caller,returnAddress,loc, localsCount); + if( caller != null ) + setInvoker(caller.getInvoker()); + else + setInvoker(null); + locals = (localsCount <= 0) ? new HashMap(2) : Maps.newHashMapWithExpectedSize(localsCount+1); + locals.put("this", _this); + } + + public void declareVariable(Class type, String name) { + locals.put(name, null); + getTypes().put(name, type); + } + + public Object getLocalVariable(String name) { + return locals.get(name); + } + + public void setLocalVariable(String name, Object value) { + locals.put(name,value); + } + + public Object closureOwner() { + return getLocalVariable("this"); + } + + @Override + public void setInvoker(Invoker invoker) { + if( invoker instanceof SandboxInvoker ) + super.setInvoker(new MPLSandboxInvoker()); + else + super.setInvoker(new MPLDefaultInvoker()); + } + + private static final long serialVersionUID = 1L; +} diff --git a/test/java/com/cloudbees/groovy/cps/sandbox/Invoker.java b/test/java/com/cloudbees/groovy/cps/sandbox/Invoker.java new file mode 100644 index 0000000..70df0ae --- /dev/null +++ b/test/java/com/cloudbees/groovy/cps/sandbox/Invoker.java @@ -0,0 +1,65 @@ +package com.cloudbees.groovy.cps.sandbox; + +import com.cloudbees.groovy.cps.Continuable; +import com.cloudbees.groovy.cps.Env; +import com.cloudbees.groovy.cps.Envs; +import com.cloudbees.groovy.cps.impl.CallSiteBlock; +import groovy.lang.Script; + +import java.io.Serializable; + +import com.griddynamics.devops.mpl.testing.MPLDefaultInvoker; + +/** + * Abstracts away interactions with Groovy objects, for example to provide an opportunity to intercept + * calls. + * + *

+ * During the execution of CPS code, {@link Invoker} is available from {@link Env#getInvoker()}. + * + * @author Kohsuke Kawaguchi + * @see Env#getInvoker() + * @see Continuable#Continuable(Script, Env) + * @see Envs#empty(Invoker) + * @see "doc/sandbox.md" + */ +public interface Invoker extends Serializable { + /** + * Default instance to be used. + */ + Invoker INSTANCE = new MPLDefaultInvoker(); + + Object methodCall(Object receiver, String method, Object[] args) throws Throwable; + + Object constructorCall(Class lhs, Object[] args) throws Throwable; + + /** + * Invokespecial equivalent used for "super.foo(...)" kind of method call. + * + * @param senderType + * The type of the current method. Resolution of 'super' depends on this. + * 'receiver' is an instance of this type. + * @param receiver + * Instance that gets the method call + */ + Object superCall(Class senderType, Object receiver, String method, Object[] args) throws Throwable; + + Object getProperty(Object lhs, String name) throws Throwable; + + void setProperty(Object lhs, String name, Object value) throws Throwable; + + Object getAttribute(Object lhs, String name) throws Throwable; + + void setAttribute(Object lhs, String name, Object value) throws Throwable; + + Object getArray(Object lhs, Object index) throws Throwable; + + void setArray(Object lhs, Object index, Object value) throws Throwable; + + Object methodPointer(Object lhs, String name); + + /** + * Returns a child {@link Invoker} used to make a call on behalf of the given {@link CallSiteBlock}. + */ + Invoker contextualize(CallSiteBlock tags); +}