Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/doc/examples/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
99 changes: 99 additions & 0 deletions src/doc/examples/resource-event-notifications.md
Original file line number Diff line number Diff line change
@@ -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<String, String>` 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<LockableResource> resources,
Run<?, ?> build, String userName) {
// handle event
}
}
```
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -73,6 +75,19 @@
*/
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.
Expand Down Expand Up @@ -129,6 +144,44 @@
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;
}

Check warning on line 174 in src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 173-174 are not covered by tests

public boolean isEventCallbackIgnoreExceptions() {
return eventCallbackIgnoreExceptions;
}

@DataBoundSetter
public void setEventCallbackIgnoreExceptions(boolean eventCallbackIgnoreExceptions) {
this.eventCallbackIgnoreExceptions = eventCallbackIgnoreExceptions;
}

Check warning on line 183 in src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 182-183 are not covered by tests

// ---------------------------------------------------------------------------
/** C-tor */
@SuppressFBWarnings(
Expand All @@ -137,6 +190,11 @@
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) {

Check warning on line 195 in src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 195 is only partially covered, one branch is missing
onResourceEventScript = onResourceEventScript.configuringWithNonKeyItem();

Check warning on line 196 in src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 196 is not covered by tests
}
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -712,6 +770,7 @@
LockedResourcesBuildAction.findAndInitAction(build).addUsedResources(getResourcesNames(resourcesToLock));

save();
ResourceEventListener.fireEvent(ResourceEvent.LOCKED, resourcesToLock, build, null);

return true;
}
Expand All @@ -727,6 +786,7 @@
}

List<LockableResource> toBeRemoved = new ArrayList<>();
List<LockableResource> freed = new ArrayList<>();

for (LockableResource resource : unlockResources) {
// No more contexts, unlock resource
Expand All @@ -738,6 +798,7 @@
resource.setBuild(null);
resource.setLockReason(null);
uncacheIfFreeing(resource, true, false);
freed.add(resource);

if (resource.isEphemeral()) {
LOGGER.fine("Remove ephemeral resource: " + resource);
Expand All @@ -749,6 +810,10 @@

// remove all ephemeral resources
removeResources(toBeRemoved);

if (!freed.isEmpty()) {
ResourceEventListener.fireEvent(ResourceEvent.UNLOCKED, freed, build, null);
}
}

public void unlockBuild(@Nullable Run<?, ?> build) {
Expand Down Expand Up @@ -1075,6 +1140,7 @@
}
save();
}
ResourceEventListener.fireEvent(ResourceEvent.RESERVED, resources, null, userName);
LOGGER.info("reserve() succeeded user='" + userName + "' resources=" + getResourcesNames(resources));
return true;
}
Expand Down Expand Up @@ -1114,6 +1180,7 @@
}
save();
}
ResourceEventListener.fireEvent(ResourceEvent.STOLEN, resources, null, userName);
return true;
}

Expand All @@ -1135,6 +1202,7 @@
}
save();
}
ResourceEventListener.fireEvent(ResourceEvent.REASSIGNED, resources, null, userName);
}

// ---------------------------------------------------------------------------
Expand All @@ -1161,6 +1229,7 @@

save();
}
ResourceEventListener.fireEvent(ResourceEvent.UNRESERVED, resources, null, null);
scheduleQueueMaintenance();
}

Expand All @@ -1185,6 +1254,7 @@

save();
}
ResourceEventListener.fireEvent(ResourceEvent.RESET, resources, null, null);
scheduleQueueMaintenance();
}

Expand All @@ -1203,6 +1273,7 @@
this.unlockResources(resources);
this.unreserve(resources);
}
ResourceEventListener.fireEvent(ResourceEvent.RECYCLED, resources, null, null);
}

// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<LockableResource> resources,
@Nullable Run<?, ?> build,
@Nullable String userName) {

LockableResourcesManager lrm = LockableResourcesManager.get();
SecureGroovyScript script = lrm.getOnResourceEventScript();
if (script == null || script.getScript().trim().isEmpty()) {

Check warning on line 55 in src/main/java/org/jenkins/plugins/lockableresources/listeners/GroovyCallbackListener.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 55 is only partially covered, one branch is missing
return;
}

List<ResourceInfo> 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(() -> {

Check warning on line 75 in src/main/java/org/jenkins/plugins/lockableresources/listeners/GroovyCallbackListener.java

View check run for this annotation

ci.jenkins.io / SpotBugs

RV_RETURN_VALUE_IGNORED_BAD_PRACTICE

LOW: Exceptional return value of java.util.concurrent.ExecutorService.submit(Runnable) ignored in org.jenkins.plugins.lockableresources.listeners.GroovyCallbackListener.onEvent(ResourceEvent, List, Run, String)
Raw output
<p> This method returns a value that is not checked. The return value should be checked since it can indicate an unusual or unexpected function execution. For example, the <code>File.delete()</code> method returns false if the file could not be successfully deleted (rather than throwing an Exception). If you don't check the result, you won't notice if the method invocation signals unexpected behavior by returning an atypical return value. </p>
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);

Check warning on line 82 in src/main/java/org/jenkins/plugins/lockableresources/listeners/GroovyCallbackListener.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 78-82 are not covered by tests
}
});
} else {
executeCallback(script, resourceInfos, eventName, buildName, userName, ignoreExceptions);
}
}

private static void executeCallback(
SecureGroovyScript script,
List<ResourceInfo> 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) {

Check warning on line 108 in src/main/java/org/jenkins/plugins/lockableresources/listeners/GroovyCallbackListener.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 108 is only partially covered, one branch is missing
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);

Check warning on line 115 in src/main/java/org/jenkins/plugins/lockableresources/listeners/GroovyCallbackListener.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 114-115 are not covered by tests
}
}
}
}
}
Loading