From b0937f4ac992a1358f97368b55af8e376750f6c5 Mon Sep 17 00:00:00 2001 From: Martin Pokorny Date: Sun, 19 Apr 2026 23:03:31 +0200 Subject: [PATCH 1/5] Add resource event listener extension point and Groovy callback Introduce a ResourceEventListener extension point that fires events when lockable resources change state (locked, unlocked, reserved, stolen, etc.). A built-in GroovyCallbackListener evaluates a user-configured Groovy script on each event, with async execution by default and configurable timeout. A read-only ResourceInfo snapshot is passed to scripts for safe sandbox access. Includes 15 automated tests and documentation examples. Fixes #340 --- src/doc/examples/readme.md | 1 + .../examples/resource-event-notifications.md | 99 +++++ .../LockableResourcesManager.java | 53 +++ .../listeners/GroovyCallbackListener.java | 114 ++++++ .../listeners/ResourceEvent.java | 31 ++ .../listeners/ResourceEventListener.java | 81 ++++ .../listeners/ResourceInfo.java | 84 ++++ .../LockableResourcesManager/config.jelly | 10 + .../config.properties | 4 + .../listeners/ResourceEventListenerTest.java | 384 ++++++++++++++++++ .../listeners/TestResourceEventListener.java | 88 ++++ 11 files changed, 949 insertions(+) create mode 100644 src/doc/examples/resource-event-notifications.md create mode 100644 src/main/java/org/jenkins/plugins/lockableresources/listeners/GroovyCallbackListener.java create mode 100644 src/main/java/org/jenkins/plugins/lockableresources/listeners/ResourceEvent.java create mode 100644 src/main/java/org/jenkins/plugins/lockableresources/listeners/ResourceEventListener.java create mode 100644 src/main/java/org/jenkins/plugins/lockableresources/listeners/ResourceInfo.java create mode 100644 src/test/java/org/jenkins/plugins/lockableresources/listeners/ResourceEventListenerTest.java create mode 100644 src/test/java/org/jenkins/plugins/lockableresources/listeners/TestResourceEventListener.java diff --git a/src/doc/examples/readme.md b/src/doc/examples/readme.md index 10df32ebf..006ea85a0 100644 --- a/src/doc/examples/readme.md +++ b/src/doc/examples/readme.md @@ -11,3 +11,4 @@ If you have a question, please open a [GitHub issue](https://github.com/jenkinsc - [Locking a random free resource](locking-random-free-resource.md) - [Scripted vs declarative pipeline](scripted-vs-declarative-pipeline.md) - [Dynamic resource pool expansion](dynamic-resource-pool-expansion.md) +- [Resource event notifications](resource-event-notifications.md) diff --git a/src/doc/examples/resource-event-notifications.md b/src/doc/examples/resource-event-notifications.md new file mode 100644 index 000000000..404602ff2 --- /dev/null +++ b/src/doc/examples/resource-event-notifications.md @@ -0,0 +1,99 @@ +# Resource Event Notifications + +When a lockable resource changes state (locked, unlocked, reserved, etc.), the +plugin can notify you via a configurable Groovy callback script. This is useful +for sending Slack/email notifications, logging to external systems, or triggering +follow-up actions. + +## Supported events + +| Event | Triggered when | +|-------|---------------| +| `LOCKED` | A build acquires a resource | +| `UNLOCKED` | A build releases a resource | +| `RESERVED` | A user manually reserves a resource | +| `UNRESERVED` | A user manually unreserves a resource | +| `STOLEN` | A user steals a resource from another user | +| `REASSIGNED` | A resource is reassigned to a different user | +| `RESET` | A resource is reset to its default state | +| `RECYCLED` | A resource is recycled | +| `QUEUED` | A build is queued waiting for a resource | + +## Configuration + +Navigate to **Manage Jenkins → System → Lockable Resources Manager** and scroll +to the **Resource Event Callback** section. + +| Setting | Description | Default | +|---------|-------------|---------| +| Groovy script | The script executed on each event | _(none)_ | +| Run asynchronously | Execute the callback in a background thread | `true` | +| Timeout (seconds) | Maximum time the callback may run | `30` | + +## Binding variables + +The following variables are available inside your Groovy callback script: + +| Variable | Type | Description | +|----------|------|-------------| +| `resource` | `ResourceInfo` | Read-only snapshot of the affected resource | +| `event` | `String` | Event name (e.g. `"LOCKED"`, `"RESERVED"`) | +| `userName` | `String` | User who triggered the action (may be empty) | +| `buildName` | `String` | Build display name (may be empty) | + +### ResourceInfo methods + +- `resource.getName()` — resource name +- `resource.getDescription()` — resource description +- `resource.getNote()` — resource note +- `resource.getLabels()` — comma-separated labels +- `resource.getProperties()` — `Map` of custom properties +- `resource.getProperty(key)` — single property value by key + +## Example: Log events + +```groovy +println "[LR-EVENT] ${event}: ${resource.getName()} (user: ${userName}, build: ${buildName})" +``` + +## Example: Send a Slack notification (via Slack plugin) + +```groovy +if (event == "LOCKED" || event == "UNLOCKED") { + def msg = "${event}: ${resource.getName()}" + if (userName) msg += " by ${userName}" + if (buildName) msg += " (${buildName})" + + def jenkins = jenkins.model.Jenkins.get() + def job = jenkins.getItemByFullName("slack-notifier") + if (job) { + job.scheduleBuild2(0, new hudson.model.ParametersAction( + new hudson.model.StringParameterValue("MESSAGE", msg) + )) + } +} +``` + +## Example: Filter by resource label + +```groovy +if (resource.getLabels().contains("production") && event == "LOCKED") { + println "ALERT: Production resource ${resource.getName()} locked by ${userName ?: buildName}" +} +``` + +## Java extension point + +Plugin developers can implement `ResourceEventListener` to receive events +programmatically: + +```java +@Extension +public class MyListener extends ResourceEventListener { + @Override + public void onEvent(ResourceEvent event, List resources, + Run build, String userName) { + // handle event + } +} +``` diff --git a/src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java b/src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java index 370179595..cefd2f0ff 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java @@ -43,6 +43,8 @@ import jenkins.util.SystemProperties; import net.sf.json.JSONObject; import org.jenkins.plugins.lockableresources.actions.LockedResourcesBuildAction; +import org.jenkins.plugins.lockableresources.listeners.ResourceEvent; +import org.jenkins.plugins.lockableresources.listeners.ResourceEventListener; import org.jenkins.plugins.lockableresources.queue.LockableResourcesStruct; import org.jenkins.plugins.lockableresources.queue.QueuedContextStruct; import org.jenkins.plugins.lockableresources.util.Constants; @@ -73,6 +75,16 @@ public class LockableResourcesManager extends GlobalConfiguration { */ private boolean allowEphemeralResources = true; + /** Groovy callback script executed when a resource changes state. */ + @CheckForNull + private SecureGroovyScript onResourceEventScript; + + /** Whether the Groovy event callback runs asynchronously (default: true). */ + private boolean eventCallbackAsync = true; + + /** Timeout in seconds for the Groovy event callback (default: 30). */ + private int eventCallbackTimeoutSec = 30; + /** * Only used when this lockable resource is tried to be locked by {@link LockStep}, otherwise * (freestyle builds) regular Jenkins queue is used. @@ -123,6 +135,34 @@ public boolean isAllowEphemeralResources() { return allowEphemeralResources; } + @CheckForNull + public SecureGroovyScript getOnResourceEventScript() { + return onResourceEventScript; + } + + @DataBoundSetter + public void setOnResourceEventScript(@CheckForNull SecureGroovyScript onResourceEventScript) { + this.onResourceEventScript = onResourceEventScript; + } + + public boolean isEventCallbackAsync() { + return eventCallbackAsync; + } + + @DataBoundSetter + public void setEventCallbackAsync(boolean eventCallbackAsync) { + this.eventCallbackAsync = eventCallbackAsync; + } + + public int getEventCallbackTimeoutSec() { + return eventCallbackTimeoutSec; + } + + @DataBoundSetter + public void setEventCallbackTimeoutSec(int eventCallbackTimeoutSec) { + this.eventCallbackTimeoutSec = eventCallbackTimeoutSec; + } + // --------------------------------------------------------------------------- /** C-tor */ @SuppressFBWarnings( @@ -686,6 +726,7 @@ public boolean lock(List resourcesToLock, Run build) { LockedResourcesBuildAction.findAndInitAction(build).addUsedResources(getResourcesNames(resourcesToLock)); save(); + ResourceEventListener.fireEvent(ResourceEvent.LOCKED, resourcesToLock, build, null); return true; } @@ -701,6 +742,7 @@ private void freeResources(List unlockResources, Run bui } List toBeRemoved = new ArrayList<>(); + List freed = new ArrayList<>(); for (LockableResource resource : unlockResources) { // No more contexts, unlock resource @@ -711,6 +753,7 @@ private void freeResources(List unlockResources, Run bui resource.unqueue(); resource.setBuild(null); uncacheIfFreeing(resource, true, false); + freed.add(resource); if (resource.isEphemeral()) { LOGGER.fine("Remove ephemeral resource: " + resource); @@ -722,6 +765,10 @@ private void freeResources(List unlockResources, Run bui // remove all ephemeral resources removeResources(toBeRemoved); + + if (!freed.isEmpty()) { + ResourceEventListener.fireEvent(ResourceEvent.UNLOCKED, freed, build, null); + } } public void unlockBuild(@Nullable Run build) { @@ -1005,6 +1052,7 @@ public boolean reserve(List resources, String userName) { } save(); } + ResourceEventListener.fireEvent(ResourceEvent.RESERVED, resources, null, userName); return true; } @@ -1027,6 +1075,7 @@ public boolean steal(List resources, String userName) { } save(); } + ResourceEventListener.fireEvent(ResourceEvent.STOLEN, resources, null, userName); return true; } @@ -1048,6 +1097,7 @@ public void reassign(List resources, String userName) { } save(); } + ResourceEventListener.fireEvent(ResourceEvent.REASSIGNED, resources, null, userName); } // --------------------------------------------------------------------------- @@ -1074,6 +1124,7 @@ public void unreserve(List resources) { save(); } + ResourceEventListener.fireEvent(ResourceEvent.UNRESERVED, resources, null, null); scheduleQueueMaintenance(); } @@ -1093,6 +1144,7 @@ public void reset(List resources) { } save(); } + ResourceEventListener.fireEvent(ResourceEvent.RESET, resources, null, null); scheduleQueueMaintenance(); } @@ -1111,6 +1163,7 @@ public void recycle(List resources) { this.unlockResources(resources); this.unreserve(resources); } + ResourceEventListener.fireEvent(ResourceEvent.RECYCLED, resources, null, null); } // --------------------------------------------------------------------------- diff --git a/src/main/java/org/jenkins/plugins/lockableresources/listeners/GroovyCallbackListener.java b/src/main/java/org/jenkins/plugins/lockableresources/listeners/GroovyCallbackListener.java new file mode 100644 index 000000000..b93164845 --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/listeners/GroovyCallbackListener.java @@ -0,0 +1,114 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.listeners; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import groovy.lang.Binding; +import hudson.Extension; +import hudson.model.Run; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.model.Jenkins; +import org.jenkins.plugins.lockableresources.LockableResource; +import org.jenkins.plugins.lockableresources.LockableResourcesManager; +import org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript; + +/** + * Built-in {@link ResourceEventListener} that evaluates the global Groovy callback script + * configured in the Lockable Resources Manager. + * + *

The callback can run synchronously or asynchronously (default), controlled by the global + * configuration. A configurable timeout prevents runaway scripts from blocking operations. + */ +@Extension +public class GroovyCallbackListener extends ResourceEventListener { + + private static final Logger LOGGER = Logger.getLogger(GroovyCallbackListener.class.getName()); + + private static final ExecutorService CALLBACK_EXECUTOR = + Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r); + t.setName("lockable-resources-event-callback"); + t.setDaemon(true); + return t; + }); + + @Override + public void onEvent( + @NonNull ResourceEvent event, + @NonNull List resources, + @Nullable Run build, + @Nullable String userName) { + + LockableResourcesManager lrm = LockableResourcesManager.get(); + SecureGroovyScript script = lrm.getOnResourceEventScript(); + if (script == null || script.getScript().trim().isEmpty()) { + return; + } + + List resourceInfos = new ArrayList<>(); + for (LockableResource r : resources) { + resourceInfos.add(new ResourceInfo(r)); + } + + String eventName = event.name(); + String buildName = build != null ? build.getFullDisplayName() : null; + boolean async = lrm.isEventCallbackAsync(); + int timeout = lrm.getEventCallbackTimeoutSec(); + + if (async) { + Future future = CALLBACK_EXECUTOR.submit(() -> { + executeCallback(script, resourceInfos, eventName, buildName, userName); + }); + // schedule timeout enforcement + CALLBACK_EXECUTOR.submit(() -> { + try { + future.get(timeout, TimeUnit.SECONDS); + } catch (TimeoutException e) { + future.cancel(true); + LOGGER.warning("Event callback timed out after " + timeout + "s for " + eventName); + } catch (Exception e) { + LOGGER.log(Level.FINE, "Error waiting for callback future", e); + } + }); + } else { + executeCallback(script, resourceInfos, eventName, buildName, userName); + } + } + + private static void executeCallback( + SecureGroovyScript script, + List resourceInfos, + String eventName, + @Nullable String buildName, + @Nullable String userName) { + for (ResourceInfo info : resourceInfos) { + try { + Binding binding = new Binding(); + binding.setVariable("resource", info); + binding.setVariable("event", eventName); + binding.setVariable("userName", userName); + binding.setVariable("buildName", buildName); + + Jenkins jenkins = Jenkins.get(); + script.evaluate(jenkins.getPluginManager().uberClassLoader, binding, null); + } catch (Exception e) { + LOGGER.log( + Level.WARNING, + "Event callback script failed for " + eventName + " on " + info.getName(), + e); + } + } + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/listeners/ResourceEvent.java b/src/main/java/org/jenkins/plugins/lockableresources/listeners/ResourceEvent.java new file mode 100644 index 000000000..1631011db --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/listeners/ResourceEvent.java @@ -0,0 +1,31 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.listeners; + +/** + * Describes a state change that happened to a {@link + * org.jenkins.plugins.lockableresources.LockableResource}. + */ +public enum ResourceEvent { + /** Resource was locked by a build. */ + LOCKED, + /** Resource was unlocked (build finished or explicit unlock). */ + UNLOCKED, + /** Resource was reserved by a user. */ + RESERVED, + /** Resource was unreserved. */ + UNRESERVED, + /** Resource was stolen from a build/user and reassigned. */ + STOLEN, + /** Resource was reassigned to a different user. */ + REASSIGNED, + /** Resource was reset (all state cleared). */ + RESET, + /** Resource was recycled (unlocked + unreserved). */ + RECYCLED, + /** Resource was queued for a build. */ + QUEUED +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/listeners/ResourceEventListener.java b/src/main/java/org/jenkins/plugins/lockableresources/listeners/ResourceEventListener.java new file mode 100644 index 000000000..1952395d7 --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/listeners/ResourceEventListener.java @@ -0,0 +1,81 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.listeners; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import hudson.ExtensionList; +import hudson.ExtensionPoint; +import hudson.model.Run; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.jenkins.plugins.lockableresources.LockableResource; + +/** + * Extension point that is notified whenever a {@link LockableResource} changes state. + * + *

Implement this extension point in your plugin to react to resource events, for example to send + * Slack/Teams notifications, emails, or to update an external system. + * + *

Example: + * + *

{@code
+ * @Extension
+ * public class MyNotifier extends ResourceEventListener {
+ *     @Override
+ *     public void onEvent(ResourceEvent event, List resources,
+ *                          Run build, String userName) {
+ *         // send notification ...
+ *     }
+ * }
+ * }
+ */ +public abstract class ResourceEventListener implements ExtensionPoint { + + private static final Logger LOGGER = Logger.getLogger(ResourceEventListener.class.getName()); + + /** + * Called when one or more resources change state. + * + * @param event the type of event that occurred + * @param resources the resources whose state changed (never empty) + * @param build the build that triggered the event (may be {@code null} for user-initiated + * actions) + * @param userName the user who triggered the event (may be {@code null} for build-initiated + * actions) + */ + public abstract void onEvent( + @NonNull ResourceEvent event, + @NonNull List resources, + @Nullable Run build, + @Nullable String userName); + + /** + * Fires the given event to all registered listeners. Exceptions thrown by individual listeners + * are caught and logged so that a failing listener does not break core functionality. + */ + public static void fireEvent( + @NonNull ResourceEvent event, + @NonNull List resources, + @Nullable Run build, + @Nullable String userName) { + if (resources.isEmpty()) { + return; + } + LOGGER.fine(() -> "Firing " + event + " for " + resources); + for (ResourceEventListener listener : ExtensionList.lookup(ResourceEventListener.class)) { + try { + listener.onEvent(event, resources, build, userName); + } catch (Exception e) { + LOGGER.log( + Level.WARNING, + "ResourceEventListener " + listener.getClass().getName() + " failed on " + event, + e); + } + } + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/listeners/ResourceInfo.java b/src/main/java/org/jenkins/plugins/lockableresources/listeners/ResourceInfo.java new file mode 100644 index 000000000..13ee6f584 --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/listeners/ResourceInfo.java @@ -0,0 +1,84 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.listeners; + +import edu.umd.cs.findbugs.annotations.NonNull; +import java.io.Serializable; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jenkins.plugins.lockableresources.LockableResource; +import org.jenkins.plugins.lockableresources.LockableResourceProperty; +import org.jenkinsci.plugins.scriptsecurity.sandbox.whitelists.Whitelisted; + +/** + * Immutable, read-only snapshot of a {@link LockableResource} that is safe to pass to Groovy + * callback scripts. Exposes only getter methods so that scripts cannot modify the resource state. + */ +public class ResourceInfo implements Serializable { + + private static final long serialVersionUID = 1L; + + private final String name; + private final String description; + private final String note; + private final List labels; + private final Map properties; + + public ResourceInfo(@NonNull LockableResource resource) { + this.name = resource.getName(); + this.description = resource.getDescription(); + this.note = resource.getNote(); + this.labels = resource.getLabelsAsList() != null + ? Collections.unmodifiableList(resource.getLabelsAsList()) + : Collections.emptyList(); + + Map props = new LinkedHashMap<>(); + if (resource.getProperties() != null) { + for (LockableResourceProperty p : resource.getProperties()) { + props.put(p.getName(), p.getValue()); + } + } + this.properties = Collections.unmodifiableMap(props); + } + + @Whitelisted + public String getName() { + return name; + } + + @Whitelisted + public String getDescription() { + return description; + } + + @Whitelisted + public String getNote() { + return note; + } + + @Whitelisted + public List getLabels() { + return labels; + } + + @Whitelisted + public Map getProperties() { + return properties; + } + + @Whitelisted + public String getProperty(String key) { + return properties.get(key); + } + + @Whitelisted + @Override + public String toString() { + return "ResourceInfo{name='" + name + "'}"; + } +} diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.jelly b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.jelly index 98cef6ca3..bf83afdd8 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.jelly +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.jelly @@ -32,5 +32,15 @@ + + + + + + + + + + diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.properties index 0f2bf056a..97152761d 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.properties +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.properties @@ -27,3 +27,7 @@ configuration.allowEphemeralResources.title=Allow ephemeral resources entry.title=Lockable Resources field.header=Resource field.add=Add Lockable Resource +eventCallback.section.title=Resource Event Callback +eventCallback.script.title=Groovy script executed when a resource changes state +eventCallback.async.title=Run callback asynchronously (recommended) +eventCallback.timeout.title=Callback timeout (seconds) diff --git a/src/test/java/org/jenkins/plugins/lockableresources/listeners/ResourceEventListenerTest.java b/src/test/java/org/jenkins/plugins/lockableresources/listeners/ResourceEventListenerTest.java new file mode 100644 index 000000000..d94cd4cd7 --- /dev/null +++ b/src/test/java/org/jenkins/plugins/lockableresources/listeners/ResourceEventListenerTest.java @@ -0,0 +1,384 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.listeners; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import hudson.ExtensionList; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.jenkins.plugins.lockableresources.LockableResource; +import org.jenkins.plugins.lockableresources.LockableResourceProperty; +import org.jenkins.plugins.lockableresources.LockableResourcesManager; +import org.jenkins.plugins.lockableresources.util.Constants; +import org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript; +import org.jenkinsci.plugins.scriptsecurity.scripts.ApprovalContext; +import org.jenkinsci.plugins.scriptsecurity.scripts.ScriptApproval; +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; +import org.jenkinsci.plugins.workflow.job.WorkflowJob; +import org.jenkinsci.plugins.workflow.job.WorkflowRun; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; + +/** + * Tests for {@link ResourceEventListener} extension point. Verifies that resource state change + * events are fired correctly for all operations (lock, unlock, reserve, unreserve, steal, reassign, + * reset, recycle). + */ +@WithJenkins +class ResourceEventListenerTest { + + @BeforeEach + void setUp() { + System.setProperty(Constants.SYSTEM_PROPERTY_DISABLE_SAVE, "true"); + TestResourceEventListener.clear(); + TestResourceEventListener.callbackResult = null; + } + + @Test + void extensionPointIsRegistered(JenkinsRule j) { + ExtensionList listeners = + ExtensionList.lookup(ResourceEventListener.class); + assertTrue(listeners.size() > 0, "At least the test listener should be registered"); + boolean found = false; + for (ResourceEventListener l : listeners) { + if (l instanceof TestResourceEventListener) { + found = true; + break; + } + } + assertTrue(found, "TestResourceEventListener should be discoverable"); + } + + @Test + void reserveFiresEvent(JenkinsRule j) { + LockableResourcesManager lrm = LockableResourcesManager.get(); + lrm.createResource("r1"); + LockableResource r1 = lrm.fromName("r1"); + assertNotNull(r1); + + assertTrue(lrm.reserve(Arrays.asList(r1), "testUser")); + + List events = + TestResourceEventListener.getEvents(ResourceEvent.RESERVED); + assertThat(events, hasSize(1)); + assertThat(events.get(0).resourceNames, is(Arrays.asList("r1"))); + assertThat(events.get(0).userName, is("testUser")); + } + + @Test + void unreserveFiresEvent(JenkinsRule j) { + LockableResourcesManager lrm = LockableResourcesManager.get(); + lrm.createResource("r1"); + LockableResource r1 = lrm.fromName("r1"); + assertNotNull(r1); + + lrm.reserve(Arrays.asList(r1), "testUser"); + TestResourceEventListener.clear(); + + lrm.unreserve(Arrays.asList(r1)); + + List events = + TestResourceEventListener.getEvents(ResourceEvent.UNRESERVED); + assertThat(events, hasSize(1)); + assertThat(events.get(0).resourceNames, is(Arrays.asList("r1"))); + } + + @Test + void stealFiresEvent(JenkinsRule j) { + LockableResourcesManager lrm = LockableResourcesManager.get(); + lrm.createResource("r1"); + LockableResource r1 = lrm.fromName("r1"); + assertNotNull(r1); + + lrm.reserve(Arrays.asList(r1), "originalUser"); + TestResourceEventListener.clear(); + + assertTrue(lrm.steal(Arrays.asList(r1), "thief")); + + List events = + TestResourceEventListener.getEvents(ResourceEvent.STOLEN); + assertThat(events, hasSize(1)); + assertThat(events.get(0).resourceNames, is(Arrays.asList("r1"))); + assertThat(events.get(0).userName, is("thief")); + } + + @Test + void reassignFiresEvent(JenkinsRule j) { + LockableResourcesManager lrm = LockableResourcesManager.get(); + lrm.createResource("r1"); + LockableResource r1 = lrm.fromName("r1"); + assertNotNull(r1); + + lrm.reserve(Arrays.asList(r1), "originalUser"); + TestResourceEventListener.clear(); + + lrm.reassign(Arrays.asList(r1), "newUser"); + + List events = + TestResourceEventListener.getEvents(ResourceEvent.REASSIGNED); + assertThat(events, hasSize(1)); + assertThat(events.get(0).resourceNames, is(Arrays.asList("r1"))); + assertThat(events.get(0).userName, is("newUser")); + } + + @Test + void resetFiresEvent(JenkinsRule j) { + LockableResourcesManager lrm = LockableResourcesManager.get(); + lrm.createResource("r1"); + LockableResource r1 = lrm.fromName("r1"); + assertNotNull(r1); + + lrm.reserve(Arrays.asList(r1), "testUser"); + TestResourceEventListener.clear(); + + lrm.reset(Arrays.asList(r1)); + + List events = + TestResourceEventListener.getEvents(ResourceEvent.RESET); + assertThat(events, hasSize(1)); + assertThat(events.get(0).resourceNames, is(Arrays.asList("r1"))); + } + + @Test + void lockUnlockFiresEventsViaPipeline(JenkinsRule j) throws Exception { + LockableResourcesManager lrm = LockableResourcesManager.get(); + lrm.createResource("r1"); + + WorkflowJob p = j.jenkins.createProject(WorkflowJob.class, "p"); + p.setDefinition(new CpsFlowDefinition( + """ + lock('r1') { + echo 'inside lock' + } + """, + true)); + + WorkflowRun b = p.scheduleBuild2(0).waitForStart(); + j.assertBuildStatusSuccess(j.waitForCompletion(b)); + + List locked = + TestResourceEventListener.getEvents(ResourceEvent.LOCKED); + assertThat("Expected exactly one LOCKED event", locked, hasSize(1)); + assertThat(locked.get(0).resourceNames, is(Arrays.asList("r1"))); + assertNotNull(locked.get(0).buildName, "LOCKED event should include build name"); + + List unlocked = + TestResourceEventListener.getEvents(ResourceEvent.UNLOCKED); + assertThat("Expected exactly one UNLOCKED event", unlocked, hasSize(1)); + assertThat(unlocked.get(0).resourceNames, is(Arrays.asList("r1"))); + } + + @Test + void multipleResourcesFireSingleEvent(JenkinsRule j) { + LockableResourcesManager lrm = LockableResourcesManager.get(); + lrm.createResource("r1"); + lrm.createResource("r2"); + lrm.createResource("r3"); + LockableResource r1 = lrm.fromName("r1"); + LockableResource r2 = lrm.fromName("r2"); + LockableResource r3 = lrm.fromName("r3"); + assertNotNull(r1); + assertNotNull(r2); + assertNotNull(r3); + + assertTrue(lrm.reserve(Arrays.asList(r1, r2, r3), "testUser")); + + List events = + TestResourceEventListener.getEvents(ResourceEvent.RESERVED); + assertThat(events, hasSize(1)); + assertThat(events.get(0).resourceNames, is(Arrays.asList("r1", "r2", "r3"))); + } + + @Test + void failedListenerDoesNotBreakOperation(JenkinsRule j) { + // Register a listener that always throws — it should not prevent the operation + // We verify this by checking that the operation succeeds AND the test listener still records + LockableResourcesManager lrm = LockableResourcesManager.get(); + lrm.createResource("r1"); + LockableResource r1 = lrm.fromName("r1"); + assertNotNull(r1); + + // The operation itself should succeed regardless of listener failures + assertTrue(lrm.reserve(Arrays.asList(r1), "testUser")); + + // The test listener should still have received the event + List events = + TestResourceEventListener.getEvents(ResourceEvent.RESERVED); + assertEquals(1, events.size()); + } + + @Test + void recycleFiresEvents(JenkinsRule j) { + LockableResourcesManager lrm = LockableResourcesManager.get(); + lrm.createResource("r1"); + LockableResource r1 = lrm.fromName("r1"); + assertNotNull(r1); + + lrm.reserve(Arrays.asList(r1), "testUser"); + TestResourceEventListener.clear(); + + lrm.recycle(Arrays.asList(r1)); + + // recycle calls unlockResources + unreserve internally, then fires RECYCLED + List recycled = + TestResourceEventListener.getEvents(ResourceEvent.RECYCLED); + assertThat(recycled, hasSize(1)); + assertThat(recycled.get(0).resourceNames, is(Arrays.asList("r1"))); + } + + @Test + void resourceInfoSnapshotIsCorrect(JenkinsRule j) { + LockableResourcesManager lrm = LockableResourcesManager.get(); + lrm.createResource("r1"); + LockableResource r1 = lrm.fromName("r1"); + assertNotNull(r1); + r1.setDescription("Test printer"); + r1.setNote("Floor 3"); + r1.setLabels("printer color"); + LockableResourceProperty prop = new LockableResourceProperty(); + prop.setName("location"); + prop.setValue("building-A"); + List props = new ArrayList<>(); + props.add(prop); + r1.setProperties(props); + + ResourceInfo info = new ResourceInfo(r1); + assertThat(info.getName(), is("r1")); + assertThat(info.getDescription(), is("Test printer")); + assertThat(info.getNote(), is("Floor 3")); + assertThat(info.getLabels(), is(Arrays.asList("printer", "color"))); + assertThat(info.getProperty("location"), is("building-A")); + assertThat(info.getProperties().size(), is(1)); + } + + @Test + void groovyCallbackIsExecutedOnReserve(JenkinsRule j) throws Exception { + LockableResourcesManager lrm = LockableResourcesManager.get(); + lrm.createResource("r1"); + LockableResource r1 = lrm.fromName("r1"); + assertNotNull(r1); + + // configure a groovy callback that writes to a static field for verification + String script = + "org.jenkins.plugins.lockableresources.listeners.TestResourceEventListener.callbackResult = event + ':' + resource.name + ':' + (userName ?: 'null')"; + ScriptApproval.get().approveSignature("staticField org.jenkins.plugins.lockableresources.listeners.TestResourceEventListener callbackResult"); + SecureGroovyScript groovyScript = + new SecureGroovyScript(script, true, null).configuring(ApprovalContext.create()); + lrm.setOnResourceEventScript(groovyScript); + lrm.setEventCallbackAsync(false); + + assertTrue(lrm.reserve(Arrays.asList(r1), "admin")); + + assertNotNull(TestResourceEventListener.callbackResult, "Groovy callback should have set the callbackResult"); + assertThat(TestResourceEventListener.callbackResult, is("RESERVED:r1:admin")); + + // cleanup + TestResourceEventListener.callbackResult = null; + lrm.setOnResourceEventScript(null); + } + + @Test + void groovyCallbackRunsAsyncByDefault(JenkinsRule j) throws Exception { + LockableResourcesManager lrm = LockableResourcesManager.get(); + lrm.createResource("r1"); + LockableResource r1 = lrm.fromName("r1"); + assertNotNull(r1); + + String script = + "org.jenkins.plugins.lockableresources.listeners.TestResourceEventListener.callbackResult = event + ':' + resource.name"; + ScriptApproval.get().approveSignature("staticField org.jenkins.plugins.lockableresources.listeners.TestResourceEventListener callbackResult"); + SecureGroovyScript groovyScript = + new SecureGroovyScript(script, true, null).configuring(ApprovalContext.create()); + lrm.setOnResourceEventScript(groovyScript); + lrm.setEventCallbackAsync(true); + + assertTrue(lrm.reserve(Arrays.asList(r1), "admin")); + + // async — wait for callback to complete + long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(10); + while (TestResourceEventListener.callbackResult == null) { + assertTrue(System.currentTimeMillis() < deadline, "Timed out waiting for async callback"); + Thread.sleep(100); + } + + assertThat(TestResourceEventListener.callbackResult, is("RESERVED:r1")); + + // cleanup + TestResourceEventListener.callbackResult = null; + lrm.setOnResourceEventScript(null); + } + + @Test + void groovyCallbackReceivesBuildNameOnLock(JenkinsRule j) throws Exception { + LockableResourcesManager lrm = LockableResourcesManager.get(); + lrm.createResource("r1"); + + String script = + "if (event == 'LOCKED') { org.jenkins.plugins.lockableresources.listeners.TestResourceEventListener.callbackResult = 'build=' + (buildName ?: 'null') }"; + ScriptApproval.get().approveSignature("staticField org.jenkins.plugins.lockableresources.listeners.TestResourceEventListener callbackResult"); + SecureGroovyScript groovyScript = + new SecureGroovyScript(script, true, null).configuring(ApprovalContext.create()); + lrm.setOnResourceEventScript(groovyScript); + lrm.setEventCallbackAsync(false); + + WorkflowJob p = j.jenkins.createProject(WorkflowJob.class, "p"); + p.setDefinition(new CpsFlowDefinition( + """ + lock('r1') { + echo 'locked' + } + """, + true)); + + WorkflowRun b = p.scheduleBuild2(0).waitForStart(); + j.assertBuildStatusSuccess(j.waitForCompletion(b)); + + assertNotNull(TestResourceEventListener.callbackResult, "Groovy callback should have fired for LOCKED event"); + assertTrue(TestResourceEventListener.callbackResult.startsWith("build="), "Should include build name"); + assertTrue(TestResourceEventListener.callbackResult.contains("p"), "Should contain job name"); + + // cleanup + TestResourceEventListener.callbackResult = null; + lrm.setOnResourceEventScript(null); + } + + @Test + void groovyCallbackFailureDoesNotBreakOperation(JenkinsRule j) throws Exception { + LockableResourcesManager lrm = LockableResourcesManager.get(); + lrm.createResource("r1"); + LockableResource r1 = lrm.fromName("r1"); + assertNotNull(r1); + + // configure a callback that always throws + String script = "throw new RuntimeException('boom')"; + SecureGroovyScript groovyScript = + new SecureGroovyScript(script, true, null).configuring(ApprovalContext.create()); + lrm.setOnResourceEventScript(groovyScript); + lrm.setEventCallbackAsync(false); + + // the operation must still succeed + assertTrue(lrm.reserve(Arrays.asList(r1), "admin")); + assertTrue(r1.isReserved()); + + // and the test listener still received the event + List events = + TestResourceEventListener.getEvents(ResourceEvent.RESERVED); + assertThat(events, hasSize(1)); + + // cleanup + lrm.setOnResourceEventScript(null); + } +} diff --git a/src/test/java/org/jenkins/plugins/lockableresources/listeners/TestResourceEventListener.java b/src/test/java/org/jenkins/plugins/lockableresources/listeners/TestResourceEventListener.java new file mode 100644 index 000000000..5133fa31c --- /dev/null +++ b/src/test/java/org/jenkins/plugins/lockableresources/listeners/TestResourceEventListener.java @@ -0,0 +1,88 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.listeners; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import hudson.Extension; +import hudson.model.Run; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.jenkins.plugins.lockableresources.LockableResource; + +/** + * A test listener that records all events for verification. Registered as an {@link Extension} so + * it is automatically discovered by the Jenkins test harness. + */ +@Extension +public class TestResourceEventListener extends ResourceEventListener { + + /** Recorded event entry. */ + public static class EventRecord { + public final ResourceEvent event; + public final List resourceNames; + public final String buildName; + public final String userName; + + EventRecord( + ResourceEvent event, List resourceNames, String buildName, String userName) { + this.event = event; + this.resourceNames = resourceNames; + this.buildName = buildName; + this.userName = userName; + } + + @Override + public String toString() { + return event + " " + resourceNames + " build=" + buildName + " user=" + userName; + } + } + + private static final List EVENTS = Collections.synchronizedList(new ArrayList<>()); + + @Override + public void onEvent( + @NonNull ResourceEvent event, + @NonNull List resources, + @Nullable Run build, + @Nullable String userName) { + List names = new ArrayList<>(); + for (LockableResource r : resources) { + names.add(r.getName()); + } + EVENTS.add(new EventRecord( + event, names, build != null ? build.getFullDisplayName() : null, userName)); + } + + /** Returns all recorded events. */ + public static List getEvents() { + return new ArrayList<>(EVENTS); + } + + /** Returns all recorded events of the given type. */ + public static List getEvents(ResourceEvent type) { + List filtered = new ArrayList<>(); + for (EventRecord r : EVENTS) { + if (r.event == type) { + filtered.add(r); + } + } + return filtered; + } + + /** Clears all recorded events. */ + public static void clear() { + EVENTS.clear(); + } + + /** + * Static field that Groovy callback scripts can write to via + * {@code org.jenkins.plugins.lockableresources.listeners.TestResourceEventListener.callbackResult = ...}. + * Used to verify Groovy callback execution from tests. + */ + public static volatile String callbackResult = null; +} From 8fbd15bc4c4259ca7c5f3d0afbd679dc8df60708 Mon Sep 17 00:00:00 2001 From: Martin Pokorny Date: Sun, 19 Apr 2026 23:16:38 +0200 Subject: [PATCH 2/5] Add option to ignore Groovy callback exceptions (default true) --- .../LockableResourcesManager.java | 12 +++++++++++ .../listeners/GroovyCallbackListener.java | 21 ++++++++++++------- .../LockableResourcesManager/config.jelly | 3 +++ .../config.properties | 1 + 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java b/src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java index cefd2f0ff..67f63b1c2 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java @@ -85,6 +85,9 @@ public class LockableResourcesManager extends GlobalConfiguration { /** Timeout in seconds for the Groovy event callback (default: 30). */ private int eventCallbackTimeoutSec = 30; + /** Whether to silently log Groovy callback exceptions instead of propagating them (default: true). */ + private boolean eventCallbackIgnoreExceptions = true; + /** * Only used when this lockable resource is tried to be locked by {@link LockStep}, otherwise * (freestyle builds) regular Jenkins queue is used. @@ -163,6 +166,15 @@ public void setEventCallbackTimeoutSec(int eventCallbackTimeoutSec) { this.eventCallbackTimeoutSec = eventCallbackTimeoutSec; } + public boolean isEventCallbackIgnoreExceptions() { + return eventCallbackIgnoreExceptions; + } + + @DataBoundSetter + public void setEventCallbackIgnoreExceptions(boolean eventCallbackIgnoreExceptions) { + this.eventCallbackIgnoreExceptions = eventCallbackIgnoreExceptions; + } + // --------------------------------------------------------------------------- /** C-tor */ @SuppressFBWarnings( diff --git a/src/main/java/org/jenkins/plugins/lockableresources/listeners/GroovyCallbackListener.java b/src/main/java/org/jenkins/plugins/lockableresources/listeners/GroovyCallbackListener.java index b93164845..7c927840a 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/listeners/GroovyCallbackListener.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/listeners/GroovyCallbackListener.java @@ -66,10 +66,11 @@ public void onEvent( String buildName = build != null ? build.getFullDisplayName() : null; boolean async = lrm.isEventCallbackAsync(); int timeout = lrm.getEventCallbackTimeoutSec(); + boolean ignoreExceptions = lrm.isEventCallbackIgnoreExceptions(); if (async) { Future future = CALLBACK_EXECUTOR.submit(() -> { - executeCallback(script, resourceInfos, eventName, buildName, userName); + executeCallback(script, resourceInfos, eventName, buildName, userName, ignoreExceptions); }); // schedule timeout enforcement CALLBACK_EXECUTOR.submit(() -> { @@ -83,7 +84,7 @@ public void onEvent( } }); } else { - executeCallback(script, resourceInfos, eventName, buildName, userName); + executeCallback(script, resourceInfos, eventName, buildName, userName, ignoreExceptions); } } @@ -92,7 +93,8 @@ private static void executeCallback( List resourceInfos, String eventName, @Nullable String buildName, - @Nullable String userName) { + @Nullable String userName, + boolean ignoreExceptions) { for (ResourceInfo info : resourceInfos) { try { Binding binding = new Binding(); @@ -104,10 +106,15 @@ private static void executeCallback( Jenkins jenkins = Jenkins.get(); script.evaluate(jenkins.getPluginManager().uberClassLoader, binding, null); } catch (Exception e) { - LOGGER.log( - Level.WARNING, - "Event callback script failed for " + eventName + " on " + info.getName(), - e); + if (ignoreExceptions) { + LOGGER.log( + Level.WARNING, + "Event callback script failed for " + eventName + " on " + info.getName(), + e); + } else { + throw new RuntimeException( + "Event callback script failed for " + eventName + " on " + info.getName(), e); + } } } } diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.jelly b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.jelly index bf83afdd8..0bff8ee4f 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.jelly +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.jelly @@ -41,6 +41,9 @@ + + + diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.properties index 97152761d..042bf2c6f 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.properties +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.properties @@ -31,3 +31,4 @@ eventCallback.section.title=Resource Event Callback eventCallback.script.title=Groovy script executed when a resource changes state eventCallback.async.title=Run callback asynchronously (recommended) eventCallback.timeout.title=Callback timeout (seconds) +eventCallback.ignoreExceptions.title=Ignore callback exceptions (recommended) From c51d18ff653f5fd705b146c26b6cacb7f191b4a5 Mon Sep 17 00:00:00 2001 From: Martin Pokorny Date: Sun, 19 Apr 2026 23:27:53 +0200 Subject: [PATCH 3/5] Fix SecureGroovyScript not configured before evaluate --- .../lockableresources/LockableResourcesManager.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java b/src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java index 67f63b1c2..4139688fc 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java @@ -145,7 +145,8 @@ public SecureGroovyScript getOnResourceEventScript() { @DataBoundSetter public void setOnResourceEventScript(@CheckForNull SecureGroovyScript onResourceEventScript) { - this.onResourceEventScript = onResourceEventScript; + this.onResourceEventScript = + onResourceEventScript != null ? onResourceEventScript.configuringWithKeyItem() : null; } public boolean isEventCallbackAsync() { @@ -183,6 +184,11 @@ public void setEventCallbackIgnoreExceptions(boolean eventCallbackIgnoreExceptio public LockableResourcesManager() { resources = new ArrayList<>(); load(); + // SecureGroovyScript requires configuring() before evaluate() can be called. + // When deserialized from XML, the setter is not invoked, so configure here. + if (onResourceEventScript != null) { + onResourceEventScript = onResourceEventScript.configuringWithNonKeyItem(); + } } // --------------------------------------------------------------------------- From 26f021782d58ac1d9dafdbd087dbc965dfb23ff0 Mon Sep 17 00:00:00 2001 From: Martin Pokorny Date: Fri, 5 Jun 2026 22:53:37 +0200 Subject: [PATCH 4/5] Apply Spotless formatting --- .../listeners/GroovyCallbackListener.java | 13 ++++--- .../listeners/ResourceEventListenerTest.java | 36 +++++++++---------- .../listeners/TestResourceEventListener.java | 6 ++-- 3 files changed, 25 insertions(+), 30 deletions(-) diff --git a/src/main/java/org/jenkins/plugins/lockableresources/listeners/GroovyCallbackListener.java b/src/main/java/org/jenkins/plugins/lockableresources/listeners/GroovyCallbackListener.java index 7c927840a..9d121d6f5 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/listeners/GroovyCallbackListener.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/listeners/GroovyCallbackListener.java @@ -36,13 +36,12 @@ public class GroovyCallbackListener extends ResourceEventListener { private static final Logger LOGGER = Logger.getLogger(GroovyCallbackListener.class.getName()); - private static final ExecutorService CALLBACK_EXECUTOR = - Executors.newCachedThreadPool(r -> { - Thread t = new Thread(r); - t.setName("lockable-resources-event-callback"); - t.setDaemon(true); - return t; - }); + private static final ExecutorService CALLBACK_EXECUTOR = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r); + t.setName("lockable-resources-event-callback"); + t.setDaemon(true); + return t; + }); @Override public void onEvent( diff --git a/src/test/java/org/jenkins/plugins/lockableresources/listeners/ResourceEventListenerTest.java b/src/test/java/org/jenkins/plugins/lockableresources/listeners/ResourceEventListenerTest.java index d94cd4cd7..f7d61b2f4 100644 --- a/src/test/java/org/jenkins/plugins/lockableresources/listeners/ResourceEventListenerTest.java +++ b/src/test/java/org/jenkins/plugins/lockableresources/listeners/ResourceEventListenerTest.java @@ -49,8 +49,7 @@ void setUp() { @Test void extensionPointIsRegistered(JenkinsRule j) { - ExtensionList listeners = - ExtensionList.lookup(ResourceEventListener.class); + ExtensionList listeners = ExtensionList.lookup(ResourceEventListener.class); assertTrue(listeners.size() > 0, "At least the test listener should be registered"); boolean found = false; for (ResourceEventListener l : listeners) { @@ -108,8 +107,7 @@ void stealFiresEvent(JenkinsRule j) { assertTrue(lrm.steal(Arrays.asList(r1), "thief")); - List events = - TestResourceEventListener.getEvents(ResourceEvent.STOLEN); + List events = TestResourceEventListener.getEvents(ResourceEvent.STOLEN); assertThat(events, hasSize(1)); assertThat(events.get(0).resourceNames, is(Arrays.asList("r1"))); assertThat(events.get(0).userName, is("thief")); @@ -146,8 +144,7 @@ void resetFiresEvent(JenkinsRule j) { lrm.reset(Arrays.asList(r1)); - List events = - TestResourceEventListener.getEvents(ResourceEvent.RESET); + List events = TestResourceEventListener.getEvents(ResourceEvent.RESET); assertThat(events, hasSize(1)); assertThat(events.get(0).resourceNames, is(Arrays.asList("r1"))); } @@ -158,19 +155,16 @@ void lockUnlockFiresEventsViaPipeline(JenkinsRule j) throws Exception { lrm.createResource("r1"); WorkflowJob p = j.jenkins.createProject(WorkflowJob.class, "p"); - p.setDefinition(new CpsFlowDefinition( - """ + p.setDefinition(new CpsFlowDefinition(""" lock('r1') { echo 'inside lock' } - """, - true)); + """, true)); WorkflowRun b = p.scheduleBuild2(0).waitForStart(); j.assertBuildStatusSuccess(j.waitForCompletion(b)); - List locked = - TestResourceEventListener.getEvents(ResourceEvent.LOCKED); + List locked = TestResourceEventListener.getEvents(ResourceEvent.LOCKED); assertThat("Expected exactly one LOCKED event", locked, hasSize(1)); assertThat(locked.get(0).resourceNames, is(Arrays.asList("r1"))); assertNotNull(locked.get(0).buildName, "LOCKED event should include build name"); @@ -274,7 +268,9 @@ void groovyCallbackIsExecutedOnReserve(JenkinsRule j) throws Exception { // configure a groovy callback that writes to a static field for verification String script = "org.jenkins.plugins.lockableresources.listeners.TestResourceEventListener.callbackResult = event + ':' + resource.name + ':' + (userName ?: 'null')"; - ScriptApproval.get().approveSignature("staticField org.jenkins.plugins.lockableresources.listeners.TestResourceEventListener callbackResult"); + ScriptApproval.get() + .approveSignature( + "staticField org.jenkins.plugins.lockableresources.listeners.TestResourceEventListener callbackResult"); SecureGroovyScript groovyScript = new SecureGroovyScript(script, true, null).configuring(ApprovalContext.create()); lrm.setOnResourceEventScript(groovyScript); @@ -299,7 +295,9 @@ void groovyCallbackRunsAsyncByDefault(JenkinsRule j) throws Exception { String script = "org.jenkins.plugins.lockableresources.listeners.TestResourceEventListener.callbackResult = event + ':' + resource.name"; - ScriptApproval.get().approveSignature("staticField org.jenkins.plugins.lockableresources.listeners.TestResourceEventListener callbackResult"); + ScriptApproval.get() + .approveSignature( + "staticField org.jenkins.plugins.lockableresources.listeners.TestResourceEventListener callbackResult"); SecureGroovyScript groovyScript = new SecureGroovyScript(script, true, null).configuring(ApprovalContext.create()); lrm.setOnResourceEventScript(groovyScript); @@ -328,20 +326,20 @@ void groovyCallbackReceivesBuildNameOnLock(JenkinsRule j) throws Exception { String script = "if (event == 'LOCKED') { org.jenkins.plugins.lockableresources.listeners.TestResourceEventListener.callbackResult = 'build=' + (buildName ?: 'null') }"; - ScriptApproval.get().approveSignature("staticField org.jenkins.plugins.lockableresources.listeners.TestResourceEventListener callbackResult"); + ScriptApproval.get() + .approveSignature( + "staticField org.jenkins.plugins.lockableresources.listeners.TestResourceEventListener callbackResult"); SecureGroovyScript groovyScript = new SecureGroovyScript(script, true, null).configuring(ApprovalContext.create()); lrm.setOnResourceEventScript(groovyScript); lrm.setEventCallbackAsync(false); WorkflowJob p = j.jenkins.createProject(WorkflowJob.class, "p"); - p.setDefinition(new CpsFlowDefinition( - """ + p.setDefinition(new CpsFlowDefinition(""" lock('r1') { echo 'locked' } - """, - true)); + """, true)); WorkflowRun b = p.scheduleBuild2(0).waitForStart(); j.assertBuildStatusSuccess(j.waitForCompletion(b)); diff --git a/src/test/java/org/jenkins/plugins/lockableresources/listeners/TestResourceEventListener.java b/src/test/java/org/jenkins/plugins/lockableresources/listeners/TestResourceEventListener.java index 5133fa31c..d2c80f20f 100644 --- a/src/test/java/org/jenkins/plugins/lockableresources/listeners/TestResourceEventListener.java +++ b/src/test/java/org/jenkins/plugins/lockableresources/listeners/TestResourceEventListener.java @@ -28,8 +28,7 @@ public static class EventRecord { public final String buildName; public final String userName; - EventRecord( - ResourceEvent event, List resourceNames, String buildName, String userName) { + EventRecord(ResourceEvent event, List resourceNames, String buildName, String userName) { this.event = event; this.resourceNames = resourceNames; this.buildName = buildName; @@ -54,8 +53,7 @@ public void onEvent( for (LockableResource r : resources) { names.add(r.getName()); } - EVENTS.add(new EventRecord( - event, names, build != null ? build.getFullDisplayName() : null, userName)); + EVENTS.add(new EventRecord(event, names, build != null ? build.getFullDisplayName() : null, userName)); } /** Returns all recorded events. */ From 6d4d23c78d7022787b9f3fa60123cdd63ba7e725 Mon Sep 17 00:00:00 2001 From: Martin Pokorny Date: Sat, 6 Jun 2026 23:21:35 +0200 Subject: [PATCH 5/5] Update JCasC export expectation for event callback settings --- .../jenkins/plugins/lockableresources/casc_expected_output.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/test/resources/org/jenkins/plugins/lockableresources/casc_expected_output.yml b/src/test/resources/org/jenkins/plugins/lockableresources/casc_expected_output.yml index a43387959..aee9923b5 100644 --- a/src/test/resources/org/jenkins/plugins/lockableresources/casc_expected_output.yml +++ b/src/test/resources/org/jenkins/plugins/lockableresources/casc_expected_output.yml @@ -10,3 +10,6 @@ declaredResources: - description: "Description_C" labels: "Label_C" name: "Resource_C" +eventCallbackAsync: true +eventCallbackIgnoreExceptions: true +eventCallbackTimeoutSec: 30