Skip to content

Detect duplicate entries in single-valued Describables #2659

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
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
Expand Up @@ -11,6 +11,7 @@
import io.jenkins.plugins.casc.impl.attributes.PersistedListAttribute;
import io.jenkins.plugins.casc.model.CNode;
import io.jenkins.plugins.casc.model.Mapping;
import io.jenkins.plugins.casc.model.Scalar;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.GenericArrayType;
Expand Down Expand Up @@ -348,6 +349,29 @@ protected void configure(Mapping config, T instance, boolean dryrun, Configurati
final Class k = attribute.getType();
final Configurator configurator = context.lookupOrFail(k);

// Check for duplicate entries in Describables
if (attribute instanceof DescribableAttribute && !attribute.isMultiple() && sub instanceof Mapping) {
Mapping mapping = sub.asMapping();
// Count the number of entries that might be conflicting configurations
int configurableEntries = 0;
for (String key : mapping.keySet()) {
CNode value = mapping.get(key);
if (value != null && !(value instanceof Scalar && (((Scalar) value).getValue() == null || ((Scalar) value).toString().isEmpty()))) {
configurableEntries++;
}
}

if (configurableEntries > 1) {
String message = String.format("Multiple configurations found for single-valued Describable '%s'. Conflicting entries: %s. Only one can be used.",
attribute.getName(), String.join(", ", mapping.keySet()));
context.warning(sub, message);
if (context.getUnknown() == ConfigurationContext.Unknown.reject) {
throw new ConfiguratorException(this, message);
}
LOGGER.warning(message);
}
}

final Object valueToSet;
if (attribute.isMultiple()) {
List<Object> values = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package io.jenkins.plugins.casc;

import hudson.ExtensionPoint;
import hudson.ExtensionList;
import hudson.model.AbstractDescribableImpl;
import hudson.model.Descriptor;
import io.jenkins.plugins.casc.misc.ConfiguredWithCode;
import io.jenkins.plugins.casc.misc.JenkinsConfiguredWithCodeRule;
import io.jenkins.plugins.casc.model.CNode;
import io.jenkins.plugins.casc.model.Mapping;
import jenkins.model.Jenkins;
import org.junit.Rule;
import org.junit.Test;
import org.jvnet.hudson.test.TestExtension;

import java.util.logging.Level;
import java.util.logging.Logger;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertThrows;

public class DuplicateEntriesDescribableTest {

private static final Logger LOGGER = Logger.getLogger(DuplicateEntriesDescribableTest.class.getName());

@Rule
public JenkinsConfiguredWithCodeRule j = new JenkinsConfiguredWithCodeRule();

@Test
@ConfiguredWithCode("/io/jenkins/plugins/casc/duplicate-entries-describable.yaml")
public void shouldWarnOnDuplicateEntries() throws Exception {
// This test verifies that we get a warning for duplicate entries in a single-valued Describable
LOGGER.info("Looking up TestComponent extensions");
ExtensionList<TestComponent> components = j.jenkins.getExtensionList(TestComponent.class);
LOGGER.info("Found " + components.size() + " TestComponent extensions");

// If no components found, the test will fail with a clear error message
if (components.isEmpty()) {
throw new AssertionError("No TestComponent extensions found. Test setup may be incorrect.");
}

TestComponent component = components.get(0);

// We should still get a configuration, but a warning should be logged
// Since we can't easily check logged warnings in tests, we at least verify
// that the component was configured with one of the options
TestServerConfig config = component.getServerConfig();
LOGGER.info("Server config: " + (config != null ? config.toString() : "null"));

// Make sure we got a configuration and it's not null
if (config == null) {
throw new AssertionError("Server configuration is null. The configuration was not applied correctly.");
}

assertThat(config.toString(), containsString("TestServerConfig"));
}

@Test
public void shouldRejectDuplicateEntriesInStrictMode() throws Exception {
// Set the unknown handling to reject mode
ConfigurationContext context = new ConfigurationContext(j.jenkins);
context.setUnknown(ConfigurationContext.Unknown.reject);

// Create a mapping with duplicate entries
Mapping serverConfigMapping = new Mapping();
serverConfigMapping.put("manual", Mapping.EMPTY);
serverConfigMapping.put("wellKnown", Mapping.EMPTY);

// Component mapping needs to match the YAML structure
Mapping componentMapping = new Mapping();
componentMapping.put("serverConfiguration", serverConfigMapping);

// Create the full mapping structure with unclassified section
Mapping fullMapping = new Mapping();
Mapping unclassified = new Mapping();
unclassified.put("duplicateEntriesDescribableTest$testComponent", componentMapping);
fullMapping.put("unclassified", unclassified);

// This should throw an exception
Configurator<TestComponent> configurator =
context.lookupOrFail(TestComponent.class);

ConfiguratorException exception = assertThrows(
ConfiguratorException.class,
() -> ConfigurationAsCode.get().configureWith(fullMapping)
);

assertThat(exception.getMessage(),
containsString("Multiple configurations found for single-valued Describable"));
}

// Test component with a single-valued Describable
@TestExtension
public static class TestComponent implements ExtensionPoint {
private TestServerConfig serverConfig;

public static TestComponent get() {
return ExtensionList.lookup(TestComponent.class).get(0);
}

public TestServerConfig getServerConfiguration() {
return serverConfig;
}

public void setServerConfiguration(TestServerConfig serverConfig) {
this.serverConfig = serverConfig;
}

public TestServerConfig getServerConfig() {
return serverConfig;
}
}

@TestExtension
public static class TestComponentDescriptor extends Descriptor<TestComponent> {
}

// Base class for server configuration
public static abstract class TestServerConfig extends AbstractDescribableImpl<TestServerConfig> {
}

// Implementation for manual configuration
public static class ManualTestServerConfig extends TestServerConfig {
@Override
public String toString() {
return "ManualTestServerConfig";
}
}

// Descriptor for manual configuration
@TestExtension
public static class ManualTestServerConfigDescriptor extends Descriptor<TestServerConfig> {
@Override
public String getDisplayName() {
return "Manual Configuration";
}
}

// Implementation for well-known configuration
public static class WellKnownTestServerConfig extends TestServerConfig {
@Override
public String toString() {
return "WellKnownTestServerConfig";
}
}

// Descriptor for well-known configuration
@TestExtension
public static class WellKnownTestServerConfigDescriptor extends Descriptor<TestServerConfig> {
@Override
public String getDisplayName() {
return "Well-Known Configuration";
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
unclassified:
duplicateEntriesDescribableTest$testComponent:
serverConfiguration:
# Two conflicting entries for a single-valued Describable
manual: {}
wellKnown: {}
Loading