Skip to content

Commit 59635fe

Browse files
authored
feat: Support build parameters in resource names, labels, and numbers (#972)
* feat: Support build parameters in resource names, labels, and numbers Allow references in the Required Lockable Resources job property so that resource names, labels, and resource numbers can be resolved from build parameters at queue time and build start. Changes: - Utils: add requiredResources(Job, EnvVars) overload and getParametersAsEnvVars(Queue.Item) to extract build parameters - LockableResourcesStruct: expand requiredNumber via env.expand() - LockableResourcesQueueTaskDispatcher: pass build parameters from queue item to resource struct for early expansion - LockRunListener: pass AbstractBuild environment to resource struct so parameter references are expanded at build start - RequiredResourcesProperty: form validation now recognises \ patterns and shows a warning instead of an error - Messages.properties: add warning messages for parameter references - Tests: add parameterizedResourceName, parameterizedLabel, parameterizedResourceNumber tests and UtilsTest.containsVariable Fixes #159, Fixes #202 Replaces #214 * style: Apply spotless formatting * fix: Narrow exception handling to satisfy SpotBugs REC_CATCH_EXCEPTION - Utils.getParametersAsEnvVars: remove unnecessary try-catch (APIs do not throw checked exceptions) - LockRunListener.onStarted: catch IOException | InterruptedException instead of generic Exception * test: Add comprehensive unit tests for getParametersAsEnvVars() Add 7 new test methods covering all code paths: - No actions (empty list) - Single parameter - Multiple parameters in one action - Multiple ParametersAction instances - Null parameter value (skipped) - Non-string value (converted via toString) - Duplicate key across actions (later wins) Also fix @NoExternalUse annotations to use @restricted(NoExternalUse.class) and add missing imports for ExcludeFromJacocoGeneratedReport.
1 parent 9657f1d commit 59635fe

8 files changed

Lines changed: 336 additions & 3 deletions

File tree

src/main/java/org/jenkins/plugins/lockableresources/RequiredResourcesProperty.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import hudson.util.FormValidation;
2323
import java.util.ArrayList;
2424
import java.util.List;
25+
import java.util.regex.Pattern;
2526
import jenkins.model.Jenkins;
2627
import net.sf.json.JSONObject;
2728
import org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript;
@@ -149,6 +150,9 @@ public SecureGroovyScript getResourceMatchScript() {
149150
@Extension
150151
public static class DescriptorImpl extends JobPropertyDescriptor {
151152

153+
/** Detects {@code ${...}} variable references that are resolved at build time. */
154+
private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\$\\{[^}]+}");
155+
152156
@NonNull
153157
@Override
154158
public String getDisplayName() {
@@ -188,10 +192,17 @@ public FormValidation doCheckResourceNames(
188192
} else {
189193
List<String> wrongNames = new ArrayList<>();
190194
for (String name : names.split("\\s+")) {
195+
// Skip validation for names containing build-parameter references
196+
if (VARIABLE_PATTERN.matcher(name).find()) {
197+
continue;
198+
}
191199
boolean found = LockableResourcesManager.get().resourceExist(name);
192200
if (!found) wrongNames.add(name);
193201
}
194202
if (wrongNames.isEmpty()) {
203+
if (VARIABLE_PATTERN.matcher(names).find()) {
204+
return FormValidation.warning(Messages.warning_resourceNameContainsVariable());
205+
}
195206
return FormValidation.ok();
196207
} else {
197208
return FormValidation.error(Messages.error_resourceDoesNotExist(wrongNames));
@@ -216,6 +227,10 @@ public FormValidation doCheckLabelName(
216227
} else if (names != null || script) {
217228
return FormValidation.error(Messages.error_labelAndNameOrGroovySpecified());
218229
} else {
230+
// Skip label validation when it contains build-parameter references
231+
if (VARIABLE_PATTERN.matcher(label).find()) {
232+
return FormValidation.warning(Messages.warning_labelContainsVariable());
233+
}
219234
if (LockableResourcesManager.get().isValidLabel(label)) {
220235
return FormValidation.ok();
221236
} else {
@@ -243,6 +258,11 @@ public FormValidation doCheckResourceNumber(
243258
return FormValidation.ok();
244259
}
245260

261+
// Skip numeric validation when number contains build-parameter references
262+
if (VARIABLE_PATTERN.matcher(number).find()) {
263+
return FormValidation.warning(Messages.warning_resourceNumberContainsVariable());
264+
}
265+
246266
int numAsInt;
247267
try {
248268
numAsInt = Integer.parseInt(number);

src/main/java/org/jenkins/plugins/lockableresources/queue/LockRunListener.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99
package org.jenkins.plugins.lockableresources.queue;
1010

1111
import edu.umd.cs.findbugs.annotations.NonNull;
12+
import hudson.EnvVars;
1213
import hudson.Extension;
1314
import hudson.model.AbstractBuild;
1415
import hudson.model.Job;
1516
import hudson.model.Run;
1617
import hudson.model.StringParameterValue;
1718
import hudson.model.TaskListener;
1819
import hudson.model.listeners.RunListener;
20+
import java.io.IOException;
1921
import java.util.ArrayList;
2022
import java.util.List;
2123
import java.util.logging.Logger;
@@ -40,12 +42,22 @@ public void onStarted(Run<?, ?> build, TaskListener listener) {
4042
}
4143

4244
if (build instanceof AbstractBuild) {
45+
AbstractBuild<?, ?> abstractBuild = (AbstractBuild<?, ?>) build;
4346
LockableResourcesManager lrm = LockableResourcesManager.get();
4447
synchronized (lrm.syncResources) {
4548
Job<?, ?> proj = Utils.getProject(build);
4649
List<LockableResource> required = new ArrayList<>();
4750

48-
LockableResourcesStruct resources = Utils.requiredResources(proj);
51+
// Resolve build parameters so that ${PARAM} references in
52+
// resource names, labels, and numbers are expanded.
53+
EnvVars buildEnv;
54+
try {
55+
buildEnv = abstractBuild.getEnvironment(listener);
56+
} catch (IOException | InterruptedException e) {
57+
buildEnv = new EnvVars();
58+
}
59+
60+
LockableResourcesStruct resources = Utils.requiredResources(proj, buildEnv);
4961

5062
if (resources != null) {
5163
if (resources.requiredNumber != null

src/main/java/org/jenkins/plugins/lockableresources/queue/LockableResourcesQueueTaskDispatcher.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import com.github.benmanes.caffeine.cache.Cache;
1212
import com.github.benmanes.caffeine.cache.Caffeine;
1313
import edu.umd.cs.findbugs.annotations.NonNull;
14+
import hudson.EnvVars;
1415
import hudson.Extension;
1516
import hudson.ExtensionList;
1617
import hudson.model.Job;
@@ -51,7 +52,10 @@ public CauseOfBlockage canRun(Queue.Item item) {
5152
Job<?, ?> project = Utils.getProject(item);
5253
if (project == null) return null;
5354

54-
LockableResourcesStruct resources = Utils.requiredResources(project);
55+
// Extract build parameters so that ${PARAM} references in resource
56+
// names, labels, and numbers are expanded before scheduling.
57+
EnvVars paramEnv = Utils.getParametersAsEnvVars(item);
58+
LockableResourcesStruct resources = Utils.requiredResources(project, paramEnv);
5559
if (resources == null
5660
|| (resources.required.isEmpty()
5761
&& resources.label.isEmpty()

src/main/java/org/jenkins/plugins/lockableresources/queue/LockableResourcesStruct.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public LockableResourcesStruct(RequiredResourcesProperty property, EnvVars env)
6969

7070
requiredVar = property.getResourceNamesVar();
7171

72-
requiredNumber = property.getResourceNumber();
72+
requiredNumber = env.expand(property.getResourceNumber());
7373
if (requiredNumber != null && requiredNumber.equals("0")) requiredNumber = null;
7474
}
7575

src/main/java/org/jenkins/plugins/lockableresources/queue/Utils.java

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,25 @@
1414
import hudson.ExtensionList;
1515
import hudson.matrix.MatrixConfiguration;
1616
import hudson.model.Job;
17+
import hudson.model.ParameterValue;
18+
import hudson.model.ParametersAction;
1719
import hudson.model.Queue;
1820
import hudson.model.Run;
21+
import java.util.List;
1922
import java.util.Map;
23+
import java.util.regex.Pattern;
24+
import org.jenkins.plugins.lockableresources.ExcludeFromJacocoGeneratedReport;
2025
import org.jenkins.plugins.lockableresources.RequiredResourcesProperty;
2126
import org.jenkinsci.plugins.variant.OptionalExtension;
27+
import org.kohsuke.accmod.Restricted;
28+
import org.kohsuke.accmod.restrictions.NoExternalUse;
2229

2330
public final class Utils {
2431
private Utils() {}
2532

33+
/** Pattern to detect {@code ${...}} variable references in configuration values. */
34+
private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\$\\{[^}]+}");
35+
2636
@CheckForNull
2737
public static Job<?, ?> getProject(@NonNull Queue.Item item) {
2838
if (item.task instanceof Job) return (Job<?, ?>) item.task;
@@ -34,21 +44,80 @@ private Utils() {}
3444
return build.getParent();
3545
}
3646

47+
/**
48+
* Build the required-resources structure for a project, without additional environment variables.
49+
*
50+
* @see #requiredResources(Job, EnvVars)
51+
*/
52+
@Deprecated
53+
@ExcludeFromJacocoGeneratedReport
3754
@CheckForNull
55+
@Restricted(NoExternalUse.class)
3856
public static LockableResourcesStruct requiredResources(@NonNull Job<?, ?> project) {
57+
return requiredResources(project, null);
58+
}
59+
60+
/**
61+
* Build the required-resources structure for a project, merging any additional
62+
* environment variables (e.g.&nbsp;build parameters) into the expansion context.
63+
*
64+
* @param project the job whose {@link RequiredResourcesProperty} is read
65+
* @param additionalEnv extra variables to use when expanding {@code ${...}} references;
66+
* may be {@code null}
67+
* @return the struct, or {@code null} if the project has no lockable-resource property
68+
*/
69+
@CheckForNull
70+
@Restricted(NoExternalUse.class)
71+
public static LockableResourcesStruct requiredResources(
72+
@NonNull Job<?, ?> project, @CheckForNull EnvVars additionalEnv) {
3973
EnvVars env = new EnvVars();
4074

4175
for (var ma : ExtensionList.lookup(MatrixAssist.class)) {
4276
env.putAll(ma.getCombination(project));
4377
project = ma.getMainProject(project);
4478
}
4579

80+
if (additionalEnv != null) {
81+
env.putAll(additionalEnv);
82+
}
83+
4684
RequiredResourcesProperty property = project.getProperty(RequiredResourcesProperty.class);
4785
if (property != null) return new LockableResourcesStruct(property, env);
4886

4987
return null;
5088
}
5189

90+
/**
91+
* Extract build parameters from a {@link Queue.Item} and return them as {@link EnvVars}
92+
* so that {@code ${PARAM}} references in resource names, labels and numbers are expanded.
93+
*/
94+
@NonNull
95+
@Restricted(NoExternalUse.class)
96+
public static EnvVars getParametersAsEnvVars(@NonNull Queue.Item item) {
97+
EnvVars env = new EnvVars();
98+
List<ParametersAction> paramActions = item.getActions(ParametersAction.class);
99+
for (ParametersAction action : paramActions) {
100+
if (action == null) continue;
101+
for (ParameterValue p : action.getParameters()) {
102+
if (p == null) continue;
103+
Object value = p.getValue();
104+
if (value != null) {
105+
env.put(p.getName(), value.toString());
106+
}
107+
}
108+
}
109+
return env;
110+
}
111+
112+
/**
113+
* Returns {@code true} when the given string contains at least one {@code ${...}} variable
114+
* reference that will be resolved at build time.
115+
*/
116+
@Restricted(NoExternalUse.class)
117+
public static boolean containsVariable(@CheckForNull String value) {
118+
return value != null && VARIABLE_PATTERN.matcher(value).find();
119+
}
120+
52121
public interface MatrixAssist {
53122
@NonNull
54123
Map<String, String> getCombination(@NonNull Job<?, ?> project);

src/main/resources/org/jenkins/plugins/lockableresources/Messages.properties

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ LockStepResource.displayName=Resource
3939
LockableResource.displayName=Resource
4040
LockableResourcesManager.displayName=External Resources
4141
RequiredResourcesProperty.displayName=Required Lockable Resources
42+
# warnings (build-parameter references)
43+
warning.resourceNameContainsVariable=Resource name contains build parameter references. \
44+
Validation will occur at build time.
45+
warning.labelContainsVariable=Label contains build parameter references. \
46+
Validation will occur at build time.
47+
warning.resourceNumberContainsVariable=Resource number contains build parameter references. \
48+
Validation will occur at build time.
4249
UpdateLockStep.displayName=Update lockable resource
4350
# UpdateLockStep errors
4451
UpdateLockStep.error.resourceRequired=The resource name must be specified.

src/test/java/org/jenkins/plugins/lockableresources/FreeStyleProjectTest.java

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
import hudson.model.FreeStyleBuild;
1616
import hudson.model.FreeStyleProject;
1717
import hudson.model.Item;
18+
import hudson.model.ParametersDefinitionProperty;
1819
import hudson.model.Queue;
1920
import hudson.model.Result;
21+
import hudson.model.StringParameterDefinition;
2022
import hudson.model.User;
2123
import hudson.model.queue.QueueTaskFuture;
2224
import hudson.triggers.TimerTrigger;
@@ -267,6 +269,71 @@ public boolean perform(AbstractBuild<?, ?> build, Launcher launcher, BuildListen
267269
j.waitForCompletion(fb2);
268270
}
269271

272+
// ---------------------------------------------------------------------------
273+
// Parameterized resource tests (build parameters as resource / label / number)
274+
// ---------------------------------------------------------------------------
275+
276+
@Test
277+
void parameterizedResourceName(JenkinsRule j) throws Exception {
278+
LockableResourcesManager.get().createResource("my-resource");
279+
280+
FreeStyleProject p = j.createFreeStyleProject("paramResourceName");
281+
p.addProperty(new RequiredResourcesProperty("${RESOURCE_NAME}", "resourceNameVar", null, null, null));
282+
p.addProperty(new ParametersDefinitionProperty(
283+
new StringParameterDefinition("RESOURCE_NAME", "my-resource", "Resource to lock")));
284+
p.getBuildersList().add(new PrinterBuilder());
285+
286+
FreeStyleBuild b = p.scheduleBuild2(0).get();
287+
j.assertLogContains("acquired lock on [my-resource]", b);
288+
j.assertLogContains("resourceNameVar: my-resource", b);
289+
j.assertBuildStatus(Result.SUCCESS, b);
290+
}
291+
292+
@Test
293+
void parameterizedLabel(JenkinsRule j) throws Exception {
294+
LockableResourcesManager.get().createResourceWithLabel("res1", "team-alpha");
295+
LockableResourcesManager.get().createResourceWithLabel("res2", "team-alpha");
296+
297+
FreeStyleProject p = j.createFreeStyleProject("paramLabel");
298+
p.addProperty(new RequiredResourcesProperty(null, "resourceNameVar", "1", "${LABEL}", null));
299+
p.addProperty(
300+
new ParametersDefinitionProperty(new StringParameterDefinition("LABEL", "team-alpha", "Label to use")));
301+
302+
FreeStyleBuild b = p.scheduleBuild2(0).get();
303+
j.assertLogContains("acquired lock on", b);
304+
j.assertBuildStatus(Result.SUCCESS, b);
305+
}
306+
307+
@Test
308+
void parameterizedResourceNumber(JenkinsRule j) throws Exception {
309+
LockableResourcesManager.get().createResourceWithLabel("pool1", "pool");
310+
LockableResourcesManager.get().createResourceWithLabel("pool2", "pool");
311+
LockableResourcesManager.get().createResourceWithLabel("pool3", "pool");
312+
313+
FreeStyleProject p = j.createFreeStyleProject("paramNumber");
314+
p.addProperty(new RequiredResourcesProperty(null, "resourceNameVar", "${COUNT}", "pool", null));
315+
p.addProperty(
316+
new ParametersDefinitionProperty(new StringParameterDefinition("COUNT", "2", "How many resources")));
317+
318+
FreeStyleBuild b = p.scheduleBuild2(0).get();
319+
j.assertLogContains("acquired lock on", b);
320+
j.assertBuildStatus(Result.SUCCESS, b);
321+
322+
// Verify exactly 2 resources were locked via the variable
323+
String log = b.getLog();
324+
String varLine = null;
325+
for (String line : log.split("\n")) {
326+
if (line.contains("acquired lock on")) {
327+
varLine = line;
328+
break;
329+
}
330+
}
331+
assertNotNull(varLine, "Expected 'acquired lock on' in build log");
332+
// Count resource names in the log line (comma-separated inside brackets)
333+
long count = varLine.chars().filter(ch -> ch == ',').count() + 1;
334+
assertEquals(2, count, "Expected exactly 2 resources to be locked");
335+
}
336+
270337
public static class PrinterBuilder extends TestBuilder {
271338

272339
@Override

0 commit comments

Comments
 (0)