Skip to content

Commit 17ec386

Browse files
committed
Store and download last applied configuration snapshot
1 parent 7a20abd commit 17ec386

3 files changed

Lines changed: 211 additions & 2 deletions

File tree

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

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
import java.nio.file.Path;
5454
import java.nio.file.PathMatcher;
5555
import java.nio.file.Paths;
56+
import java.nio.file.StandardCopyOption;
5657
import java.util.ArrayList;
5758
import java.util.Arrays;
5859
import java.util.Collection;
@@ -110,6 +111,7 @@ public class ConfigurationAsCode extends ManagementLink {
110111
public static final String CASC_JENKINS_CONFIG_ENV = "CASC_JENKINS_CONFIG";
111112
public static final String DEFAULT_JENKINS_YAML_PATH = "jenkins.yaml";
112113
public static final String YAML_FILES_PATTERN = "glob:**.{yml,yaml,YAML,YML}";
114+
public static final String LAST_APPLIED_CONFIG_FILE = "casc_last_applied.yaml";
113115

114116
private static final Logger LOGGER = Logger.getLogger(ConfigurationAsCode.class.getName());
115117

@@ -726,7 +728,6 @@ public void configure(Collection<String> configParameters) throws ConfiguratorEx
726728
}
727729
sources = Collections.unmodifiableList(new ArrayList<>(configParameters));
728730
configureWith(configs);
729-
lastTimeLoaded = System.currentTimeMillis();
730731
}
731732

732733
public static boolean isSupportedURI(String configurationParameter) {
@@ -754,7 +755,6 @@ public void configureWith(YamlSource source) throws ConfiguratorException {
754755
}
755756

756757
private void configureWith(List<YamlSource> sources) throws ConfiguratorException {
757-
lastTimeLoaded = System.currentTimeMillis();
758758
ConfigurationContext context = new ConfigurationContext(registry);
759759
configureWith(YamlUtils.loadFrom(sources, context), context);
760760
}
@@ -886,6 +886,12 @@ 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+
lastTimeLoaded = System.currentTimeMillis();
890+
try {
891+
saveLastAppliedConfiguration();
892+
} catch (IOException e) {
893+
LOGGER.log(Level.WARNING, "Failed to save last applied configuration", e);
894+
}
889895
}
890896

891897
public Map<Source, String> checkWith(Mapping entries, ConfigurationContext context) throws ConfiguratorException {
@@ -1028,4 +1034,49 @@ public void doConfigure(StaplerRequest2 req, StaplerResponse2 res) throws Except
10281034
res.getWriter().print(errors);
10291035
}
10301036
}
1037+
1038+
private void saveLastAppliedConfiguration() throws IOException {
1039+
Path targetFile = Paths.get(Jenkins.get().getRootDir().getPath(), LAST_APPLIED_CONFIG_FILE);
1040+
Path tempFile = Files.createTempFile(Jenkins.get().getRootDir().toPath(), "casc-last-applied", ".tmp");
1041+
1042+
try {
1043+
try (OutputStream out = Files.newOutputStream(tempFile)) {
1044+
export(out);
1045+
}
1046+
1047+
Files.move(tempFile, targetFile, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
1048+
1049+
LOGGER.log(Level.FINE, "Saved last applied configuration to {0}", targetFile);
1050+
} catch (Exception e) {
1051+
LOGGER.log(Level.WARNING, "Failed to save last applied configuration", e);
1052+
try {
1053+
Files.deleteIfExists(tempFile);
1054+
} catch (IOException ignored) {
1055+
}
1056+
}
1057+
}
1058+
1059+
public boolean isLastAppliedConfigurationAvailable() {
1060+
Path file = Paths.get(Jenkins.get().getRootDir().getPath(), LAST_APPLIED_CONFIG_FILE);
1061+
return Files.exists(file);
1062+
}
1063+
1064+
@RequirePOST
1065+
@Restricted(NoExternalUse.class)
1066+
public void doDownloadLastAppliedConfiguration(StaplerRequest2 req, StaplerResponse2 res) throws Exception {
1067+
if (!Jenkins.get().hasPermission(Jenkins.SYSTEM_READ)) {
1068+
res.sendError(HttpServletResponse.SC_FORBIDDEN, "Requires ADMINISTER permission");
1069+
return;
1070+
}
1071+
1072+
Path file = Paths.get(Jenkins.get().getRootDir().getPath(), LAST_APPLIED_CONFIG_FILE);
1073+
if (!Files.exists(file)) {
1074+
res.sendError(HttpServletResponse.SC_NOT_FOUND, "No last applied configuration found.");
1075+
return;
1076+
}
1077+
1078+
res.setContentType("application/x-yaml; charset=utf-8");
1079+
res.addHeader("Content-Disposition", "attachment; filename=" + LAST_APPLIED_CONFIG_FILE);
1080+
Files.copy(file, res.getOutputStream());
1081+
}
10311082
}

plugin/src/main/resources/io/jenkins/plugins/casc/ConfigurationAsCode/index.jelly

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@
3232
</l:hasPermission>
3333
</f:form>
3434

35+
<j:if test="${it.lastAppliedConfigurationAvailable}">
36+
<f:form method="post" action="downloadLastAppliedConfiguration" name="downloadLastAppliedConfiguration">
37+
<l:hasPermission permission="${app.SYSTEM_READ}">
38+
<button name="downloadLastApplied" class="jenkins-button">
39+
${%Download last applied config}
40+
</button>
41+
</l:hasPermission>
42+
</f:form>
43+
</j:if>
44+
3545
<j:if test="${!empty it.sources}">
3646
<f:form method="post" action="reload" name="reload">
3747
<l:hasAdministerOrManage>
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package io.jenkins.plugins.casc;
2+
3+
import static org.junit.Assert.assertEquals;
4+
import static org.junit.Assert.assertFalse;
5+
import static org.junit.Assert.assertTrue;
6+
import static org.junit.Assert.fail;
7+
8+
import java.net.URL;
9+
import java.nio.file.Files;
10+
import java.nio.file.Path;
11+
import jenkins.model.Jenkins;
12+
import org.htmlunit.FailingHttpStatusCodeException;
13+
import org.htmlunit.HttpMethod;
14+
import org.htmlunit.Page;
15+
import org.htmlunit.WebRequest;
16+
import org.htmlunit.WebResponse;
17+
import org.junit.Before;
18+
import org.junit.Rule;
19+
import org.junit.Test;
20+
import org.jvnet.hudson.test.JenkinsRule;
21+
import org.jvnet.hudson.test.MockAuthorizationStrategy;
22+
23+
public class ConfigurationAsCodeLastAppliedTest {
24+
25+
@Rule
26+
public JenkinsRule j = new JenkinsRule();
27+
28+
private static final String ENDPOINT = "configuration-as-code/configure";
29+
private static final String DOWNLOAD_ENDPOINT = "configuration-as-code/downloadLastAppliedConfiguration";
30+
private static final String YAML_CONTENT_TYPE = "application/yaml";
31+
private static final String ADMIN = "admin";
32+
33+
private WebRequest webRequest(String endpoint) throws Exception {
34+
return new WebRequest(new URL(j.getURL(), endpoint), HttpMethod.POST);
35+
}
36+
37+
private WebRequest yamlPost(String requestBody) throws Exception {
38+
WebRequest request = webRequest(ENDPOINT);
39+
request.setAdditionalHeader("Content-Type", YAML_CONTENT_TYPE);
40+
if (requestBody != null) {
41+
request.setRequestBody(requestBody);
42+
}
43+
return request;
44+
}
45+
46+
private WebRequest post() throws Exception {
47+
return webRequest(ConfigurationAsCodeLastAppliedTest.DOWNLOAD_ENDPOINT);
48+
}
49+
50+
private void configureAdminSecurity() {
51+
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
52+
j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy()
53+
.grant(Jenkins.ADMINISTER, Jenkins.SYSTEM_READ)
54+
.everywhere()
55+
.to(ADMIN));
56+
j.jenkins.setCrumbIssuer(null);
57+
}
58+
59+
private Path lastAppliedFile() {
60+
return j.jenkins.getRootDir().toPath().resolve(ConfigurationAsCode.LAST_APPLIED_CONFIG_FILE);
61+
}
62+
63+
@Test
64+
public void testSuccessfulApplySavesFile() throws Exception {
65+
configureAdminSecurity();
66+
String validYaml = "jenkins:\n systemMessage: 'Valid Message'\n";
67+
68+
try (JenkinsRule.WebClient wc = j.createWebClient()) {
69+
wc.login(ADMIN);
70+
wc.getPage(yamlPost(validYaml));
71+
72+
assertTrue("Last applied config file should exist", Files.exists(lastAppliedFile()));
73+
assertTrue("UI helper should return true", ConfigurationAsCode.get().isLastAppliedConfigurationAvailable());
74+
75+
String content = Files.readString(lastAppliedFile());
76+
assertTrue(
77+
"File should contain the applied configuration",
78+
content.contains("systemMessage: \"Valid Message\""));
79+
}
80+
}
81+
82+
@Test
83+
public void testFailedApplyDoesNotOverwriteFile() throws Exception {
84+
configureAdminSecurity();
85+
String validYaml = "jenkins:\n systemMessage: 'Good State'\n";
86+
87+
try (JenkinsRule.WebClient wc = j.createWebClient()) {
88+
wc.login(ADMIN);
89+
wc.getPage(yamlPost(validYaml));
90+
assertTrue(Files.exists(lastAppliedFile()));
91+
92+
String invalidYaml = "unsupported_root_element:\n foo: 'bar'\n";
93+
try {
94+
wc.getPage(yamlPost(invalidYaml));
95+
fail("Expected a 400 Bad Request exception");
96+
} catch (FailingHttpStatusCodeException e) {
97+
assertEquals(400, e.getStatusCode());
98+
}
99+
}
100+
101+
String content = Files.readString(lastAppliedFile());
102+
assertTrue("Should retain previous good state", content.contains("systemMessage: \"Good State\""));
103+
assertFalse("Should not contain invalid state", content.contains("unsupported_root_element"));
104+
}
105+
106+
@Test
107+
public void testDownloadEndpointWorksForAdmin() throws Exception {
108+
configureAdminSecurity();
109+
110+
try (JenkinsRule.WebClient wc = j.createWebClient()) {
111+
wc.login(ADMIN);
112+
wc.getPage(yamlPost("jenkins:\n systemMessage: 'Download Me'\n"));
113+
114+
Page page = wc.getPage(post());
115+
WebResponse response = page.getWebResponse();
116+
117+
assertEquals(200, response.getStatusCode());
118+
assertEquals("application/x-yaml", response.getContentType());
119+
assertTrue(response.getResponseHeaderValue("Content-Disposition")
120+
.contains(ConfigurationAsCode.LAST_APPLIED_CONFIG_FILE));
121+
assertTrue(response.getContentAsString().contains("systemMessage: \"Download Me\""));
122+
}
123+
}
124+
125+
@Test
126+
public void testDownloadEndpointRequiresPermissions() throws Exception {
127+
configureAdminSecurity();
128+
129+
try (JenkinsRule.WebClient adminWc = j.createWebClient();
130+
JenkinsRule.WebClient anonWc = j.createWebClient()) {
131+
132+
adminWc.login(ADMIN);
133+
adminWc.getPage(yamlPost("jenkins:\n systemMessage: 'Secret'\n"));
134+
135+
try {
136+
anonWc.getPage(post());
137+
fail("Expected a 403 Forbidden exception for anonymous user");
138+
} catch (FailingHttpStatusCodeException e) {
139+
assertEquals("Should reject unauthorized access", 403, e.getStatusCode());
140+
}
141+
}
142+
}
143+
144+
@Before
145+
public void cleanup() throws Exception {
146+
Files.deleteIfExists(lastAppliedFile());
147+
}
148+
}

0 commit comments

Comments
 (0)