Skip to content

Commit e92f5b2

Browse files
committed
Introduce JCasC configuration history tracking via listener extension point
1 parent 7a20abd commit e92f5b2

8 files changed

Lines changed: 577 additions & 1 deletion

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package io.jenkins.plugins.casc;
2+
3+
import hudson.ExtensionList;
4+
import hudson.ExtensionPoint;
5+
import java.util.logging.Level;
6+
import java.util.logging.Logger;
7+
8+
public interface CasCReloadListener extends ExtensionPoint {
9+
10+
void onConfigurationReloaded();
11+
12+
static void fire() {
13+
Logger logger = Logger.getLogger(CasCReloadListener.class.getName());
14+
15+
for (CasCReloadListener listener : ExtensionList.lookup(CasCReloadListener.class)) {
16+
try {
17+
listener.onConfigurationReloaded();
18+
} catch (Exception e) {
19+
logger.log(
20+
Level.WARNING,
21+
"Listener " + listener.getClass().getName()
22+
+ " threw an exception during CasC reload notification",
23+
e);
24+
}
25+
}
26+
}
27+
}

plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -754,9 +754,9 @@ public void configureWith(YamlSource source) throws ConfiguratorException {
754754
}
755755

756756
private void configureWith(List<YamlSource> sources) throws ConfiguratorException {
757-
lastTimeLoaded = System.currentTimeMillis();
758757
ConfigurationContext context = new ConfigurationContext(registry);
759758
configureWith(YamlUtils.loadFrom(sources, context), context);
759+
lastTimeLoaded = System.currentTimeMillis();
760760
}
761761

762762
@Restricted(NoExternalUse.class)
@@ -886,6 +886,7 @@ private void configureWith(Mapping entries, ConfigurationContext context) throws
886886
try (ACLContext acl = ACL.as2(ACL.SYSTEM2)) {
887887
invokeWith(entries, (configurator, config) -> configurator.configure(config, context));
888888
}
889+
CasCReloadListener.fire();
889890
}
890891

891892
public Map<Source, String> checkWith(Mapping entries, ConfigurationContext context) throws ConfiguratorException {
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package io.jenkins.plugins.casc.history;
2+
3+
import hudson.Extension;
4+
import hudson.model.ManagementLink;
5+
import java.io.File;
6+
import java.io.IOException;
7+
import java.nio.file.Files;
8+
import java.time.LocalDateTime;
9+
import java.time.format.DateTimeFormatter;
10+
import java.util.ArrayList;
11+
import java.util.Arrays;
12+
import java.util.Comparator;
13+
import java.util.List;
14+
import java.util.logging.Level;
15+
import java.util.logging.Logger;
16+
import javax.xml.parsers.DocumentBuilder;
17+
import javax.xml.parsers.DocumentBuilderFactory;
18+
import jenkins.model.Jenkins;
19+
import org.kohsuke.stapler.StaplerRequest2;
20+
import org.kohsuke.stapler.StaplerResponse2;
21+
import org.springframework.lang.NonNull;
22+
import org.w3c.dom.Document;
23+
24+
@Extension
25+
public class CasCHistoryAction extends ManagementLink {
26+
27+
private static final Logger LOGGER = Logger.getLogger(CasCHistoryAction.class.getName());
28+
29+
@Override
30+
public String getIconFileName() {
31+
return "symbol-time";
32+
}
33+
34+
@Override
35+
public String getDisplayName() {
36+
return "CasC History";
37+
}
38+
39+
@Override
40+
public String getUrlName() {
41+
return "casc-history";
42+
}
43+
44+
@Override
45+
public String getDescription() {
46+
return "Browse CasC history and snapshots.";
47+
}
48+
49+
public List<HistoryEntry> getHistoryEntries() {
50+
List<HistoryEntry> entries = new ArrayList<>();
51+
File baseDir = new File(Jenkins.get().getRootDir(), "casc-history");
52+
53+
if (baseDir.exists() && baseDir.isDirectory()) {
54+
File[] historyFolders = baseDir.listFiles(File::isDirectory);
55+
if (historyFolders != null) {
56+
Arrays.sort(historyFolders, Comparator.comparing(File::getName).reversed());
57+
58+
for (File folder : historyFolders) {
59+
entries.add(new HistoryEntry(folder.getName(), parseUserFromXml(folder)));
60+
}
61+
}
62+
}
63+
return entries;
64+
}
65+
66+
private String parseUserFromXml(File folder) {
67+
File xmlFile = new File(folder, "history.xml");
68+
if (xmlFile.exists()) {
69+
try {
70+
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
71+
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
72+
DocumentBuilder builder = factory.newDocumentBuilder();
73+
Document doc = builder.parse(xmlFile);
74+
75+
return doc.getElementsByTagName("user").item(0).getTextContent();
76+
} catch (Exception e) {
77+
LOGGER.log(Level.WARNING, "Failed to read history.xml in " + folder.getName(), e);
78+
}
79+
}
80+
return "Unknown User";
81+
}
82+
83+
public record HistoryEntry(String timestamp, String user) {
84+
85+
@SuppressWarnings("unused")
86+
public String getFormattedTimestamp() {
87+
LocalDateTime dt = LocalDateTime.parse(timestamp, DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"));
88+
89+
return dt.format(DateTimeFormatter.ofPattern("dd MMM yyyy, HH:mm:ss"));
90+
}
91+
}
92+
93+
@SuppressWarnings("unused")
94+
public void doView(StaplerRequest2 req, StaplerResponse2 res) throws IOException {
95+
Jenkins.get().checkPermission(Jenkins.ADMINISTER);
96+
97+
String timestamp = req.getParameter("timestamp");
98+
if (timestamp == null || timestamp.isEmpty()) {
99+
res.sendError(400, "Missing timestamp parameter");
100+
return;
101+
}
102+
103+
if (!timestamp.matches("^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2}(_[0-9]+)?$")) {
104+
res.sendError(400, "Invalid timestamp format");
105+
return;
106+
}
107+
108+
File baseDir = new File(Jenkins.get().getRootDir(), "casc-history");
109+
File historyDir = new File(baseDir, timestamp);
110+
File yamlFile = new File(historyDir, "jenkins.yaml");
111+
112+
if (!yamlFile.exists()) {
113+
res.sendError(404, "History record not found");
114+
return;
115+
}
116+
117+
res.setContentType("text/plain;charset=UTF-8");
118+
Files.copy(yamlFile.toPath(), res.getOutputStream());
119+
}
120+
121+
@Override
122+
@NonNull
123+
public Category getCategory() {
124+
return Category.CONFIGURATION;
125+
}
126+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package io.jenkins.plugins.casc.history;
2+
3+
import hudson.ExtensionPoint;
4+
import java.io.IOException;
5+
6+
public abstract class CasCHistoryBackend implements ExtensionPoint {
7+
8+
public abstract void save(String yamlContent, String triggeredBy) throws IOException;
9+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package io.jenkins.plugins.casc.history;
2+
3+
import hudson.Extension;
4+
import hudson.ExtensionList;
5+
import io.jenkins.plugins.casc.CasCReloadListener;
6+
import io.jenkins.plugins.casc.ConfigurationAsCode;
7+
import java.io.ByteArrayOutputStream;
8+
import java.nio.charset.StandardCharsets;
9+
import java.util.logging.Level;
10+
import java.util.logging.Logger;
11+
import jenkins.model.Jenkins;
12+
import jenkins.util.Timer;
13+
import org.springframework.security.core.Authentication;
14+
15+
@Extension
16+
@SuppressWarnings("unused")
17+
public class CasCHistoryRecorder implements CasCReloadListener {
18+
19+
private static final Logger LOGGER = Logger.getLogger(CasCHistoryRecorder.class.getName());
20+
21+
@Override
22+
public void onConfigurationReloaded() {
23+
LOGGER.fine("CasC reload detected. Queuing async capture of current YAML state...");
24+
25+
final Authentication auth = Jenkins.getAuthentication2();
26+
final String triggeredBy = auth.getName();
27+
28+
Timer.get().submit(() -> {
29+
try {
30+
ByteArrayOutputStream out = new ByteArrayOutputStream();
31+
ConfigurationAsCode.get().export(out);
32+
String currentYaml = out.toString(StandardCharsets.UTF_8);
33+
34+
CasCHistoryBackend backend = ExtensionList.lookupFirst(CasCHistoryBackend.class);
35+
backend.save(currentYaml, triggeredBy);
36+
37+
} catch (Exception e) {
38+
LOGGER.log(Level.WARNING, "Failed to capture CasC history asynchronously", e);
39+
}
40+
});
41+
}
42+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package io.jenkins.plugins.casc.history;
2+
3+
import static org.apache.commons.lang.StringEscapeUtils.escapeXml;
4+
5+
import hudson.Extension;
6+
import hudson.Util;
7+
import java.io.File;
8+
import java.io.IOException;
9+
import java.nio.file.Files;
10+
import java.nio.file.Path;
11+
import java.text.SimpleDateFormat;
12+
import java.util.Arrays;
13+
import java.util.Comparator;
14+
import java.util.Date;
15+
import java.util.logging.Level;
16+
import java.util.logging.Logger;
17+
import jenkins.model.Jenkins;
18+
19+
@Extension
20+
public class LocalFileHistoryBackend extends CasCHistoryBackend {
21+
private static final Logger LOGGER = Logger.getLogger(LocalFileHistoryBackend.class.getName());
22+
private static final String TIMESTAMP_FORMAT = "yyyy-MM-dd_HH-mm-ss";
23+
private static final int MAX_HISTORY_ENTRIES = 50;
24+
private final Object writeLock = new Object();
25+
26+
@Override
27+
public void save(String yamlContent, String triggeredBy) throws IOException {
28+
File jenkinsHome = Jenkins.get().getRootDir();
29+
File baseHistoryDir = new File(jenkinsHome, "casc-history");
30+
31+
synchronized (writeLock) {
32+
if (!baseHistoryDir.mkdirs() && !baseHistoryDir.exists()) {
33+
throw new IOException("Failed to create base history directory: " + baseHistoryDir.getAbsolutePath());
34+
}
35+
36+
String timestamp = getCurrentTimestamp();
37+
File specificHistoryDir = new File(baseHistoryDir, timestamp);
38+
39+
int counter = 1;
40+
while (specificHistoryDir.exists()) {
41+
specificHistoryDir = new File(baseHistoryDir, timestamp + "_" + counter);
42+
counter++;
43+
}
44+
45+
if (!specificHistoryDir.mkdirs()) {
46+
throw new IOException(
47+
"Failed to create specific history directory: " + specificHistoryDir.getAbsolutePath());
48+
}
49+
50+
Path yamlFile = new File(specificHistoryDir, "jenkins.yaml").toPath();
51+
Files.writeString(yamlFile, yamlContent);
52+
53+
String xmlMetadata = String.format(
54+
"<?xml version='1.1' encoding='UTF-8'?>%n" + "<history>%n"
55+
+ " <user>%s</user>%n"
56+
+ " <timestamp>%s</timestamp>%n"
57+
+ "</history>",
58+
escapeXml(triggeredBy), timestamp);
59+
Path metadataFile = new File(specificHistoryDir, "history.xml").toPath();
60+
Files.writeString(metadataFile, xmlMetadata);
61+
62+
LOGGER.info("CasC history successfully saved at: " + specificHistoryDir.getAbsolutePath());
63+
64+
cleanupOldHistory(baseHistoryDir);
65+
}
66+
}
67+
68+
private void cleanupOldHistory(File baseHistoryDir) {
69+
File[] historyFolders = baseHistoryDir.listFiles(File::isDirectory);
70+
71+
if (historyFolders != null && historyFolders.length > MAX_HISTORY_ENTRIES) {
72+
Arrays.sort(historyFolders, Comparator.comparing(File::getName));
73+
int directoriesToDelete = historyFolders.length - MAX_HISTORY_ENTRIES;
74+
75+
for (int i = 0; i < directoriesToDelete; i++) {
76+
File folderToDelete = historyFolders[i];
77+
LOGGER.fine("Deleting old CasC history entry: " + folderToDelete.getName());
78+
try {
79+
Util.deleteRecursive(folderToDelete);
80+
} catch (IOException e) {
81+
LOGGER.log(
82+
Level.WARNING,
83+
"Failed to delete old CasC history folder: " + folderToDelete.getAbsolutePath(),
84+
e);
85+
}
86+
}
87+
}
88+
}
89+
90+
String getCurrentTimestamp() {
91+
return new SimpleDateFormat(TIMESTAMP_FORMAT).format(new Date());
92+
}
93+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?jelly escape-by-default='true'?>
2+
<j:jelly xmlns:j="jelly:core" xmlns:l="/lib/layout">
3+
<l:layout title="CasC Configuration History" permission="${app.ADMINISTER}">
4+
5+
<l:main-panel>
6+
<h1>CasC Configuration History</h1>
7+
<p> Browse the history of CasC changes and view the resulting configuration snapshots</p>
8+
9+
<table class="jenkins-table sortable">
10+
<thead>
11+
<tr>
12+
<th class="jenkins-table__cell--tight">Timestamp</th>
13+
<th>User</th>
14+
<th>Actions</th>
15+
</tr>
16+
</thead>
17+
<tbody>
18+
<j:forEach var="entry" items="${it.historyEntries}">
19+
<tr>
20+
<td>${entry.formattedTimestamp}</td>
21+
<td>${entry.user()}</td>
22+
<td>
23+
<a href="view?timestamp=${entry.timestamp()}"
24+
class="jenkins-button jenkins-button--tertiary">
25+
View YAML
26+
</a>
27+
</td>
28+
</tr>
29+
</j:forEach>
30+
</tbody>
31+
</table>
32+
33+
<j:if test="${empty(it.historyEntries)}">
34+
<p class="jenkins-!-margin-top-3">No history found. Try reloading your configuration first!</p>
35+
</j:if>
36+
37+
</l:main-panel>
38+
</l:layout>
39+
</j:jelly>

0 commit comments

Comments
 (0)