Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());

Expand Down Expand Up @@ -726,7 +728,6 @@ public void configure(Collection<String> configParameters) throws ConfiguratorEx
}
sources = Collections.unmodifiableList(new ArrayList<>(configParameters));
configureWith(configs);
lastTimeLoaded = System.currentTimeMillis();
}

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

private void configureWith(List<YamlSource> sources) throws ConfiguratorException {
lastTimeLoaded = System.currentTimeMillis();
ConfigurationContext context = new ConfigurationContext(registry);
configureWith(YamlUtils.loadFrom(sources, context), context);
}
Expand Down Expand Up @@ -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<Source, String> checkWith(Mapping entries, ConfigurationContext context) throws ConfiguratorException {
Expand Down Expand Up @@ -1028,4 +1034,50 @@ 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)
@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");
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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@
</l:hasPermission>
</f:form>

<j:if test="${it.lastAppliedConfigurationAvailable}">
<f:form method="post" action="downloadLastAppliedConfiguration" name="downloadLastAppliedConfiguration">
<l:hasPermission permission="${app.SYSTEM_READ}">
<button name="downloadLastApplied" class="jenkins-button">
${%Download last applied config}
</button>
</l:hasPermission>
</f:form>
</j:if>

<j:if test="${!empty it.sources}">
<f:form method="post" action="reload" name="reload">
<l:hasAdministerOrManage>
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading