diff --git a/demos/jobs/README.md b/demos/jobs/README.md index b21d06b5fb..3ca642f3b2 100644 --- a/demos/jobs/README.md +++ b/demos/jobs/README.md @@ -64,3 +64,26 @@ items: defaultValue: "dev" description: "Target environment" ``` + +## Item removal strategy + +The `items` root element supports an optional `actionOnUndeclaredItems` property to +control how items that are not present in the configuration are handled. + +The following strategies are supported: + +| Strategy | Description | +|--------------| --- | +| `keep` | Do not remove items that are not present in the configuration. This is the default. | +| `delete-tracked` | Remove items that are not present in the configuration if they were previously managed by JCasC. | +| `delete-all` | Remove all items that are not present in the configuration. | + +For example, to synchronize JCasC-managed items: + +```yaml +items: + actionOnUndeclaredItems: delete-tracked + items: + - freestyle: + name: my-freestyle-job +``` diff --git a/integrations/src/test/java/io/jenkins/plugins/casc/core/FreestyleDemoTest.java b/integrations/src/test/java/io/jenkins/plugins/casc/core/FreestyleDemoTest.java index 317e33f7cd..da53da7e26 100644 --- a/integrations/src/test/java/io/jenkins/plugins/casc/core/FreestyleDemoTest.java +++ b/integrations/src/test/java/io/jenkins/plugins/casc/core/FreestyleDemoTest.java @@ -24,7 +24,7 @@ public class FreestyleDemoTest { public JenkinsConfiguredWithReadmeRule j = new JenkinsConfiguredWithReadmeRule(); @Test - @ConfiguredWithReadme("jobs/README.md") + @ConfiguredWithReadme("jobs/README.md#0") public void shouldConfigureFreestyleJobFromReadme() { FreeStyleProject job = (FreeStyleProject) Jenkins.get().getItem("my-freestyle-full-job"); @@ -77,4 +77,13 @@ public void shouldConfigureFreestyleJobFromReadme() { assertEquals("DEPLOY_ENV", stringParam.getName()); assertEquals("dev", stringParam.getDefaultValue()); } + + @Test + @ConfiguredWithReadme("jobs/README.md#1") + public void shouldConfigureFreestyleJobWithSyncStrategyFromReadme() { + FreeStyleProject job = (FreeStyleProject) Jenkins.get().getItem("my-freestyle-job"); + + assertNotNull("Freestyle job with sync strategy should have been created by JCasC from README", job); + assertEquals("my-freestyle-job", job.getName()); + } } diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/SchemaGeneration.java b/plugin/src/main/java/io/jenkins/plugins/casc/SchemaGeneration.java index 6ab71bd7f5..88e45f5df7 100644 --- a/plugin/src/main/java/io/jenkins/plugins/casc/SchemaGeneration.java +++ b/plugin/src/main/java/io/jenkins/plugins/casc/SchemaGeneration.java @@ -264,16 +264,55 @@ private static void generateMultipleAttributeSchema( } itemsSchema.put("type", "string").put("enum", new JSONArray(values)); } else { - JSONObject properties = new JSONObject(); - Configurator lookup = context.lookup(attribute.getType()); + @SuppressWarnings("rawtypes") + Configurator lookup = context.lookup(attribute.getType()); + if (lookup != null) { - lookup.getAttributes() - .forEach(attr -> properties.put( - attr.getName(), - generateNonEnumAttributeObject(attr, baseConfigurator, context, definitions))); + @SuppressWarnings({"rawtypes", "unchecked"}) + List implementors = lookup.getConfigurators(context); + + if (implementors.size() > 1) { + JSONArray oneOfJsonArray = new JSONArray(); + JSONObject propertiesObject = new JSONObject(); + + for (Object obj : implementors) { + @SuppressWarnings("rawtypes") + Configurator impl = (Configurator) obj; + String name = impl.getName(); + Class targetClass = impl.getTarget(); + propertiesObject.put( + name, new JSONObject().put("$ref", "#/definitions/" + targetClass.getName())); + oneOfJsonArray.put(new JSONObject().put("required", new JSONArray().put(name))); + ensureDefinitionExists(targetClass, context, definitions); + } + itemsSchema + .put("type", "object") + .put("additionalProperties", false) + .put("properties", propertiesObject) + .put("minProperties", 1) + .put("maxProperties", 1) + .put("oneOf", oneOfJsonArray); + } else if (lookup instanceof HeteroDescribableConfigurator) { + itemsSchema = generateHeteroDescribableConfigObject( + (HeteroDescribableConfigurator) lookup, context, definitions); + } else { + JSONObject properties = new JSONObject(); + for (Object attr : lookup.getAttributes()) { + Attribute a = (Attribute) attr; + properties.put( + a.getName(), generateNonEnumAttributeObject(a, baseConfigurator, context, definitions)); + } + itemsSchema + .put("type", "object") + .put("properties", properties) + .put("additionalProperties", false); + } + } else { + itemsSchema + .put("type", "object") + .put("properties", new JSONObject()) + .put("additionalProperties", false); } - - itemsSchema.put("type", "object").put("properties", properties).put("additionalProperties", false); } JSONObject attributeObject = new JSONObject().put("type", "array").put("items", itemsSchema); diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/core/ItemRemoveStrategy.java b/plugin/src/main/java/io/jenkins/plugins/casc/core/ItemRemoveStrategy.java new file mode 100644 index 0000000000..c0032d13dd --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/casc/core/ItemRemoveStrategy.java @@ -0,0 +1,47 @@ +package io.jenkins.plugins.casc.core; + +import org.apache.commons.lang3.StringUtils; + +public enum ItemRemoveStrategy { + + /** + * Do not remove any items that are not present in the configuration. + */ + KEEP("keep"), + + /** + * Remove items that are not present in the configuration if they were + * previously managed by Configuration as Code. + */ + DELETE_TRACKED("delete-tracked"), + + /** + * Remove all items that are not present in the configuration, + * regardless of whether they were previously managed by Configuration as Code. + */ + DELETE_ALL("delete-all"); + + private final String value; + + ItemRemoveStrategy(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + // In ItemRemoveStrategy.java + public static ItemRemoveStrategy fromString(String strategy) { + if (StringUtils.isBlank(strategy)) { + return KEEP; + } + String cleanStrategy = strategy.trim(); + for (ItemRemoveStrategy s : values()) { + if (s.value.equalsIgnoreCase(cleanStrategy)) { + return s; + } + } + throw new IllegalArgumentException("Invalid actionOnUndeclaredItems: " + strategy); + } +} diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/core/ItemsRootConfigurator.java b/plugin/src/main/java/io/jenkins/plugins/casc/core/ItemsRootConfigurator.java index 8c42f8cfb9..7808428e87 100644 --- a/plugin/src/main/java/io/jenkins/plugins/casc/core/ItemsRootConfigurator.java +++ b/plugin/src/main/java/io/jenkins/plugins/casc/core/ItemsRootConfigurator.java @@ -1,24 +1,42 @@ package io.jenkins.plugins.casc.core; +import static io.jenkins.plugins.casc.Attribute.noop; +import static io.jenkins.plugins.casc.core.ItemRemoveStrategy.KEEP; +import static io.jenkins.plugins.casc.core.ItemRemoveStrategy.fromString; +import static java.lang.Thread.currentThread; +import static java.util.Collections.unmodifiableSet; +import static java.util.logging.Level.INFO; +import static java.util.logging.Level.WARNING; + import edu.umd.cs.findbugs.annotations.NonNull; import hudson.Extension; import hudson.ExtensionList; +import hudson.model.TopLevelItem; import hudson.security.ACL; import hudson.security.ACLContext; import io.jenkins.plugins.casc.Attribute; +import io.jenkins.plugins.casc.BaseConfigurator; import io.jenkins.plugins.casc.ConfigurationContext; import io.jenkins.plugins.casc.ConfiguratorException; import io.jenkins.plugins.casc.ItemConfigurator; import io.jenkins.plugins.casc.RootElementConfigurator; +import io.jenkins.plugins.casc.impl.attributes.MultivaluedAttribute; import io.jenkins.plugins.casc.model.CNode; import io.jenkins.plugins.casc.model.Mapping; -import java.util.Collections; +import io.jenkins.plugins.casc.model.Sequence; +import java.io.File; +import java.io.IOException; +import java.util.HashSet; import java.util.Map.Entry; import java.util.Set; +import java.util.logging.Logger; import jenkins.model.Jenkins; @Extension -public class ItemsRootConfigurator implements RootElementConfigurator { +public class ItemsRootConfigurator extends BaseConfigurator + implements RootElementConfigurator { + + private static final Logger LOGGER = Logger.getLogger(ItemsRootConfigurator.class.getName()); @Override @NonNull @@ -27,60 +45,141 @@ public String getName() { } @Override - public Class getTarget() { - return Jenkins.class; + public Class getTarget() { + return ItemsRootConfigurator.class; } @Override - public Jenkins getTargetComponent(ConfigurationContext context) { - return Jenkins.get(); + public ItemsRootConfigurator getTargetComponent(ConfigurationContext context) { + return this; } @Override @NonNull - public Jenkins configure(CNode config, ConfigurationContext context) throws ConfiguratorException { + public ItemsRootConfigurator configure(CNode config, ConfigurationContext context) throws ConfiguratorException { CNode interpolatedConfig = CNodeInterpolator.interpolate(config, context); doCheck(interpolatedConfig); Jenkins jenkins = Jenkins.get(); + ItemRemoveStrategy actionOnUndeclaredItems = KEEP; + CNode itemsSequence = interpolatedConfig; + + if (interpolatedConfig instanceof Mapping) { + Mapping mapping = interpolatedConfig.asMapping(); + actionOnUndeclaredItems = parseActionOnUndeclaredItems(mapping); + itemsSequence = mapping.containsKey("items") ? mapping.get("items") : new Sequence(); + } + + Set configuredItemNames = new HashSet<>(); + try (ACLContext ignored = ACL.as2(ACL.SYSTEM2)) { - for (CNode itemNode : interpolatedConfig.asSequence()) { + for (CNode itemNode : itemsSequence.asSequence()) { Mapping itemMapping = itemNode.asMapping(); Entry entry = itemMapping.entrySet().iterator().next(); String type = entry.getKey(); Mapping properties = entry.getValue().asMapping(); String name = properties.getScalarValue("name"); + configuredItemNames.add(name); ItemConfigurator configurator = findConfigurator(type); if (configurator == null) { throw new ConfiguratorException("No ItemConfigurator found for type: " + type); } - configurator.configure(name, properties, context); + TopLevelItem configuredItem = configurator.configure(name, properties, context); + + File cascMarker = new File(configuredItem.getRootDir(), ".casc-managed"); + File parentDir = cascMarker.getParentFile(); + + if (!parentDir.exists()) { + throw new ConfiguratorException("Cannot create CasC marker for item '" + name + + "': The item's root directory does not exist. " + + "This indicates the item was not properly saved to disk."); + } + + try { + if (!cascMarker.exists() && !cascMarker.createNewFile()) { + LOGGER.log( + WARNING, "Failed to create CasC marker file (it may already exist) for item: " + name); + } + } catch (IOException e) { + throw new ConfiguratorException("Failed to write CasC marker file for item: " + name, e); + } } - } - return jenkins; + applyRemovalStrategy(jenkins, configuredItemNames, actionOnUndeclaredItems); + } + return this; } - private ItemConfigurator findConfigurator(String type) { - for (ItemConfigurator configurator : ExtensionList.lookup(ItemConfigurator.class)) { - if (configurator.getName().equalsIgnoreCase(type)) { - return configurator; + private void applyRemovalStrategy(Jenkins jenkins, Set configuredItemNames, ItemRemoveStrategy strategy) + throws ConfiguratorException { + if (strategy == KEEP) { + return; + } + + try { + for (TopLevelItem item : jenkins.getItems()) { + if (!configuredItemNames.contains(item.getName())) { + + boolean isCascManaged = new File(item.getRootDir(), ".casc-managed").exists(); + + if (strategy == ItemRemoveStrategy.DELETE_ALL) { + LOGGER.log(INFO, "CasC remove-all strategy: Deleting unconfigured item {0}", item.getName()); + item.delete(); + } else if (strategy == ItemRemoveStrategy.DELETE_TRACKED && isCascManaged) { + LOGGER.log( + INFO, + "CasC sync strategy: Deleting previously managed, now unconfigured item {0}", + item.getName()); + item.delete(); + } + } } + } catch (IOException | IllegalArgumentException e) { + throw new ConfiguratorException("Failed to apply item removal strategy", e); + } catch (InterruptedException e) { + currentThread().interrupt(); + throw new ConfiguratorException("Interrupted while applying item removal strategy", e); } - return null; } @Override - public Jenkins check(CNode config, ConfigurationContext context) throws ConfiguratorException { + public ItemsRootConfigurator check(CNode config, ConfigurationContext context) throws ConfiguratorException { CNode interpolatedConfig = CNodeInterpolator.interpolate(config, context); - return doCheck(interpolatedConfig); + doCheck(interpolatedConfig); + return this; } - private Jenkins doCheck(CNode interpolatedConfig) throws ConfiguratorException { - for (CNode itemNode : interpolatedConfig.asSequence()) { + private void doCheck(CNode interpolatedConfig) throws ConfiguratorException { + CNode itemsSequence = interpolatedConfig; + + if (interpolatedConfig instanceof Mapping) { + Mapping mapping = interpolatedConfig.asMapping(); + + if (!mapping.containsKey("items") && !mapping.containsKey("actionOnUndeclaredItems")) { + throw new ConfiguratorException( + "Invalid items configuration. Expected a sequence of items, or a mapping containing 'items' or 'actionOnUndeclaredItems'."); + } + + for (String key : mapping.keySet()) { + if (!key.equals("items") && !key.equals("actionOnUndeclaredItems")) { + throw new ConfiguratorException("Invalid items configuration. Unsupported key '" + key + + "'. Only 'items' and 'actionOnUndeclaredItems' are allowed."); + } + } + + parseActionOnUndeclaredItems(mapping); + itemsSequence = mapping.containsKey("items") ? mapping.get("items") : new Sequence(); + } + + if (!(itemsSequence instanceof Sequence)) { + throw new ConfiguratorException("Expected a sequence of items, found: " + + itemsSequence.getClass().getSimpleName()); + } + + for (CNode itemNode : itemsSequence.asSequence()) { Mapping itemMapping = itemNode.asMapping(); if (itemMapping.size() != 1) { @@ -95,6 +194,9 @@ private Jenkins doCheck(CNode interpolatedConfig) throws ConfiguratorException { if (nameNode == null) { throw new ConfiguratorException("Item of type '" + type + "' is missing a 'name' attribute."); } + if (nameNode.getType() != CNode.Type.SCALAR) { + throw new ConfiguratorException("Item of type '" + type + "' must have a string 'name' attribute."); + } String name = nameNode.asScalar().getValue(); @@ -108,17 +210,52 @@ private Jenkins doCheck(CNode interpolatedConfig) throws ConfiguratorException { } } - return Jenkins.get(); + Jenkins.get(); + } + + private ItemRemoveStrategy parseActionOnUndeclaredItems(Mapping mapping) throws ConfiguratorException { + if (!mapping.containsKey("actionOnUndeclaredItems")) { + return KEEP; + } + + try { + return fromString(mapping.get("actionOnUndeclaredItems").asScalar().getValue()); + } catch (IllegalArgumentException e) { + throw new ConfiguratorException(e.getMessage()); + } + } + + private ItemConfigurator findConfigurator(String type) { + return ExtensionList.lookup(ItemConfigurator.class).stream() + .filter(c -> c.getName().equalsIgnoreCase(type)) + .findFirst() + .orElse(null); } @Override - public CNode describe(Jenkins instance, ConfigurationContext context) { + protected ItemsRootConfigurator instance(Mapping mapping, ConfigurationContext context) + throws ConfiguratorException { + return this; + } + + @Override + public CNode describe(ItemsRootConfigurator instance, ConfigurationContext context) { return null; } @Override @NonNull - public Set> describe() { - return Collections.emptySet(); + public Set> describe() { + Set> attributes = new HashSet<>(); + + attributes.add(new MultivaluedAttribute("items", TopLevelItem.class) + .getter(target -> Jenkins.get().getItems()) + .setter(noop())); + + attributes.add(new Attribute( + "actionOnUndeclaredItems", ItemRemoveStrategy.class) + .setter(noop())); + + return unmodifiableSet(attributes); } } diff --git a/test-harness/src/test/java/io/jenkins/plugins/casc/SchemaGenerationTest.java b/test-harness/src/test/java/io/jenkins/plugins/casc/SchemaGenerationTest.java index 06378f922a..acf3632395 100644 --- a/test-harness/src/test/java/io/jenkins/plugins/casc/SchemaGenerationTest.java +++ b/test-harness/src/test/java/io/jenkins/plugins/casc/SchemaGenerationTest.java @@ -1,5 +1,6 @@ package io.jenkins.plugins.casc; +import static io.jenkins.plugins.casc.SchemaGeneration.generateSchema; import static io.jenkins.plugins.casc.misc.Util.convertYamlFileToJson; import static io.jenkins.plugins.casc.misc.Util.validateSchema; import static org.hamcrest.MatcherAssert.assertThat; @@ -88,9 +89,38 @@ void arrayAttributesShouldGenerateAsArrays(JenkinsConfiguredWithCodeRule j) { assertEquals("string", items.getString("type"), "agentProtocols items should be of type string"); } + @Test + void itemsRootShouldExposePolymorphicItemSchema(JenkinsConfiguredWithCodeRule j) { + JSONObject schema = generateSchema(); + JSONObject itemsRoot = schema.getJSONObject("properties").getJSONObject("items"); + JSONObject itemsRootProperties = itemsRoot.getJSONObject("properties"); + + assertEquals( + "string", + itemsRootProperties.getJSONObject("actionOnUndeclaredItems").getString("type"), + "actionOnUndeclaredItems should be generated as a string enum"); + assertEquals( + "array", + itemsRootProperties.getJSONObject("items").getString("type"), + "items should be generated as an array"); + + JSONObject itemSchema = itemsRootProperties.getJSONObject("items").getJSONObject("items"); + JSONObject itemProperties = itemSchema.getJSONObject("properties"); + assertNotNull(itemProperties.getJSONObject("freestyle"), "freestyle should be present in item schema"); + assertEquals( + "#/definitions/hudson.model.FreeStyleProject", + itemProperties.getJSONObject("freestyle").getString("$ref"), + "freestyle should reference the FreeStyleProject definition"); + assertNotNull( + itemSchema.getJSONArray("oneOf"), "polymorphic item schema should expose oneOf for item configurators"); + assertNotNull( + schema.getJSONObject("definitions").getJSONObject("hudson.model.FreeStyleProject"), + "FreeStyleProject definition should be generated"); + } + @Test void arrayEnumAttributesShouldGenerateAsEnumArrays(JenkinsConfiguredWithCodeRule j) { - JSONObject schema = SchemaGeneration.generateSchema(); + JSONObject schema = generateSchema(); JSONObject unclassifiedProps = schema.getJSONObject("properties").getJSONObject("unclassified").getJSONObject("properties"); diff --git a/test-harness/src/test/java/io/jenkins/plugins/casc/core/ItemRemoveStrategyTest.java b/test-harness/src/test/java/io/jenkins/plugins/casc/core/ItemRemoveStrategyTest.java new file mode 100644 index 0000000000..024f45a1e5 --- /dev/null +++ b/test-harness/src/test/java/io/jenkins/plugins/casc/core/ItemRemoveStrategyTest.java @@ -0,0 +1,57 @@ +package io.jenkins.plugins.casc.core; + +import static io.jenkins.plugins.casc.core.ItemRemoveStrategy.DELETE_ALL; +import static io.jenkins.plugins.casc.core.ItemRemoveStrategy.DELETE_TRACKED; +import static io.jenkins.plugins.casc.core.ItemRemoveStrategy.KEEP; +import static io.jenkins.plugins.casc.core.ItemRemoveStrategy.fromString; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +public class ItemRemoveStrategyTest { + + @Test + public void testFromStringValidStrategies() { + assertEquals(KEEP, fromString("keep")); + assertEquals(DELETE_TRACKED, fromString("delete-tracked")); + assertEquals(DELETE_ALL, fromString("delete-all")); + } + + @Test + public void testFromStringCaseInsensitive() { + // Change these to use the new string values with mixed casing + assertEquals(DELETE_TRACKED, fromString("Delete-Tracked")); + assertEquals(DELETE_ALL, fromString("DELETE-ALL")); + assertEquals(KEEP, fromString("kEeP")); + } + + @Test + public void testFromStringDefaultsToNoneOnBlankOrNull() { + assertEquals(KEEP, fromString(null)); + assertEquals(KEEP, fromString("")); + assertEquals(KEEP, fromString(" ")); + } + + @Test + public void testFromStringThrowsOnUnknown() { + assertThrows(IllegalArgumentException.class, () -> fromString("unknown-strategy")); + assertThrows(IllegalArgumentException.class, () -> fromString("delete")); + assertThrows(IllegalArgumentException.class, () -> fromString("garbage")); + } + + @Test + public void testGetValue() { + assertEquals("keep", KEEP.getValue()); + assertEquals("delete-tracked", DELETE_TRACKED.getValue()); + assertEquals("delete-all", DELETE_ALL.getValue()); + } + + @Test + public void testEnumImplicitMethods() { + assertEquals(3, ItemRemoveStrategy.values().length); + assertEquals(KEEP, ItemRemoveStrategy.valueOf("KEEP")); + assertEquals(DELETE_TRACKED, ItemRemoveStrategy.valueOf("DELETE_TRACKED")); + assertEquals(DELETE_ALL, ItemRemoveStrategy.valueOf("DELETE_ALL")); + } +} diff --git a/test-harness/src/test/java/io/jenkins/plugins/casc/core/ItemsRootConfiguratorTest.java b/test-harness/src/test/java/io/jenkins/plugins/casc/core/ItemsRootConfiguratorTest.java index 878fd4a9fb..39a5541ad7 100644 --- a/test-harness/src/test/java/io/jenkins/plugins/casc/core/ItemsRootConfiguratorTest.java +++ b/test-harness/src/test/java/io/jenkins/plugins/casc/core/ItemsRootConfiguratorTest.java @@ -1,6 +1,7 @@ package io.jenkins.plugins.casc.core; import static io.jenkins.plugins.casc.ConfigurationAsCode.get; +import static java.util.Collections.emptyList; import static java.util.Collections.emptySet; import static java.util.Objects.requireNonNull; import static org.junit.Assert.assertEquals; @@ -12,7 +13,12 @@ import static org.junit.Assert.fail; import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.model.AbstractItem; import hudson.model.FreeStyleProject; +import hudson.model.ItemGroup; +import hudson.model.Job; +import hudson.model.TopLevelItem; +import hudson.model.TopLevelItemDescriptor; import io.jenkins.plugins.casc.Attribute; import io.jenkins.plugins.casc.ConfigurationContext; import io.jenkins.plugins.casc.ConfiguratorException; @@ -24,7 +30,9 @@ import io.jenkins.plugins.casc.model.Mapping; import io.jenkins.plugins.casc.model.Scalar; import io.jenkins.plugins.casc.model.Sequence; +import java.io.File; import java.io.IOException; +import java.util.Collection; import java.util.Set; import jenkins.model.Jenkins; import org.junit.Rule; @@ -36,6 +44,27 @@ public class ItemsRootConfiguratorTest { @Rule public JenkinsConfiguredWithCodeRule j = new JenkinsConfiguredWithCodeRule(); + private Mapping dummyJob(String name) { + Mapping properties = new Mapping(); + properties.put("name", new Scalar(name)); + Mapping item = new Mapping(); + item.put("dummy", properties); + return item; + } + + private Mapping root(String strategy, String... jobNames) { + Sequence itemsSequence = new Sequence(); + for (String name : jobNames) { + itemsSequence.add(dummyJob(name)); + } + Mapping root = new Mapping(); + if (strategy != null) { + root.put("actionOnUndeclaredItems", new Scalar(strategy)); + } + root.put("items", itemsSequence); + return root; + } + @Test @ConfiguredWithCode("ItemsRootConfiguratorTest.yml") public void shouldDiscoverAndDelegateToItemConfigurator() { @@ -117,6 +146,39 @@ public void shouldFailOnEmptyName() { } } + @Test + public void shouldSyncRemoveManagedNonJobItem() { + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + Mapping properties = new Mapping(); + properties.put("name", new Scalar("my-non-job")); + Mapping item = new Mapping(); + item.put("dummy-non-job", properties); + + Sequence items = new Sequence(); + items.add(item); + + Mapping configRoot = new Mapping(); + configRoot.put("actionOnUndeclaredItems", new Scalar("delete-tracked")); + configRoot.put("items", items); + + configurator.configure(configRoot, context); + + TopLevelItem createdItem = j.jenkins.getItem("my-non-job"); + assertNotNull("Non-job item should be created", createdItem); + assertFalse("Item should explicitly not be an instance of Job", createdItem instanceof Job); + + File markerFile = new File(createdItem.getRootDir(), ".casc-managed"); + assertTrue("CasC marker file should exist in the root dir", markerFile.exists()); + + configurator.configure(root("delete-tracked"), context); + + assertNull( + "Non-job item should be deleted during sync because it was CasC managed", + j.jenkins.getItem("my-non-job")); + } + @TestExtension @SuppressWarnings("unused") public static class DummyItemConfigurator implements ItemConfigurator { @@ -206,16 +268,136 @@ public FreeStyleProject check(CNode config, ConfigurationContext context) throws } } + public static class DummyNonJob extends AbstractItem implements TopLevelItem { + public DummyNonJob(ItemGroup parent, String name) { + super(parent, name); + } + + @Override + public Collection> getAllJobs() { + return emptyList(); + } + + @Override + public TopLevelItemDescriptor getDescriptor() { + return (TopLevelItemDescriptor) Jenkins.get().getDescriptorOrDie(getClass()); + } + } + + @TestExtension + @SuppressWarnings("unused") + public static class DummyNonJobDescriptor extends TopLevelItemDescriptor { + public DummyNonJobDescriptor() { + super(DummyNonJob.class); + } + + @Override + @NonNull + public String getDisplayName() { + return "Dummy Non Job"; + } + + @Override + public TopLevelItem newInstance(ItemGroup parent, String name) { + return new DummyNonJob(parent, name); + } + } + + @TestExtension + @SuppressWarnings("unused") + public static class DummyNonJobConfigurator implements ItemConfigurator { + + @Override + public @NonNull String getName() { + return "dummy-non-job"; + } + + @Override + public Class getTarget() { + return DummyNonJob.class; + } + + @Override + public DummyNonJob configure(String name, CNode config, ConfigurationContext context) + throws ConfiguratorException { + try { + Jenkins jenkins = Jenkins.get(); + TopLevelItem item = jenkins.getItem(name); + if (item == null) { + item = jenkins.createProject(DummyNonJob.class, name); + } + item.save(); + return (DummyNonJob) item; + } catch (IOException e) { + throw new ConfiguratorException("Failed to configure dummy non-job: " + name, e); + } + } + + @Override + public CNode describe(DummyNonJob instance, ConfigurationContext context) { + return null; + } + + @Override + @NonNull + public Set> describe() { + return emptySet(); + } + + @Override + @NonNull + public DummyNonJob configure(CNode config, ConfigurationContext context) { + throw new UnsupportedOperationException("Requires a name to configure"); + } + + @Override + public DummyNonJob check(CNode config, ConfigurationContext context) { + return null; + } + } + + public static class ScopedSystemProperty implements AutoCloseable { + private final String key; + private final String previousValue; + + public ScopedSystemProperty(String key, String value) { + this.key = key; + this.previousValue = System.getProperty(key); + System.setProperty(key, value); + } + + @Override + public void close() { + if (previousValue == null) { + System.clearProperty(key); + } else { + System.setProperty(key, previousValue); + } + } + } + @Test public void shouldReturnTargetComponent() { ItemsRootConfigurator configurator = new ItemsRootConfigurator(); - assertEquals(Jenkins.get(), configurator.getTargetComponent(null)); + assertEquals(configurator, configurator.getTargetComponent(null)); } @Test public void shouldReturnNullOnDescribe() { ItemsRootConfigurator configurator = new ItemsRootConfigurator(); - assertNull(configurator.describe(Jenkins.get(), null)); + assertNull(configurator.describe(configurator, null)); + } + + @Test + public void shouldBeDiscoveredForReferenceDocumentation() { + Collection configurators = get().getConfigurators(); + + assertTrue( + "ItemsRootConfigurator should be included in the reference documentation model", + configurators.stream().anyMatch(ItemsRootConfigurator.class::isInstance)); + assertTrue( + "Freestyle item configurator should be reachable from the items root", + configurators.stream().anyMatch(FreestyleItemConfigurator.class::isInstance)); } @Test @@ -236,18 +418,12 @@ public void shouldCheckValidConfiguration() { ItemsRootConfigurator configurator = new ItemsRootConfigurator(); ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); - Mapping properties = new Mapping(); - properties.put("name", new Scalar("my-check-job")); - - Mapping item = new Mapping(); - item.put("dummy", properties); - Sequence itemsSequence = new Sequence(); - itemsSequence.add(item); + itemsSequence.add(dummyJob("my-check-job")); - Jenkins result = configurator.check(itemsSequence, context); + ItemsRootConfigurator result = configurator.check(itemsSequence, context); - assertNotNull("Check should return a non-null Jenkins instance", result); + assertNotNull("Check should return a non-null ItemsRootConfigurator instance", result); } @Test @@ -257,10 +433,8 @@ public void shouldFailCheckOnUnknownType() { Mapping properties = new Mapping(); properties.put("name", new Scalar("my-check-job")); - Mapping item = new Mapping(); item.put("unknown_type", properties); - Sequence itemsSequence = new Sequence(); itemsSequence.add(item); @@ -271,4 +445,381 @@ public void shouldFailCheckOnUnknownType() { "Message did not match. Got: " + e.getMessage(), e.getMessage().contains("No ItemConfigurator found for type: unknown_type")); } + + @Test + public void shouldNotRemoveUnmanagedItemsWhenStrategyIsNone() throws Exception { + j.createFreeStyleProject("manual-job"); + + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + configurator.configure(root(null, "casc-job"), context); + + assertNotNull("Manual job should be untouched by default/none strategy", j.jenkins.getItem("manual-job")); + assertNotNull("CasC job should be created", j.jenkins.getItem("casc-job")); + } + + @Test + public void shouldRemoveAllUnconfiguredItemsWhenStrategyIsRemoveAll() throws Exception { + j.createFreeStyleProject("manual-job"); + + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + configurator.configure(root("delete-all", "casc-job"), context); + + assertNull("Manual job should be deleted by remove-all strategy", j.jenkins.getItem("manual-job")); + assertNotNull("CasC job should exist", j.jenkins.getItem("casc-job")); + } + + @Test + public void shouldOnlyRemoveCascManagedItemsWhenStrategyIsSync() throws Exception { + j.createFreeStyleProject("manual-job"); + + FreeStyleProject oldCascJob = j.createFreeStyleProject("old-casc-job"); + File markerFile = new File(oldCascJob.getRootDir(), ".casc-managed"); + assertTrue(markerFile.createNewFile()); + + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + configurator.configure(root("delete-tracked", "new-casc-job"), context); + + assertNotNull("Manual job should be untouched by sync strategy", j.jenkins.getItem("manual-job")); + assertNull("Old CasC job should be deleted because it is no longer in YAML", j.jenkins.getItem("old-casc-job")); + assertNotNull("New CasC job should exist", j.jenkins.getItem("new-casc-job")); + + FreeStyleProject newJob = (FreeStyleProject) j.jenkins.getItem("new-casc-job"); + File cascMarker = new File(requireNonNull(newJob).getRootDir(), ".casc-managed"); + assertTrue("New job must have the .casc-managed marker file", cascMarker.exists()); + } + + @Test + public void shouldNotDeleteMultipleUnmanagedJobsWhenStrategyIsSync() throws Exception { + j.createFreeStyleProject("manual-job-1"); + j.createFreeStyleProject("manual-job-2"); + + FreeStyleProject oldCascJob = j.createFreeStyleProject("old-casc-job"); + File markerFile = new File(oldCascJob.getRootDir(), ".casc-managed"); + assertTrue(markerFile.createNewFile()); + + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + configurator.configure(root("delete-tracked", "new-casc-job"), context); + + assertNotNull("manual-job-1 should be untouched", j.jenkins.getItem("manual-job-1")); + assertNotNull("manual-job-2 should be untouched", j.jenkins.getItem("manual-job-2")); + assertNull("old-casc-job should be deleted", j.jenkins.getItem("old-casc-job")); + assertNotNull("new-casc-job should be created", j.jenkins.getItem("new-casc-job")); + } + + @Test + public void shouldKeepAndConfigureManagedJobWhenStillPresent() throws Exception { + FreeStyleProject oldCascJob = j.createFreeStyleProject("old-casc-job"); + File markerFile = new File(oldCascJob.getRootDir(), ".casc-managed"); + assertTrue(markerFile.createNewFile()); + oldCascJob.setDescription("original description"); + oldCascJob.save(); + + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + Mapping properties = new Mapping(); + properties.put("name", new Scalar("old-casc-job")); + properties.put("description", new Scalar("updated description")); + Mapping item = new Mapping(); + item.put("dummy", properties); + Sequence items = new Sequence(); + items.add(item); + + Mapping root = new Mapping(); + root.put("actionOnUndeclaredItems", new Scalar("delete-tracked")); + root.put("items", items); + + configurator.configure(root, context); + + assertNotNull("Managed job should still exist", j.jenkins.getItem("old-casc-job")); + assertEquals( + "Managed job should be updated rather than recreated", + "updated description", + oldCascJob.getDescription()); + } + + @Test + public void shouldHandlePartialRemovalDuringSync() { + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + configurator.configure(root("delete-tracked", "job-A", "job-B"), context); + assertNotNull(j.jenkins.getItem("job-A")); + assertNotNull(j.jenkins.getItem("job-B")); + + configurator.configure(root("delete-tracked", "job-A"), context); + + assertNotNull("Job A should exist", j.jenkins.getItem("job-A")); + assertNull("Job B should be removed via partial sync", j.jenkins.getItem("job-B")); + assertEquals("Total items count should be 1", 1, j.jenkins.getItems().size()); + } + + @Test + public void shouldRemoveAllCascManagedItemsWhenSyncWithEmptyList() throws Exception { + j.createFreeStyleProject("manual-job"); + + FreeStyleProject oldCascJob = j.createFreeStyleProject("old-casc-job"); + File markerFile = new File(oldCascJob.getRootDir(), ".casc-managed"); + assertTrue(markerFile.createNewFile()); + + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + configurator.configure(root("delete-tracked"), context); + + assertNotNull("Manual job should be preserved", j.jenkins.getItem("manual-job")); + assertNull("Old CasC job should be deleted because items list is empty", j.jenkins.getItem("old-casc-job")); + } + + @Test + public void shouldFailOnInvalidActionOnUndeclaredItems() { + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + ConfiguratorException e = + assertThrows(ConfiguratorException.class, () -> configurator.configure(root("abc", "job-A"), context)); + + assertTrue( + "Message did not match. Got: " + e.getMessage(), + e.getMessage().contains("Invalid actionOnUndeclaredItems: abc")); + } + + @Test + public void shouldInterpolateVariablesInItemName() { + try (ScopedSystemProperty ignored = new ScopedSystemProperty("MY_INTERPOLATED_JOB_NAME", "dynamic-job-name")) { + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + configurator.configure(root(null, "${MY_INTERPOLATED_JOB_NAME}"), context); + + assertNotNull("Job should be created using the interpolated name", j.jenkins.getItem("dynamic-job-name")); + } + } + + @Test + public void shouldBeIdempotentWhenApplyingSameConfigTwice() { + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + Sequence itemsSequence = new Sequence(); + itemsSequence.add(dummyJob("idempotent-job")); + + configurator.configure(itemsSequence, context); + int itemCountAfterFirstApply = j.jenkins.getItems().size(); + assertEquals(1, itemCountAfterFirstApply); + assertNotNull(j.jenkins.getItem("idempotent-job")); + + Sequence itemsSequence2 = new Sequence(); + itemsSequence2.add(dummyJob("idempotent-job")); + + configurator.configure(itemsSequence2, context); + int itemCountAfterSecondApply = j.jenkins.getItems().size(); + + assertEquals( + "Applying the same config twice should not duplicate or change item count", + itemCountAfterFirstApply, + itemCountAfterSecondApply); + } + + @Test + public void shouldHandleRepeatedSyncConfigurations() { + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + configurator.configure(root("delete-tracked", "job-A"), context); + assertNotNull("Job A should exist after first sync", j.jenkins.getItem("job-A")); + assertEquals(1, j.jenkins.getItems().size()); + + configurator.configure(root("delete-tracked", "job-B"), context); + + assertNull( + "Job A should be deleted by the second sync because it is missing from YAML", + j.jenkins.getItem("job-A")); + assertNotNull("Job B should be created by the second sync", j.jenkins.getItem("job-B")); + assertEquals("Total items should remain 1", 1, j.jenkins.getItems().size()); + } + + @Test + public void shouldHandleMissingItemsKey() { + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + Mapping root = new Mapping(); + root.put("actionOnUndeclaredItems", new Scalar("delete-tracked")); + + configurator.configure(root, context); + + assertEquals(0, j.jenkins.getItems().size()); + } + + @Test + public void shouldCheckValidMappingConfiguration() { + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + ItemsRootConfigurator result = configurator.check(root("delete-tracked", "job-A"), context); + + assertNotNull(result); + } + + @Test + public void shouldFailOnInvalidItemsMapping() { + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + Mapping mapping = new Mapping(); + mapping.put("foo", new Scalar("bar")); + + ConfiguratorException e = assertThrows(ConfiguratorException.class, () -> configurator.check(mapping, context)); + + assertTrue(e.getMessage().contains("Invalid items configuration")); + } + + @Test + public void shouldFailWhenItemsIsNotSequence() { + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + Mapping root = new Mapping(); + root.put("items", new Mapping()); + + ConfiguratorException e = assertThrows(ConfiguratorException.class, () -> configurator.check(root, context)); + + assertTrue(e.getMessage().contains("Expected a sequence of items")); + } + + @Test + public void shouldNotDeleteConfiguredItemWithRemoveAll() { + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + configurator.configure(root("delete-all", "job-A"), context); + + assertNotNull(j.jenkins.getItem("job-A")); + } + + @Test + public void shouldFailCheckOnInvalidActionOnUndeclaredItems() { + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + Mapping root = new Mapping(); + root.put("actionOnUndeclaredItems", new Scalar("invalid")); + root.put("items", new Sequence()); + + assertThrows(ConfiguratorException.class, () -> configurator.check(root, context)); + } + + @Test + public void shouldFailOnEmptyMapping() { + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + Mapping mapping = new Mapping(); + + assertThrows(ConfiguratorException.class, () -> configurator.check(mapping, context)); + } + + @Test + public void shouldAllowMissingItemsWhenOnlyStrategySpecified() { + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + Mapping root = new Mapping(); + root.put("actionOnUndeclaredItems", new Scalar("keep")); + + configurator.configure(root, context); + + assertEquals(0, j.jenkins.getItems().size()); + } + + @Test + public void shouldRejectUnknownKeyBeforeApplyingRemoveAll() throws IOException { + j.createFreeStyleProject("manual-job"); + + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + Mapping root = new Mapping(); + root.put("actionOnUndeclaredItems", new Scalar("remove-all")); + root.put("itmes", new Sequence()); + + ConfiguratorException e = + assertThrows(ConfiguratorException.class, () -> configurator.configure(root, context)); + + assertTrue(e.getMessage().contains("Unsupported key 'itmes'")); + assertNotNull("Item must not be deleted when configuration validation fails", j.jenkins.getItem("manual-job")); + } + + @Test + public void shouldRejectUnknownKeyDuringSync() throws IOException { + j.createFreeStyleProject("manual-job"); + + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + Mapping root = new Mapping(); + root.put("actionOnUndeclaredItems", new Scalar("sync")); + root.put("itmes", new Sequence()); + + ConfiguratorException e = + assertThrows(ConfiguratorException.class, () -> configurator.configure(root, context)); + + assertTrue(e.getMessage().contains("Unsupported key 'itmes'")); + assertNotNull(j.jenkins.getItem("manual-job")); + } + + @Test + public void shouldRejectUnknownKeyEvenWhenItemsArePresent() { + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + Mapping root = root("sync", "job-A"); + root.put("itmes", new Sequence()); + + ConfiguratorException e = assertThrows(ConfiguratorException.class, () -> configurator.check(root, context)); + + assertTrue(e.getMessage().contains("Unsupported key 'itmes'")); + } + + @Test + public void shouldCreateCascMarkerForJob() { + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + configurator.configure(root("keep", "job-A"), context); + + TopLevelItem item = j.jenkins.getItem("job-A"); + assertNotNull(item); + + File markerFile = new File(item.getRootDir(), ".casc-managed"); + assertTrue("CasC marker should exist for Job", markerFile.exists()); + } + + @Test + public void shouldDeleteItemDirectoryDuringSync() { + ItemsRootConfigurator configurator = new ItemsRootConfigurator(); + ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); + + configurator.configure(root("delete-tracked", "job-A"), context); + + TopLevelItem item = j.jenkins.getItem("job-A"); + assertNotNull(item); + + File rootDir = item.getRootDir(); + assertTrue(rootDir.exists()); + + configurator.configure(root("delete-tracked"), context); + + assertNull(j.jenkins.getItem("job-A")); + assertFalse("Item directory should be deleted", rootDir.exists()); + } }