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
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package org.rundeck.plugins.nodes.attributes;

import com.dtolabs.rundeck.core.plugins.Plugin;
import com.dtolabs.rundeck.plugins.descriptions.*;
import com.dtolabs.rundeck.plugins.descriptions.PluginDescription;
import com.dtolabs.rundeck.plugins.descriptions.PluginMetadata;
import com.dtolabs.rundeck.plugins.descriptions.PluginProperty;
import com.dtolabs.rundeck.plugins.descriptions.TextArea;
import com.dtolabs.rundeck.plugins.nodes.IModifiableNodeEntry;
import com.dtolabs.rundeck.plugins.nodes.NodeEnhancerPlugin;

Expand Down Expand Up @@ -34,10 +37,12 @@ public class AttributeNodeEnhancer
+ "Where `key` is the attribute name, "
+ "`operator` is one of:\n\n"
+ "* `==` equality match\n"
+ "* `!!` not present match\n"
+ "* `~~` is present match (no value)\n"
+ "* `!!` not present match (no value)\n"
+ "* `=~` regular expression match\n"
+ "* `!=` inequality match\n"
+ "* `!~` negative regular expression match\n",
+ "* `!~` negative regular expression match\n\n"
+ "`value` is optional for some operators.\n",
required = true)
@TextArea
private String match;
Expand All @@ -55,59 +60,92 @@ public class AttributeNodeEnhancer

private String addTags;

@PluginProperty(title = "Enable Attribute Substitution",
description = "If enabled, added tags and attribute values can use `${attribute}` to substitute existing node attribute values. E.g. `tag1,image-${ec2.imageId}` or `newattr=some-${oldattr}/${otherattr}`")
private boolean enableSubstitution;


Map<String, String> loadedProps;
Set<String> loadedTags;
static final Pattern ComparisonPattern = Pattern.compile(
"^(?<key>.+?)(?<op>==|!=|=~|!~|!!)(?<val>.*)$"
"^(?<key>.+?)(?<op>==|!=|=~|!~|!!|~~)(?<val>.*)$"
);

@Override
public void updateNode(
final String project, final IModifiableNodeEntry node
)
{
if (!matchesAll(node.getAttributes())) {
if (!matchesAll(node.getAttributes(), match)) {
return;
}
addAll(node.getAttributes());
addAllTags(node.getTags());
addAll(node.getAttributes(), add, enableSubstitution);
addAllTags(node.getTags(), node.getAttributes(), addTags, enableSubstitution);
}

private void addAllTags(final Set tags) {
if (null == loadedTags) {
loadedTags = new HashSet<>();
if (addTags != null && !"".equals(addTags.trim())) {
loadedTags.addAll(Arrays.asList(addTags.split(",\\s*")));
public static void addAllTags(final Set<String> tags, Map<String, String> attributes, String newTags, boolean enableSubstitution) {
Set<String> loadedTags = new HashSet<>();
if (newTags != null && !newTags.trim().isEmpty()) {
String[] list = newTags.split("\\s*,\\s*");
for (String tag : list) {
if (!tag.trim().isEmpty()) {
//substitute any existing attribute values
loadedTags.add(
enableSubstitution ? substitute(attributes, tag)
: tag
);
}
}
}
tags.addAll(loadedTags);
}


private void addAll(final Map<String, String> attributes) {
if (null == loadedProps) {
Map<String, String> map = new HashMap<>();
if (add != null && !"".equals(add.trim())) {
try {
Properties props = new Properties();
props.load(new StringReader(add));
for (String stringPropertyName : props.stringPropertyNames()) {
map.put(stringPropertyName, props.getProperty(stringPropertyName));
}
} catch (IOException e) {
throw new IllegalArgumentException();
static final Pattern PROP_REF_PATTERN = Pattern.compile("\\$\\{(?<name>[^}]+)}");

public static void addAll(final Map<String, String> attributes, String propString, boolean enableSubstitution) {
Map<String, String> map = new HashMap<>();
if (propString != null && !propString.trim().isEmpty()) {
try {
Properties props = new Properties();
props.load(new StringReader(propString));
for (String stringPropertyName : props.stringPropertyNames()) {
String value = props.getProperty(stringPropertyName);
map.put(
stringPropertyName,
enableSubstitution ? substitute(attributes, value) :
value
);
}
} catch (IOException e) {
throw new IllegalArgumentException("Unable to parse properties", e);
}
loadedProps = map;
}
attributes.putAll(loadedProps);

attributes.putAll(map);
}

/**
* Substitutes any references in the value with the values from the attributes map.
*
* @param attributes attributes
* @param value value string
* @return new string
*/
private static String substitute(Map<String, String> attributes, String value) {
Matcher matcher = PROP_REF_PATTERN.matcher(value);
StringBuilder sb = new StringBuilder();
while (matcher.find()) {
String name = matcher.group("name");
String replacement = attributes.get(name);
matcher.appendReplacement(sb, Objects.requireNonNullElse(replacement, ""));
Copy link

Copilot AI Apr 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use Matcher.quoteReplacement on the replacement string to ensure that any special characters are escaped properly. For example, replace with matcher.appendReplacement(sb, Matcher.quoteReplacement(Objects.requireNonNullElse(replacement, ""))).

Suggested change
matcher.appendReplacement(sb, Objects.requireNonNullElse(replacement, ""));
matcher.appendReplacement(sb, Matcher.quoteReplacement(Objects.requireNonNullElse(replacement, "")));

Copilot uses AI. Check for mistakes.
}
matcher.appendTail(sb);
return sb.toString();
}

private boolean matchesAll(final Map<String, String> attributes) {
public static boolean matchesAll(final Map<String, String> attributes, String matches) {
boolean regexMatch = true;
Map<String, Predicate<String>> comparisons = new HashMap<>();
for (final String s : match.split("\r?\n")) {
for (final String s : matches.split("\r?\n")) {
Matcher matcher = ComparisonPattern.matcher(s);
if (matcher.matches()) {
String key = matcher.group("key");
Expand All @@ -132,13 +170,15 @@ private boolean matchesAll(final Map<String, String> attributes) {
return true;
}

private Predicate<String> makePredicate(final String op, final String val) {
static Predicate<String> makePredicate(final String op, final String val) {
if ("==".equals(op)) {
return val::equals;
} else if ("!=".equals(op)) {
return makePredicate("==", val).negate();
} else if ("!!".equals(op)) {
return Objects::isNull;
} else if ("~~".equals(op)) {
return Predicate.not(Objects::isNull);
} else if ("=~".equals(op)) {
Pattern p = Pattern.compile(val);
return (s) -> s != null && p.matcher(s).matches();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,60 @@ class AttributeNodeEnhancerSpec extends Specification{

}

def "test attribute value substitution"() {

given:
def plugin = new AttributeNodeEnhancer()
plugin.match = "attb1==value1\nattb2~~"
plugin.add = 'attb2=a-${attb1}-b-${attb2}'
plugin.enableSubstitution = enabled


def node = new ModifiableNodeEntry("test1")
node.attributes = [attb1: "value1", attb2: "value2"]

def project = "TestProject"
when:

plugin.updateNode(project, node)

then:
node.attributes.attb2 == result

where:
enabled | result
true | "a-value1-b-value2"
false | 'a-${attb1}-b-${attb2}'
}

def "test tag substitution"() {

given:
def plugin = new AttributeNodeEnhancer()
plugin.match = "attb1==value1\nattb2~~"
plugin.addTags = 'tag1,a-${attb1}-b-${attb2}'
plugin.enableSubstitution = enabled


def node = new ModifiableNodeEntry("test1")
node.attributes = [attb1: "value1", attb2: "value2"]
node.tags = new HashSet<>(["tag0"])

def project = "TestProject"
when:

plugin.updateNode(project, node)

then:
node.tags == ["tag0", "tag1", result].toSet()

where:
enabled | result
true | "a-value1-b-value2"
false | 'a-${attb1}-b-${attb2}'

}

def "test multiples regex match"(){

given:
Expand Down Expand Up @@ -75,6 +129,35 @@ class AttributeNodeEnhancerSpec extends Specification{
"attb1==value1\nattb2=value2" | null
}

def "test present and not present match"() {

given:
def plugin = new AttributeNodeEnhancer()
plugin.match = match
plugin.add = "result=valueResult"


def node = new ModifiableNodeEntry("test1")
node.attributes = [attb1: "value1", attb2: "value2"]

def project = "TestProject"
when:

plugin.updateNode(project, node)

then:
node.attributes.result == result

where:
match | result
"attb1~~" | "valueResult"
"attb1!!" | null
"attb2~~" | "valueResult"
"attb2!!" | null
"attb3~~" | null
"attb3!!" | "valueResult"
}


class ModifiableNodeEntry extends NodeEntryImpl implements IModifiableNodeEntry{

Expand Down