diff --git a/src/doc/examples/readme.md b/src/doc/examples/readme.md index 03d6f60b1..cf7bd95df 100644 --- a/src/doc/examples/readme.md +++ b/src/doc/examples/readme.md @@ -14,4 +14,5 @@ If you have a question, please open a [GitHub issue](https://github.com/jenkinsc - [Scripted vs declarative pipeline](scripted-vs-declarative-pipeline.md) - [Timeout inside lock](timeout-inside-lock.md) - [Dynamic resource pool expansion](dynamic-resource-pool-expansion.md) +- [Resource event notifications](resource-event-notifications.md) - [Lock with allocation timeout](lock-with-timeout.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 a7f161e9e..1c9848ad8 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,19 @@ 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; + + /** 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. @@ -129,6 +144,44 @@ public boolean isAllowEphemeralResources() { return allowEphemeralResources; } + @CheckForNull + public SecureGroovyScript getOnResourceEventScript() { + return onResourceEventScript; + } + + @DataBoundSetter + public void setOnResourceEventScript(@CheckForNull SecureGroovyScript onResourceEventScript) { + this.onResourceEventScript = + onResourceEventScript != null ? onResourceEventScript.configuringWithKeyItem() : null; + } + + 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; + } + + public boolean isEventCallbackIgnoreExceptions() { + return eventCallbackIgnoreExceptions; + } + + @DataBoundSetter + public void setEventCallbackIgnoreExceptions(boolean eventCallbackIgnoreExceptions) { + this.eventCallbackIgnoreExceptions = eventCallbackIgnoreExceptions; + } + // --------------------------------------------------------------------------- /** C-tor */ @SuppressFBWarnings( @@ -137,6 +190,11 @@ public boolean isAllowEphemeralResources() { 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(); + } } // --------------------------------------------------------------------------- @@ -712,6 +770,7 @@ public boolean lock(List resourcesToLock, Run build, @Nu LockedResourcesBuildAction.findAndInitAction(build).addUsedResources(getResourcesNames(resourcesToLock)); save(); + ResourceEventListener.fireEvent(ResourceEvent.LOCKED, resourcesToLock, build, null); return true; } @@ -727,6 +786,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 @@ -738,6 +798,7 @@ private void freeResources(List unlockResources, Run bui resource.setBuild(null); resource.setLockReason(null); uncacheIfFreeing(resource, true, false); + freed.add(resource); if (resource.isEphemeral()) { LOGGER.fine("Remove ephemeral resource: " + resource); @@ -749,6 +810,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) { @@ -1075,6 +1140,7 @@ public boolean reserve(List resources, String userName, String } save(); } + ResourceEventListener.fireEvent(ResourceEvent.RESERVED, resources, null, userName); LOGGER.info("reserve() succeeded user='" + userName + "' resources=" + getResourcesNames(resources)); return true; } @@ -1114,6 +1180,7 @@ public boolean steal(List resources, String userName, String r } save(); } + ResourceEventListener.fireEvent(ResourceEvent.STOLEN, resources, null, userName); return true; } @@ -1135,6 +1202,7 @@ public void reassign(List resources, String userName) { } save(); } + ResourceEventListener.fireEvent(ResourceEvent.REASSIGNED, resources, null, userName); } // --------------------------------------------------------------------------- @@ -1161,6 +1229,7 @@ public void unreserve(List resources) { save(); } + ResourceEventListener.fireEvent(ResourceEvent.UNRESERVED, resources, null, null); scheduleQueueMaintenance(); } @@ -1185,6 +1254,7 @@ public void reset(List resources) { save(); } + ResourceEventListener.fireEvent(ResourceEvent.RESET, resources, null, null); scheduleQueueMaintenance(); } @@ -1203,6 +1273,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..9d121d6f5 --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/listeners/GroovyCallbackListener.java @@ -0,0 +1,120 @@ +/* + * 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(); + boolean ignoreExceptions = lrm.isEventCallbackIgnoreExceptions(); + + if (async) { + Future future = CALLBACK_EXECUTOR.submit(() -> { + executeCallback(script, resourceInfos, eventName, buildName, userName, ignoreExceptions); + }); + // 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, ignoreExceptions); + } + } + + private static void executeCallback( + SecureGroovyScript script, + List resourceInfos, + String eventName, + @Nullable String buildName, + @Nullable String userName, + boolean ignoreExceptions) { + 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) { + 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/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 0a403e1d4..5ae53a2aa 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.jelly +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.jelly @@ -28,5 +28,18 @@ ${%entry.moved.link} + + + + + + + + + + + + + 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 512da0759..d2ae39afa 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.properties +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.properties @@ -29,3 +29,8 @@ entry.moved=Resources are now managed from the dedicated Lockable Resources page entry.moved.link=Go to 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) +eventCallback.ignoreExceptions.title=Ignore callback exceptions (recommended) 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..f7d61b2f4 --- /dev/null +++ b/src/test/java/org/jenkins/plugins/lockableresources/listeners/ResourceEventListenerTest.java @@ -0,0 +1,382 @@ +/* + * 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..d2c80f20f --- /dev/null +++ b/src/test/java/org/jenkins/plugins/lockableresources/listeners/TestResourceEventListener.java @@ -0,0 +1,86 @@ +/* + * 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; +} 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