diff --git a/docs/features/configurationReload.md b/docs/features/configurationReload.md index 19809a4c25..512547e57e 100644 --- a/docs/features/configurationReload.md +++ b/docs/features/configurationReload.md @@ -24,6 +24,18 @@ $ curl -X POST -G -d @/path/to/secret/file "JENKINS_URL/reload-configuration-as- permissions. Since Jenkins 2.96 CRUMB is not needed for API tokens. - via [Jenkins CLI](https://www.jenkins.io/doc/book/managing/cli/): with the Jenkins CLI (either with SSH or JAR), the command `java -jar jenkins-cli.jar -s ${JENKINS_URL} reload-jcasc-configuration` triggers a configuration reload. This Jenkins CLI command is only present when the plugin `configuration-as-code` is installed, and reported in the help message: + +- via http POST to `JENKINS_URL/configuration-as-code/configure` + This endpoint allows you to send your configuration as code directly in the HTTP POST body. + + To use this endpoint, you must: + - Authenticate the request using the username and API token of a user with `Administer` permissions. + + **Example Usage:** + ```sh + $ curl -X POST -u admin:YOUR_API_TOKEN \ + --data-binary @jenkins.yaml \ + "JENKINS_URL/configuration-as-code/configure" ```shell $ java -jar jenkins-cli.jar -s ${JENKINS_URL} help 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 5578ef4d94..a6197eee08 100644 --- a/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java +++ b/plugin/src/main/java/io/jenkins/plugins/casc/ConfigurationAsCode.java @@ -985,4 +985,47 @@ public static String printThrowable(@NonNull Throwable t) { .replaceAll("\t", " "); return s.substring(0, s.lastIndexOf(")") + 1); } + + @RequirePOST + @Restricted(NoExternalUse.class) + @SuppressWarnings("unused") + public void doConfigure(StaplerRequest2 req, StaplerResponse2 res) throws Exception { + + if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) { + res.sendError(HttpServletResponse.SC_FORBIDDEN, "Requires ADMINISTER permission"); + return; + } + + if (req.getContentLength() <= 0) { + res.setStatus(HttpServletResponse.SC_BAD_REQUEST); + res.setContentType("application/json; charset=utf-8"); + JSONArray errors = new JSONArray(); + JSONObject error = new JSONObject(); + error.put("line", -1); + error.put("message", "Request body cannot be empty."); + errors.add(error); + res.getWriter().print(errors); + return; + } + + try { + configureWith(YamlSource.of(req)); + res.setStatus(HttpServletResponse.SC_OK); + res.setContentType("text/plain; charset=utf-8"); + res.getWriter().print("Configuration successfully applied."); + + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Failed to apply configuration from POST payload", e); + res.setStatus(HttpServletResponse.SC_BAD_REQUEST); + res.setContentType("application/json; charset=utf-8"); + + JSONArray errors = new JSONArray(); + JSONObject error = new JSONObject(); + error.put("line", -1); + error.put("message", e.getMessage()); + errors.add(error); + + res.getWriter().print(errors); + } + } } diff --git a/plugin/src/test/java/io/jenkins/plugins/casc/ConfigurationAsCodeApiTest.java b/plugin/src/test/java/io/jenkins/plugins/casc/ConfigurationAsCodeApiTest.java new file mode 100644 index 0000000000..b69e8774e0 --- /dev/null +++ b/plugin/src/test/java/io/jenkins/plugins/casc/ConfigurationAsCodeApiTest.java @@ -0,0 +1,162 @@ +package io.jenkins.plugins.casc; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; + +import java.net.URL; +import jenkins.model.Jenkins; +import org.htmlunit.HttpMethod; +import org.htmlunit.WebRequest; +import org.htmlunit.WebResponse; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.MockAuthorizationStrategy; + +public class ConfigurationAsCodeApiTest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + private static final String ENDPOINT = "configuration-as-code/configure"; + private static final String YAML_CONTENT_TYPE = "application/yaml"; + private static final String ADMIN = "admin"; + + private WebRequest webRequest(HttpMethod method) throws Exception { + return new WebRequest(new URL(j.getURL(), ENDPOINT), method); + } + + private WebRequest yamlPost(String requestBody) throws Exception { + WebRequest request = webRequest(HttpMethod.POST); + request.setAdditionalHeader("Content-Type", YAML_CONTENT_TYPE); + if (requestBody != null) { + request.setRequestBody(requestBody); + } + return request; + } + + private void configureAdminSecurity() { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy() + .grant(Jenkins.ADMINISTER) + .everywhere() + .to(ADMIN)); + } + + @Test + public void testDoConfigure_RequiresPost() throws Exception { + try (JenkinsRule.WebClient wc = j.createWebClient()) { + wc.setThrowExceptionOnFailingStatusCode(false); + + WebResponse response = wc.getPage(webRequest(HttpMethod.GET)).getWebResponse(); + assertThat(response.getStatusCode(), is(405)); + } + } + + @Test + public void testDoConfigure_Success() throws Exception { + configureAdminSecurity(); + + try (JenkinsRule.WebClient wc = j.createWebClient().withBasicApiToken(ADMIN)) { + wc.setThrowExceptionOnFailingStatusCode(false); + + WebResponse response = wc.getPage(yamlPost("jenkins:\n systemMessage: 'Webhook Success'")) + .getWebResponse(); + + assertThat(response.getStatusCode(), is(200)); + assertThat(j.jenkins.getSystemMessage(), is("Webhook Success")); + } + } + + @Test + public void testDoConfigure_InvalidYaml() throws Exception { + configureAdminSecurity(); + + try (JenkinsRule.WebClient wc = j.createWebClient().withBasicApiToken(ADMIN)) { + wc.setThrowExceptionOnFailingStatusCode(false); + + WebResponse response = + wc.getPage(yamlPost("jenkins:\n systemMessage: [invalid")).getWebResponse(); + + assertThat(response.getStatusCode(), is(400)); + assertThat(response.getContentAsString(), containsString("message")); + } + } + + @Test + public void testDoConfigure_NonAdminForbidden() throws Exception { + try (JenkinsRule.WebClient wc = j.createWebClient().withBasicApiToken("user")) { + wc.setThrowExceptionOnFailingStatusCode(false); + + WebRequest request = yamlPost("jenkins:\n systemMessage: 'fail'"); + + WebResponse response = wc.getPage(request).getWebResponse(); + assertThat(response.getStatusCode(), is(403)); + } + } + + @Test + public void testDoConfigure_Unauthenticated() throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + j.jenkins.setAuthorizationStrategy( + new MockAuthorizationStrategy().grant(Jenkins.READ).everywhere().toEveryone()); + + try (JenkinsRule.WebClient wc = j.createWebClient()) { + wc.setThrowExceptionOnFailingStatusCode(false); + + WebRequest request = yamlPost("jenkins:\n systemMessage: 'anonymous bypass attempt'"); + + WebResponse response = wc.getPage(request).getWebResponse(); + assertThat(response.getStatusCode(), is(403)); + } + } + + @Test + public void testDoConfigure_EmptyBody() throws Exception { + configureAdminSecurity(); + + try (JenkinsRule.WebClient wc = j.createWebClient().withBasicApiToken(ADMIN)) { + wc.setThrowExceptionOnFailingStatusCode(false); + + WebResponse response = wc.getPage(yamlPost(null)).getWebResponse(); + + assertThat(response.getStatusCode(), is(400)); + assertThat(response.getContentAsString(), containsString("message")); + } + } + + @Test + public void testDoConfigure_MalformedStructure() throws Exception { + configureAdminSecurity(); + + try (JenkinsRule.WebClient wc = j.createWebClient().withBasicApiToken(ADMIN)) { + wc.setThrowExceptionOnFailingStatusCode(false); + + WebResponse response = wc.getPage(yamlPost("jenkins:\n invalidRoot:\n foo: bar")) + .getWebResponse(); + + assertThat(response.getStatusCode(), is(400)); + assertThat(response.getContentAsString(), containsString("message")); + } + } + + @Test + public void testDoConfigure_ValidYaml_NoChanges() throws Exception { + configureAdminSecurity(); + + try (JenkinsRule.WebClient wc = j.createWebClient().withBasicApiToken(ADMIN)) { + wc.setThrowExceptionOnFailingStatusCode(false); + + WebResponse response1 = wc.getPage(yamlPost("jenkins:\n systemMessage: 'Idempotency Test'")) + .getWebResponse(); + assertThat(response1.getStatusCode(), is(200)); + assertThat(j.jenkins.getSystemMessage(), is("Idempotency Test")); + + WebResponse response2 = wc.getPage(yamlPost("jenkins:\n systemMessage: 'Idempotency Test'")) + .getWebResponse(); + assertThat(response2.getStatusCode(), is(200)); + assertThat(j.jenkins.getSystemMessage(), is("Idempotency Test")); + } + } +}