diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/CasCReloadListener.java b/plugin/src/main/java/io/jenkins/plugins/casc/CasCReloadListener.java new file mode 100644 index 0000000000..ec99530865 --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/casc/CasCReloadListener.java @@ -0,0 +1,27 @@ +package io.jenkins.plugins.casc; + +import hudson.ExtensionList; +import hudson.ExtensionPoint; +import java.util.logging.Level; +import java.util.logging.Logger; + +public interface CasCReloadListener extends ExtensionPoint { + + void onConfigurationReloaded(); + + static void fire() { + Logger logger = Logger.getLogger(CasCReloadListener.class.getName()); + + for (CasCReloadListener listener : ExtensionList.lookup(CasCReloadListener.class)) { + try { + listener.onConfigurationReloaded(); + } catch (Exception e) { + logger.log( + Level.WARNING, + "Listener " + listener.getClass().getName() + + " threw an exception during CasC reload notification", + e); + } + } + } +} 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..ab00d62da1 100644 --- a/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java +++ b/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java @@ -754,9 +754,9 @@ 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); + lastTimeLoaded = System.currentTimeMillis(); } @Restricted(NoExternalUse.class) @@ -886,6 +886,7 @@ private void configureWith(Mapping entries, ConfigurationContext context) throws try (ACLContext acl = ACL.as2(ACL.SYSTEM2)) { invokeWith(entries, (configurator, config) -> configurator.configure(config, context)); } + CasCReloadListener.fire(); } public Map checkWith(Mapping entries, ConfigurationContext context) throws ConfiguratorException { diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/history/CasCHistoryAction.java b/plugin/src/main/java/io/jenkins/plugins/casc/history/CasCHistoryAction.java new file mode 100644 index 0000000000..9350268822 --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/casc/history/CasCHistoryAction.java @@ -0,0 +1,128 @@ +package io.jenkins.plugins.casc.history; + +import hudson.Extension; +import hudson.model.ManagementLink; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import jenkins.model.Jenkins; +import org.kohsuke.stapler.StaplerRequest2; +import org.kohsuke.stapler.StaplerResponse2; +import org.kohsuke.stapler.verb.GET; +import org.springframework.lang.NonNull; +import org.w3c.dom.Document; + +@Extension +public class CasCHistoryAction extends ManagementLink { + + private static final Logger LOGGER = Logger.getLogger(CasCHistoryAction.class.getName()); + + @Override + public String getIconFileName() { + return "symbol-time"; + } + + @Override + public String getDisplayName() { + return "CasC History"; + } + + @Override + public String getUrlName() { + return "casc-history"; + } + + @Override + public String getDescription() { + return "Browse CasC history and snapshots."; + } + + public List getHistoryEntries() { + List entries = new ArrayList<>(); + File baseDir = new File(Jenkins.get().getRootDir(), "casc-history"); + + if (baseDir.exists() && baseDir.isDirectory()) { + File[] historyFolders = baseDir.listFiles(File::isDirectory); + if (historyFolders != null) { + Arrays.sort(historyFolders, Comparator.comparing(File::getName).reversed()); + + for (File folder : historyFolders) { + entries.add(new HistoryEntry(folder.getName(), parseUserFromXml(folder))); + } + } + } + return entries; + } + + private String parseUserFromXml(File folder) { + File xmlFile = new File(folder, "history.xml"); + if (xmlFile.exists()) { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document doc = builder.parse(xmlFile); + + return doc.getElementsByTagName("user").item(0).getTextContent(); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to read history.xml in " + folder.getName(), e); + } + } + return "Unknown User"; + } + + public record HistoryEntry(String timestamp, String user) { + + @SuppressWarnings("unused") + public String getFormattedTimestamp() { + LocalDateTime dt = LocalDateTime.parse(timestamp, DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")); + + return dt.format(DateTimeFormatter.ofPattern("dd MMM yyyy, HH:mm:ss")); + } + } + + @GET + @SuppressWarnings("unused") + public void doView(StaplerRequest2 req, StaplerResponse2 res) throws IOException { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + + String timestamp = req.getParameter("timestamp"); + if (timestamp == null || timestamp.isEmpty()) { + res.sendError(400, "Missing timestamp parameter"); + return; + } + + if (!timestamp.matches("^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2}(_[0-9]+)?$")) { + res.sendError(400, "Invalid timestamp format"); + return; + } + + File baseDir = new File(Jenkins.get().getRootDir(), "casc-history"); + File historyDir = new File(baseDir, timestamp); + File yamlFile = new File(historyDir, "jenkins.yaml"); + + if (!yamlFile.exists()) { + res.sendError(404, "History record not found"); + return; + } + + res.setContentType("text/plain;charset=UTF-8"); + Files.copy(yamlFile.toPath(), res.getOutputStream()); + } + + @Override + @NonNull + public Category getCategory() { + return Category.CONFIGURATION; + } +} diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/history/CasCHistoryBackend.java b/plugin/src/main/java/io/jenkins/plugins/casc/history/CasCHistoryBackend.java new file mode 100644 index 0000000000..3e6be03f16 --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/casc/history/CasCHistoryBackend.java @@ -0,0 +1,9 @@ +package io.jenkins.plugins.casc.history; + +import hudson.ExtensionPoint; +import java.io.IOException; + +public abstract class CasCHistoryBackend implements ExtensionPoint { + + public abstract void save(String yamlContent, String triggeredBy) throws IOException; +} diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/history/CasCHistoryRecorder.java b/plugin/src/main/java/io/jenkins/plugins/casc/history/CasCHistoryRecorder.java new file mode 100644 index 0000000000..a0bfe42c2b --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/casc/history/CasCHistoryRecorder.java @@ -0,0 +1,42 @@ +package io.jenkins.plugins.casc.history; + +import hudson.Extension; +import hudson.ExtensionList; +import io.jenkins.plugins.casc.CasCReloadListener; +import io.jenkins.plugins.casc.ConfigurationAsCode; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.model.Jenkins; +import jenkins.util.Timer; +import org.springframework.security.core.Authentication; + +@Extension +@SuppressWarnings("unused") +public class CasCHistoryRecorder implements CasCReloadListener { + + private static final Logger LOGGER = Logger.getLogger(CasCHistoryRecorder.class.getName()); + + @Override + public void onConfigurationReloaded() { + LOGGER.fine("CasC reload detected. Queuing async capture of current YAML state..."); + + final Authentication auth = Jenkins.getAuthentication2(); + final String triggeredBy = auth.getName(); + + Timer.get().submit(() -> { + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ConfigurationAsCode.get().export(out); + String currentYaml = out.toString(StandardCharsets.UTF_8); + + CasCHistoryBackend backend = ExtensionList.lookupFirst(CasCHistoryBackend.class); + backend.save(currentYaml, triggeredBy); + + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to capture CasC history asynchronously", e); + } + }); + } +} diff --git a/plugin/src/main/java/io/jenkins/plugins/casc/history/LocalFileHistoryBackend.java b/plugin/src/main/java/io/jenkins/plugins/casc/history/LocalFileHistoryBackend.java new file mode 100644 index 0000000000..b6ca59a653 --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/casc/history/LocalFileHistoryBackend.java @@ -0,0 +1,93 @@ +package io.jenkins.plugins.casc.history; + +import static org.apache.commons.lang.StringEscapeUtils.escapeXml; + +import hudson.Extension; +import hudson.Util; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Date; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.model.Jenkins; + +@Extension +public class LocalFileHistoryBackend extends CasCHistoryBackend { + private static final Logger LOGGER = Logger.getLogger(LocalFileHistoryBackend.class.getName()); + private static final String TIMESTAMP_FORMAT = "yyyy-MM-dd_HH-mm-ss"; + private static final int MAX_HISTORY_ENTRIES = 50; + private final Object writeLock = new Object(); + + @Override + public void save(String yamlContent, String triggeredBy) throws IOException { + File jenkinsHome = Jenkins.get().getRootDir(); + File baseHistoryDir = new File(jenkinsHome, "casc-history"); + + synchronized (writeLock) { + if (!baseHistoryDir.mkdirs() && !baseHistoryDir.exists()) { + throw new IOException("Failed to create base history directory: " + baseHistoryDir.getAbsolutePath()); + } + + String timestamp = getCurrentTimestamp(); + File specificHistoryDir = new File(baseHistoryDir, timestamp); + + int counter = 1; + while (specificHistoryDir.exists()) { + specificHistoryDir = new File(baseHistoryDir, timestamp + "_" + counter); + counter++; + } + + if (!specificHistoryDir.mkdirs()) { + throw new IOException( + "Failed to create specific history directory: " + specificHistoryDir.getAbsolutePath()); + } + + Path yamlFile = new File(specificHistoryDir, "jenkins.yaml").toPath(); + Files.writeString(yamlFile, yamlContent); + + String xmlMetadata = String.format( + "%n" + "%n" + + " %s%n" + + " %s%n" + + "", + escapeXml(triggeredBy), timestamp); + Path metadataFile = new File(specificHistoryDir, "history.xml").toPath(); + Files.writeString(metadataFile, xmlMetadata); + + LOGGER.info("CasC history successfully saved at: " + specificHistoryDir.getAbsolutePath()); + + cleanupOldHistory(baseHistoryDir); + } + } + + private void cleanupOldHistory(File baseHistoryDir) { + File[] historyFolders = baseHistoryDir.listFiles(File::isDirectory); + + if (historyFolders != null && historyFolders.length > MAX_HISTORY_ENTRIES) { + Arrays.sort(historyFolders, Comparator.comparing(File::getName)); + int directoriesToDelete = historyFolders.length - MAX_HISTORY_ENTRIES; + + for (int i = 0; i < directoriesToDelete; i++) { + File folderToDelete = historyFolders[i]; + LOGGER.fine("Deleting old CasC history entry: " + folderToDelete.getName()); + try { + Util.deleteRecursive(folderToDelete); + } catch (IOException e) { + LOGGER.log( + Level.WARNING, + "Failed to delete old CasC history folder: " + folderToDelete.getAbsolutePath(), + e); + } + } + } + } + + String getCurrentTimestamp() { + return new SimpleDateFormat(TIMESTAMP_FORMAT).format(new Date()); + } +} diff --git a/plugin/src/main/resources/io/jenkins/plugins/casc/history/CasCHistoryAction/index.jelly b/plugin/src/main/resources/io/jenkins/plugins/casc/history/CasCHistoryAction/index.jelly new file mode 100644 index 0000000000..4428ba398f --- /dev/null +++ b/plugin/src/main/resources/io/jenkins/plugins/casc/history/CasCHistoryAction/index.jelly @@ -0,0 +1,39 @@ + + + + + +

CasC Configuration History

+

Browse the history of CasC changes and view the resulting configuration snapshots

+ + + + + + + + + + + + + + + + + + +
TimestampUserActions
${entry.formattedTimestamp}${entry.user()} + + View YAML + +
+ + +

No history found. Try reloading your configuration first!

+
+ +
+
+
diff --git a/plugin/src/test/java/io/jenkins/plugins/casc/history/LocalFileHistoryBackendTest.java b/plugin/src/test/java/io/jenkins/plugins/casc/history/LocalFileHistoryBackendTest.java new file mode 100644 index 0000000000..bb48fe3284 --- /dev/null +++ b/plugin/src/test/java/io/jenkins/plugins/casc/history/LocalFileHistoryBackendTest.java @@ -0,0 +1,721 @@ +package io.jenkins.plugins.casc.history; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +import io.jenkins.plugins.casc.CasCReloadListener; +import io.jenkins.plugins.casc.history.CasCHistoryAction.HistoryEntry; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.WriteListener; +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.lang.reflect.Proxy; +import java.nio.channels.FileLock; +import java.nio.file.Files; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.TestExtension; +import org.kohsuke.stapler.StaplerRequest2; +import org.kohsuke.stapler.StaplerResponse2; + +public class LocalFileHistoryBackendTest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Test + public void testSaveCreatesFilesAndMetadataCorrectly() throws Exception { + Thread.sleep(2000); + File baseHistoryDir = new File(j.jenkins.getRootDir(), "casc-history"); + hudson.Util.deleteRecursive(baseHistoryDir); + + LocalFileHistoryBackend backend = new LocalFileHistoryBackend(); + String yamlContent = "jenkins:\n systemMessage: 'Hello World'"; + String user = "admin_user"; + + backend.save(yamlContent, user); + + assertTrue("Base history directory should be created", baseHistoryDir.exists()); + + File[] subDirs = baseHistoryDir.listFiles(File::isDirectory); + assertEquals("Should have exactly one history entry folder", 1, Objects.requireNonNull(subDirs).length); + + File specificHistoryDir = subDirs[0]; + File yamlFile = new File(specificHistoryDir, "jenkins.yaml"); + File xmlFile = new File(specificHistoryDir, "history.xml"); + + assertTrue("jenkins.yaml should exist", yamlFile.exists()); + assertTrue("history.xml should exist", xmlFile.exists()); + + String savedYaml = Files.readString(yamlFile.toPath()); + assertEquals("YAML content should match exactly", yamlContent, savedYaml); + + String savedXml = Files.readString(xmlFile.toPath()); + assertTrue("XML should contain the user", savedXml.contains("admin_user")); + } + + @Test + public void testHistoryRetentionPolicyDeletesOldestEntries() throws Exception { + Thread.sleep(2000); + File baseHistoryDir = new File(j.jenkins.getRootDir(), "casc-history"); + hudson.Util.deleteRecursive(baseHistoryDir); + assertTrue("Failed to create base history directory", baseHistoryDir.mkdirs() || baseHistoryDir.exists()); + + LocalFileHistoryBackend backend = new LocalFileHistoryBackend(); + + for (int i = 10; i <= 64; i++) { + File dummyDir = new File(baseHistoryDir, "2026-01-01_12-00-" + i); + assertTrue("Failed to create dummy directory " + dummyDir.getName(), dummyDir.mkdirs()); + Files.writeString(new File(dummyDir, "dummy.txt").toPath(), "test data"); + } + + assertEquals(55, Objects.requireNonNull(baseHistoryDir.listFiles(File::isDirectory)).length); + + backend.save("dummy yaml", "admin"); + + File[] remainingDirs = baseHistoryDir.listFiles(File::isDirectory); + assertEquals( + "Should retain exactly 50 directories after cleanup", 50, Objects.requireNonNull(remainingDirs).length); + + for (int i = 10; i <= 15; i++) { + File deletedDir = new File(baseHistoryDir, "2026-01-01_12-00-" + i); + assertFalse("Oldest directory " + deletedDir.getName() + " should have been deleted", deletedDir.exists()); + } + + File survivedDir = new File(baseHistoryDir, "2026-01-01_12-00-16"); + assertTrue("Directory 16 should have survived the cleanup", survivedDir.exists()); + + File newestDummyDir = new File(baseHistoryDir, "2026-01-01_12-00-64"); + assertTrue("Directory 64 should have survived the cleanup", newestDummyDir.exists()); + } + + @Test + public void testXmlEscapingWithSpecialCharacters() throws Exception { + File baseDir = new File(j.jenkins.getRootDir(), "casc-history"); + hudson.Util.deleteRecursive(baseDir); + + LocalFileHistoryBackend backend = new LocalFileHistoryBackend(); + String problematicUser = "admin & 'quotes'"; + + backend.save("dummy-yaml-content", problematicUser); + + File[] dirs = baseDir.listFiles(File::isDirectory); + + assertTrue("History directory should exist", dirs != null && dirs.length > 0); + File latestDir = dirs[0]; + + String xml = Files.readString(new File(latestDir, "history.xml").toPath()); + + assertTrue("Less-than should be escaped", xml.contains("<danger>")); + assertTrue("Ampersand should be escaped", xml.contains("&")); + assertTrue("Single quote should be escaped", xml.contains("'quotes'")); + } + + @Test + public void testSaveHandlesTimestampCollisionsCorrectly() throws Exception { + Thread.sleep(2000); + File baseHistoryDir = new File(j.jenkins.getRootDir(), "casc-history"); + hudson.Util.deleteRecursive(baseHistoryDir); + assertTrue(baseHistoryDir.mkdirs() || baseHistoryDir.exists()); + + final String FROZEN_TIME = "2026-01-01_12-00-00"; + LocalFileHistoryBackend backend = new LocalFileHistoryBackend() { + @Override + String getCurrentTimestamp() { + return FROZEN_TIME; + } + }; + + File collisionDir = new File(baseHistoryDir, FROZEN_TIME); + assertTrue("Failed to create manual collision directory", collisionDir.mkdirs()); + + backend.save("colliding-content", "user1"); + + File[] dirs = baseHistoryDir.listFiles(File::isDirectory); + assertNotNull(dirs); + assertEquals("Should have 2 directories (the manual one and the auto-incremented one)", 2, dirs.length); + + File incrementedDir = new File(baseHistoryDir, FROZEN_TIME + "_1"); + assertTrue("The directory with _1 suffix should have been created", incrementedDir.exists()); + + String savedYaml = Files.readString(new File(incrementedDir, "jenkins.yaml").toPath()); + assertEquals("The incremented directory should contain the correct YAML", "colliding-content", savedYaml); + } + + @Test + public void testGetHistoryEntriesReturnsEmptyListWhenNoHistoryExists() throws IOException { + File baseHistoryDir = new File(j.jenkins.getRootDir(), "casc-history"); + hudson.Util.deleteRecursive(baseHistoryDir); + + CasCHistoryAction action = new CasCHistoryAction(); + + List entries = action.getHistoryEntries(); + + assertNotNull("Entries list should not be null", entries); + Assert.assertTrue("Entries list should be empty", entries.isEmpty()); + } + + @Test + public void testGetHistoryEntriesReturnsNewestFirst() throws Exception { + File baseHistoryDir = new File(j.jenkins.getRootDir(), "casc-history"); + hudson.Util.deleteRecursive(baseHistoryDir); + assertTrue(baseHistoryDir.mkdirs() || baseHistoryDir.exists()); + + String oldest = "2026-01-01_10-00-00"; + String middle = "2026-01-01_11-00-00"; + String newest = "2026-01-01_12-00-00"; + + assertTrue(new File(baseHistoryDir, middle).mkdirs()); + assertTrue(new File(baseHistoryDir, oldest).mkdirs()); + assertTrue(new File(baseHistoryDir, newest).mkdirs()); + + Files.writeString( + new File(new File(baseHistoryDir, oldest), "history.xml").toPath(), + "Alice"); + Files.writeString( + new File(new File(baseHistoryDir, middle), "history.xml").toPath(), + "Bob"); + Files.writeString( + new File(new File(baseHistoryDir, newest), "history.xml").toPath(), + "Charlie"); + + CasCHistoryAction action = new CasCHistoryAction(); + + List entries = action.getHistoryEntries(); + + Assert.assertEquals("Should have exactly 3 entries", 3, entries.size()); + + Assert.assertEquals( + "Newest entry should be first", newest, entries.get(0).timestamp()); + Assert.assertEquals("Charlie", entries.get(0).user()); + + Assert.assertEquals( + "Middle entry should be second", middle, entries.get(1).timestamp()); + Assert.assertEquals("Bob", entries.get(1).user()); + + Assert.assertEquals( + "Oldest entry should be last", oldest, entries.get(2).timestamp()); + Assert.assertEquals("Alice", entries.get(2).user()); + } + + @Test + public void testConcurrentSavesDoNotCorruptDataOrThrowExceptions() throws Exception { + Thread.sleep(2000); + File baseDir = new File(j.jenkins.getRootDir(), "casc-history"); + hudson.Util.deleteRecursive(baseDir); + + LocalFileHistoryBackend backend = new LocalFileHistoryBackend(); + int threadCount = 20; + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startingGun = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + for (int i = 0; i < threadCount; i++) { + final String user = "user-" + i; + final String yaml = "content-" + i; + + futures.add(executor.submit(() -> { + startingGun.await(); + backend.save(yaml, user); + return null; + })); + } + + startingGun.countDown(); + + for (Future future : futures) { + future.get(); + } + executor.shutdown(); + + File[] dirs = baseDir.listFiles(File::isDirectory); + + assertNotNull(dirs); + assertEquals( + "Should have successfully created all 20 history entries without overwriting each other", + threadCount, + dirs.length); + } + + @Test + public void testReloadListenerExceptionDoesNotBreakOthers() { + GoodListener.called = false; + BadListener.shouldThrow = true; + + try { + CasCReloadListener.fire(); + + assertTrue("Other listeners should still execute even if one throws an exception", GoodListener.called); + } finally { + BadListener.shouldThrow = false; + } + } + + @TestExtension + public static class BadListener implements CasCReloadListener { + static boolean shouldThrow = false; + + @Override + public void onConfigurationReloaded() { + if (shouldThrow) { + throw new RuntimeException("Boom!"); + } + } + } + + @TestExtension + public static class GoodListener implements CasCReloadListener { + static boolean called = false; + + @Override + public void onConfigurationReloaded() { + called = true; + } + } + + @Test + public void testSaveThrowsExceptionWhenDirectoryCreationFails() throws Exception { + File jenkinsRoot = j.jenkins.getRootDir(); + File baseHistoryDir = new File(jenkinsRoot, "casc-history"); + + hudson.Util.deleteRecursive(baseHistoryDir); + + boolean locked = jenkinsRoot.setWritable(false, false); + + assumeTrue("Test requires the OS to support setting directories to read-only", locked); + + try { + LocalFileHistoryBackend backend = new LocalFileHistoryBackend(); + + backend.save("dummy yaml", "admin"); + + Assert.fail("Should have thrown an IOException because the directory couldn't be created!"); + } catch (IOException e) { + assertTrue( + "Message should match the exception in the code", + e.getMessage().contains("Failed to create base history directory")); + } finally { + if (!jenkinsRoot.setWritable(true, false)) { + System.err.println("WARNING: Failed to restore writable state to Jenkins root directory!"); + } + } + } + + @Test + public void testSaveThrowsExceptionWhenSpecificDirectoryCreationFails() throws Exception { + File baseHistoryDir = new File(j.jenkins.getRootDir(), "casc-history"); + + hudson.Util.deleteRecursive(baseHistoryDir); + assertTrue("Setup failed: could not create base directory", baseHistoryDir.mkdirs()); + + boolean locked = baseHistoryDir.setWritable(false, false); + assumeTrue("Test requires the OS to support setting directories to read-only", locked); + + try { + LocalFileHistoryBackend backend = new LocalFileHistoryBackend(); + + backend.save("dummy yaml", "admin"); + + Assert.fail("Should have thrown an IOException because the specific directory couldn't be created!"); + } catch (IOException e) { + assertTrue( + "Message should match the specific directory exception in the code. Actual message: " + + e.getMessage(), + e.getMessage().contains("Failed to create specific history directory")); + } finally { + if (!baseHistoryDir.setWritable(true, false)) { + System.err.println("WARNING: Failed to restore writable state to base history directory!"); + } + } + } + + @Test + public void testExceptionLoggedWhenOldDirectoryCannotBeDeleted() throws Exception { + Thread.sleep(2000); + + File baseHistoryDir = new File(j.jenkins.getRootDir(), "casc-history"); + hudson.Util.deleteRecursive(baseHistoryDir); + assertTrue(baseHistoryDir.mkdirs() || baseHistoryDir.exists()); + + LocalFileHistoryBackend backend = new LocalFileHistoryBackend(); + + for (int i = 10; i <= 64; i++) { + File dummyDir = new File(baseHistoryDir, "2026-01-01_12-00-" + i); + assertTrue(dummyDir.mkdirs()); + Files.writeString(new File(dummyDir, "dummy.txt").toPath(), "test data"); + } + + File probeDir = new File(baseHistoryDir, "probe-dir"); + assertTrue(probeDir.mkdirs()); + Files.writeString(new File(probeDir, "probe.txt").toPath(), "probe data"); + assumeTrue("OS must support locking directories", probeDir.setWritable(false, false)); + + boolean osRespectsLocks; + try { + hudson.Util.deleteRecursive(probeDir); + osRespectsLocks = probeDir.exists(); + } catch (IOException e) { + osRespectsLocks = true; + } + + assumeTrue("OS ignores directory locks, gracefully skipping test", osRespectsLocks); + + File oldestDir = new File(baseHistoryDir, "2026-01-01_12-00-10"); + + boolean locked = oldestDir.setWritable(false, false); + assumeTrue("Test requires the OS to support setting directories to read-only", locked); + + try { + backend.save("dummy yaml", "admin"); + assertTrue("Oldest directory should still exist because deletion was blocked", oldestDir.exists()); + } finally { + if (!oldestDir.setWritable(true, false)) { + System.err.println("WARNING: Failed to restore writable state to the oldest directory!"); + } + } + } + + @Test + public void testGetHistoryEntriesHandlesCorruptXmlGracefully() throws Exception { + File baseHistoryDir = new File(j.jenkins.getRootDir(), "casc-history"); + hudson.Util.deleteRecursive(baseHistoryDir); + assertTrue(baseHistoryDir.mkdirs() || baseHistoryDir.exists()); + + String timestamp = "2026-01-01_12-00-00"; + File specificHistoryDir = new File(baseHistoryDir, timestamp); + assertTrue(specificHistoryDir.mkdirs()); + + File xmlFile = new File(specificHistoryDir, "history.xml"); + Files.writeString(xmlFile.toPath(), "admin entries = action.getHistoryEntries(); + + assertEquals("Should have exactly 1 entry", 1, entries.size()); + assertEquals("Timestamp should match", timestamp, entries.get(0).timestamp()); + assertEquals( + "User should fallback to Unknown User when XML is unreadable", + "Unknown User", + entries.get(0).user()); + } + + @Test + public void testHistoryEntryFormattedTimestamp() { + HistoryEntry entry = new HistoryEntry("2026-04-09_14-30-05", "admin"); + + LocalDateTime dt = + LocalDateTime.parse("2026-04-09_14-30-05", DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")); + String expected = dt.format(DateTimeFormatter.ofPattern("dd MMM yyyy, HH:mm:ss")); + + assertEquals("Timestamp should be formatted correctly", expected, entry.getFormattedTimestamp()); + } + + @Test + public void testDoViewReturns400WhenTimestampIsMissing() throws Exception { + CasCHistoryAction action = new CasCHistoryAction(); + + StaplerRequest2 req = (StaplerRequest2) java.lang.reflect.Proxy.newProxyInstance( + StaplerRequest2.class.getClassLoader(), new Class[] {StaplerRequest2.class}, (proxy, method, args) -> { + if (method.getDeclaringClass() == Object.class) { + switch (method.getName()) { + case "equals" -> { + return proxy == args[0]; + } + case "hashCode" -> { + return System.identityHashCode(proxy); + } + case "toString" -> { + return "MockRequest"; + } + } + } + return null; + }); + + int[] capturedErrorCode = new int[1]; + + StaplerResponse2 res = (StaplerResponse2) Proxy.newProxyInstance( + StaplerResponse2.class.getClassLoader(), + new Class[] {StaplerResponse2.class}, + (proxy, method, args) -> { + if (method.getDeclaringClass() == Object.class) { + switch (method.getName()) { + case "equals" -> { + return proxy == args[0]; + } + case "hashCode" -> { + return System.identityHashCode(proxy); + } + case "toString" -> { + return "MockResponse"; + } + } + } + if ("sendError".equals(method.getName())) { + capturedErrorCode[0] = (int) args[0]; + } + return null; + }); + + action.doView(req, res); + assertEquals("Should return 400 Bad Request", 400, capturedErrorCode[0]); + } + + @Test + public void testDoViewReturns400WhenTimestampIsInvalid() throws Exception { + CasCHistoryAction action = new CasCHistoryAction(); + + StaplerRequest2 req = (StaplerRequest2) Proxy.newProxyInstance( + StaplerRequest2.class.getClassLoader(), new Class[] {StaplerRequest2.class}, (proxy, method, args) -> { + if (method.getDeclaringClass() == Object.class) { + switch (method.getName()) { + case "equals" -> { + return proxy == args[0]; + } + case "hashCode" -> { + return System.identityHashCode(proxy); + } + case "toString" -> { + return "MockRequest"; + } + } + } + if ("getParameter".equals(method.getName())) { + return "../../etc/passwd"; + } + return null; + }); + + int[] capturedErrorCode = new int[1]; + StaplerResponse2 res = (StaplerResponse2) Proxy.newProxyInstance( + StaplerResponse2.class.getClassLoader(), + new Class[] {StaplerResponse2.class}, + (proxy, method, args) -> { + if (method.getDeclaringClass() == Object.class) { + switch (method.getName()) { + case "equals" -> { + return proxy == args[0]; + } + case "hashCode" -> { + return System.identityHashCode(proxy); + } + case "toString" -> { + return "MockResponse"; + } + } + } + if ("sendError".equals(method.getName())) { + capturedErrorCode[0] = (int) args[0]; + } + return null; + }); + + action.doView(req, res); + assertEquals("Should return 400 Bad Request", 400, capturedErrorCode[0]); + } + + @Test + public void testDoViewReturns404WhenRecordDoesNotExist() throws Exception { + CasCHistoryAction action = new CasCHistoryAction(); + + StaplerRequest2 req = (StaplerRequest2) Proxy.newProxyInstance( + StaplerRequest2.class.getClassLoader(), + new Class[] {org.kohsuke.stapler.StaplerRequest2.class}, + (proxy, method, args) -> { + if (method.getDeclaringClass() == Object.class) { + switch (method.getName()) { + case "equals" -> { + return proxy == args[0]; + } + case "hashCode" -> { + return System.identityHashCode(proxy); + } + case "toString" -> { + return "MockRequest"; + } + } + } + if ("getParameter".equals(method.getName())) { + return "2026-01-01_12-00-00"; + } + return null; + }); + + int[] capturedErrorCode = new int[1]; + StaplerResponse2 res = (StaplerResponse2) Proxy.newProxyInstance( + StaplerResponse2.class.getClassLoader(), + new Class[] {StaplerResponse2.class}, + (proxy, method, args) -> { + if (method.getDeclaringClass() == Object.class) { + switch (method.getName()) { + case "equals" -> { + return proxy == args[0]; + } + case "hashCode" -> { + return System.identityHashCode(proxy); + } + case "toString" -> { + return "MockResponse"; + } + } + } + if ("sendError".equals(method.getName())) { + capturedErrorCode[0] = (int) args[0]; + } + return null; + }); + + action.doView(req, res); + assertEquals("Should return 404 Not Found", 404, capturedErrorCode[0]); + } + + @Test + public void testDoViewSuccessfullyReturnsYamlContent() throws Exception { + File baseHistoryDir = new File(j.jenkins.getRootDir(), "casc-history"); + hudson.Util.deleteRecursive(baseHistoryDir); + + String timestamp = "2026-01-01_12-00-00"; + File specificDir = new File(baseHistoryDir, timestamp); + assertTrue(specificDir.mkdirs()); + File yamlFile = new File(specificDir, "jenkins.yaml"); + Files.writeString(yamlFile.toPath(), "jenkins:\n systemMessage: 'Testing'"); + + CasCHistoryAction action = new CasCHistoryAction(); + + StaplerRequest2 req = (StaplerRequest2) java.lang.reflect.Proxy.newProxyInstance( + StaplerRequest2.class.getClassLoader(), new Class[] {StaplerRequest2.class}, (proxy, method, args) -> { + if (method.getDeclaringClass() == Object.class) { + switch (method.getName()) { + case "equals" -> { + return proxy == args[0]; + } + case "hashCode" -> { + return System.identityHashCode(proxy); + } + case "toString" -> { + return "MockRequest"; + } + } + } + if ("getParameter".equals(method.getName())) { + return timestamp; + } + return null; + }); + + String[] capturedContentType = new String[1]; + + try (ServletOutputStream dummyStream = new ServletOutputStream() { + @Override + public boolean isReady() { + return true; + } + + @Override + public void setWriteListener(WriteListener writeListener) {} + + @Override + public void write(int b) {} + }) { + StaplerResponse2 res = (StaplerResponse2) Proxy.newProxyInstance( + StaplerResponse2.class.getClassLoader(), + new Class[] {StaplerResponse2.class}, + (proxy, method, args) -> { + if (method.getDeclaringClass() == Object.class) { + switch (method.getName()) { + case "equals" -> { + return proxy == args[0]; + } + case "hashCode" -> { + return System.identityHashCode(proxy); + } + case "toString" -> { + return "MockResponse"; + } + } + } + if ("setContentType".equals(method.getName())) { + capturedContentType[0] = (String) args[0]; + } + if ("getOutputStream".equals(method.getName())) { + return dummyStream; + } + return null; + }); + + action.doView(req, res); + } + + assertEquals("Content type should match", "text/plain;charset=UTF-8", capturedContentType[0]); + } + + @Test + public void testCleanupOldHistoryHandlesDeletionFailureGracefully() throws Exception { + File baseHistoryDir = new File(j.jenkins.getRootDir(), "casc-history"); + hudson.Util.deleteRecursive(baseHistoryDir); + assertTrue("Setup failed: could not create base directory", baseHistoryDir.mkdirs()); + + for (int i = 10; i <= 59; i++) { + File dummyDir = new File(baseHistoryDir, "2000-01-01_12-00-" + i); + assertTrue(dummyDir.mkdirs()); + } + + File targetedDir = new File(baseHistoryDir, "2000-01-01_12-00-10"); + + File stubbornFile = new File(targetedDir, "stubborn.txt"); + Files.writeString(stubbornFile.toPath(), "You shall not pass!"); + + RandomAccessFile raf = null; + FileLock lock = null; + + try { + raf = new RandomAccessFile(stubbornFile, "rw"); + lock = raf.getChannel().lock(); + + if (!targetedDir.setReadable(false, false) || !targetedDir.setWritable(false, false)) { + System.out.println("Note: OS ignored permission strip. Relying on FileLock instead."); + } + + LocalFileHistoryBackend backend = new LocalFileHistoryBackend(); + backend.save("dummy-yaml", "admin"); + + assertTrue( + "Target directory should still exist, proving the catch block was executed", targetedDir.exists()); + + } finally { + if (lock != null) { + lock.release(); + } + if (raf != null) { + raf.close(); + } + + if (!targetedDir.setReadable(true, false) + || !targetedDir.setWritable(true, false) + || !stubbornFile.setWritable(true, false)) { + System.err.println("WARNING: Failed to fully restore permissions during teardown!"); + } + + hudson.Util.deleteRecursive(baseHistoryDir); + } + } +}