From 17ec38634fd0ab29d394cb84800dfea66b9d2ce2 Mon Sep 17 00:00:00 2001 From: somiljain2006 Date: Wed, 8 Apr 2026 00:07:29 +0530 Subject: [PATCH 1/2] Store and download last applied configuration snapshot --- .../plugins/casc/ConfigurationAsCode.java | 55 ++++++- .../casc/ConfigurationAsCode/index.jelly | 10 ++ .../ConfigurationAsCodeLastAppliedTest.java | 148 ++++++++++++++++++ 3 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 plugin/src/test/java/io/jenkins/plugins/casc/ConfigurationAsCodeLastAppliedTest.java diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java b/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java index a6197eee08..5b2aee315c 100644 --- a/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java +++ b/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java @@ -53,6 +53,7 @@ import java.nio.file.Path; import java.nio.file.PathMatcher; import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -110,6 +111,7 @@ public class ConfigurationAsCode extends ManagementLink { public static final String CASC_JENKINS_CONFIG_ENV = "CASC_JENKINS_CONFIG"; public static final String DEFAULT_JENKINS_YAML_PATH = "jenkins.yaml"; public static final String YAML_FILES_PATTERN = "glob:**.{yml,yaml,YAML,YML}"; + public static final String LAST_APPLIED_CONFIG_FILE = "casc_last_applied.yaml"; private static final Logger LOGGER = Logger.getLogger(ConfigurationAsCode.class.getName()); @@ -726,7 +728,6 @@ public void configure(Collection configParameters) throws ConfiguratorEx } sources = Collections.unmodifiableList(new ArrayList<>(configParameters)); configureWith(configs); - lastTimeLoaded = System.currentTimeMillis(); } public static boolean isSupportedURI(String configurationParameter) { @@ -754,7 +755,6 @@ public void configureWith(YamlSource source) throws ConfiguratorException { } private void configureWith(List sources) throws ConfiguratorException { - lastTimeLoaded = System.currentTimeMillis(); ConfigurationContext context = new ConfigurationContext(registry); configureWith(YamlUtils.loadFrom(sources, context), context); } @@ -886,6 +886,12 @@ private void configureWith(Mapping entries, ConfigurationContext context) throws try (ACLContext acl = ACL.as2(ACL.SYSTEM2)) { invokeWith(entries, (configurator, config) -> configurator.configure(config, context)); } + lastTimeLoaded = System.currentTimeMillis(); + try { + saveLastAppliedConfiguration(); + } catch (IOException e) { + LOGGER.log(Level.WARNING, "Failed to save last applied configuration", e); + } } public Map checkWith(Mapping entries, ConfigurationContext context) throws ConfiguratorException { @@ -1028,4 +1034,49 @@ public void doConfigure(StaplerRequest2 req, StaplerResponse2 res) throws Except res.getWriter().print(errors); } } + + private void saveLastAppliedConfiguration() throws IOException { + Path targetFile = Paths.get(Jenkins.get().getRootDir().getPath(), LAST_APPLIED_CONFIG_FILE); + Path tempFile = Files.createTempFile(Jenkins.get().getRootDir().toPath(), "casc-last-applied", ".tmp"); + + try { + try (OutputStream out = Files.newOutputStream(tempFile)) { + export(out); + } + + Files.move(tempFile, targetFile, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + + LOGGER.log(Level.FINE, "Saved last applied configuration to {0}", targetFile); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to save last applied configuration", e); + try { + Files.deleteIfExists(tempFile); + } catch (IOException ignored) { + } + } + } + + public boolean isLastAppliedConfigurationAvailable() { + Path file = Paths.get(Jenkins.get().getRootDir().getPath(), LAST_APPLIED_CONFIG_FILE); + return Files.exists(file); + } + + @RequirePOST + @Restricted(NoExternalUse.class) + public void doDownloadLastAppliedConfiguration(StaplerRequest2 req, StaplerResponse2 res) throws Exception { + if (!Jenkins.get().hasPermission(Jenkins.SYSTEM_READ)) { + res.sendError(HttpServletResponse.SC_FORBIDDEN, "Requires ADMINISTER permission"); + return; + } + + Path file = Paths.get(Jenkins.get().getRootDir().getPath(), LAST_APPLIED_CONFIG_FILE); + if (!Files.exists(file)) { + res.sendError(HttpServletResponse.SC_NOT_FOUND, "No last applied configuration found."); + return; + } + + res.setContentType("application/x-yaml; charset=utf-8"); + res.addHeader("Content-Disposition", "attachment; filename=" + LAST_APPLIED_CONFIG_FILE); + Files.copy(file, res.getOutputStream()); + } } diff --git a/plugin/src/main/resources/io/jenkins/plugins/casc/ConfigurationAsCode/index.jelly b/plugin/src/main/resources/io/jenkins/plugins/casc/ConfigurationAsCode/index.jelly index ad4cedf02a..9cabe6e391 100644 --- a/plugin/src/main/resources/io/jenkins/plugins/casc/ConfigurationAsCode/index.jelly +++ b/plugin/src/main/resources/io/jenkins/plugins/casc/ConfigurationAsCode/index.jelly @@ -32,6 +32,16 @@ + + + + + + + + diff --git a/plugin/src/test/java/io/jenkins/plugins/casc/ConfigurationAsCodeLastAppliedTest.java b/plugin/src/test/java/io/jenkins/plugins/casc/ConfigurationAsCodeLastAppliedTest.java new file mode 100644 index 0000000000..62cd56112e --- /dev/null +++ b/plugin/src/test/java/io/jenkins/plugins/casc/ConfigurationAsCodeLastAppliedTest.java @@ -0,0 +1,148 @@ +package io.jenkins.plugins.casc; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import jenkins.model.Jenkins; +import org.htmlunit.FailingHttpStatusCodeException; +import org.htmlunit.HttpMethod; +import org.htmlunit.Page; +import org.htmlunit.WebRequest; +import org.htmlunit.WebResponse; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.MockAuthorizationStrategy; + +public class ConfigurationAsCodeLastAppliedTest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + private static final String ENDPOINT = "configuration-as-code/configure"; + private static final String DOWNLOAD_ENDPOINT = "configuration-as-code/downloadLastAppliedConfiguration"; + private static final String YAML_CONTENT_TYPE = "application/yaml"; + private static final String ADMIN = "admin"; + + private WebRequest webRequest(String endpoint) throws Exception { + return new WebRequest(new URL(j.getURL(), endpoint), HttpMethod.POST); + } + + private WebRequest yamlPost(String requestBody) throws Exception { + WebRequest request = webRequest(ENDPOINT); + request.setAdditionalHeader("Content-Type", YAML_CONTENT_TYPE); + if (requestBody != null) { + request.setRequestBody(requestBody); + } + return request; + } + + private WebRequest post() throws Exception { + return webRequest(ConfigurationAsCodeLastAppliedTest.DOWNLOAD_ENDPOINT); + } + + private void configureAdminSecurity() { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy() + .grant(Jenkins.ADMINISTER, Jenkins.SYSTEM_READ) + .everywhere() + .to(ADMIN)); + j.jenkins.setCrumbIssuer(null); + } + + private Path lastAppliedFile() { + return j.jenkins.getRootDir().toPath().resolve(ConfigurationAsCode.LAST_APPLIED_CONFIG_FILE); + } + + @Test + public void testSuccessfulApplySavesFile() throws Exception { + configureAdminSecurity(); + String validYaml = "jenkins:\n systemMessage: 'Valid Message'\n"; + + try (JenkinsRule.WebClient wc = j.createWebClient()) { + wc.login(ADMIN); + wc.getPage(yamlPost(validYaml)); + + assertTrue("Last applied config file should exist", Files.exists(lastAppliedFile())); + assertTrue("UI helper should return true", ConfigurationAsCode.get().isLastAppliedConfigurationAvailable()); + + String content = Files.readString(lastAppliedFile()); + assertTrue( + "File should contain the applied configuration", + content.contains("systemMessage: \"Valid Message\"")); + } + } + + @Test + public void testFailedApplyDoesNotOverwriteFile() throws Exception { + configureAdminSecurity(); + String validYaml = "jenkins:\n systemMessage: 'Good State'\n"; + + try (JenkinsRule.WebClient wc = j.createWebClient()) { + wc.login(ADMIN); + wc.getPage(yamlPost(validYaml)); + assertTrue(Files.exists(lastAppliedFile())); + + String invalidYaml = "unsupported_root_element:\n foo: 'bar'\n"; + try { + wc.getPage(yamlPost(invalidYaml)); + fail("Expected a 400 Bad Request exception"); + } catch (FailingHttpStatusCodeException e) { + assertEquals(400, e.getStatusCode()); + } + } + + String content = Files.readString(lastAppliedFile()); + assertTrue("Should retain previous good state", content.contains("systemMessage: \"Good State\"")); + assertFalse("Should not contain invalid state", content.contains("unsupported_root_element")); + } + + @Test + public void testDownloadEndpointWorksForAdmin() throws Exception { + configureAdminSecurity(); + + try (JenkinsRule.WebClient wc = j.createWebClient()) { + wc.login(ADMIN); + wc.getPage(yamlPost("jenkins:\n systemMessage: 'Download Me'\n")); + + Page page = wc.getPage(post()); + WebResponse response = page.getWebResponse(); + + assertEquals(200, response.getStatusCode()); + assertEquals("application/x-yaml", response.getContentType()); + assertTrue(response.getResponseHeaderValue("Content-Disposition") + .contains(ConfigurationAsCode.LAST_APPLIED_CONFIG_FILE)); + assertTrue(response.getContentAsString().contains("systemMessage: \"Download Me\"")); + } + } + + @Test + public void testDownloadEndpointRequiresPermissions() throws Exception { + configureAdminSecurity(); + + try (JenkinsRule.WebClient adminWc = j.createWebClient(); + JenkinsRule.WebClient anonWc = j.createWebClient()) { + + adminWc.login(ADMIN); + adminWc.getPage(yamlPost("jenkins:\n systemMessage: 'Secret'\n")); + + try { + anonWc.getPage(post()); + fail("Expected a 403 Forbidden exception for anonymous user"); + } catch (FailingHttpStatusCodeException e) { + assertEquals("Should reject unauthorized access", 403, e.getStatusCode()); + } + } + } + + @Before + public void cleanup() throws Exception { + Files.deleteIfExists(lastAppliedFile()); + } +} From 9ed22699188f885cfbef67bf5b45f1f5aeae928e Mon Sep 17 00:00:00 2001 From: somiljain2006 Date: Wed, 8 Apr 2026 00:47:48 +0530 Subject: [PATCH 2/2] Added annotation --- .../main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java | 1 + 1 file changed, 1 insertion(+) diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java b/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java index 5b2aee315c..9341d7eb2f 100644 --- a/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java +++ b/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java @@ -1063,6 +1063,7 @@ public boolean isLastAppliedConfigurationAvailable() { @RequirePOST @Restricted(NoExternalUse.class) + @SuppressWarnings("unused") public void doDownloadLastAppliedConfiguration(StaplerRequest2 req, StaplerResponse2 res) throws Exception { if (!Jenkins.get().hasPermission(Jenkins.SYSTEM_READ)) { res.sendError(HttpServletResponse.SC_FORBIDDEN, "Requires ADMINISTER permission");