Allow triggering reload with pre-shared secret (#712)
This commit is contained in:
parent
15e78611e4
commit
791fccccee
16
README.md
16
README.md
|
|
@ -324,6 +324,22 @@ Kubernetes users:
|
|||
|
||||
Most plugins should be supported out-of-the-box, or maybe require some minimal changes. See this [dashboard](https://issues.jenkins.io/secure/Dashboard.jspa?selectPageId=18341) for known compatibility issues.
|
||||
|
||||
## Triggering Configuration Reload
|
||||
|
||||
You have the following option to trigger a configuration reload:
|
||||
|
||||
- via the user interface: `Manage Jenkins -> Configuration -> Reload existing configuration`
|
||||
- via http POST to `JENKINS_URL/configuration-as-code/reload`
|
||||
Note: this needs to include a valid CRUMB and authentication information e.g. username + token of a user with admin
|
||||
permissions. Since Jenkins 2.96 CRUMB is not needed for API tokens.
|
||||
- via Jenkins CLI
|
||||
- via http POST to `JENKINS_URL/reload-configuration-as-code`
|
||||
It's disabled by default and secured via a token configured as system property `casc.reload.token`.
|
||||
Setting the system property enables this functionality and the requests need to include the token as
|
||||
query parameter named `casc-reload-token`, i.e. `JENKINS_URL/reload-configuration-as-code/?casc-reload-token=32424324rdsadsa`.
|
||||
|
||||
`curl -X POST "JENKINS_URL:8080/reload-configuration-as-code/?casc-reload-token=32424324rdsadsa"`
|
||||
|
||||
## Jenkins Enhancement Proposal
|
||||
|
||||
As configuration as code is demonstrated to be a highly requested topic in Jenkins community, we have published
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
package io.jenkins.plugins.casc;
|
||||
|
||||
import com.google.common.base.Strings;
|
||||
import hudson.Extension;
|
||||
import hudson.model.UnprotectedRootAction;
|
||||
import java.io.IOException;
|
||||
import java.util.logging.Logger;
|
||||
import javax.annotation.CheckForNull;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import org.apache.commons.httpclient.HttpStatus;
|
||||
import org.kohsuke.stapler.StaplerRequest;
|
||||
import org.kohsuke.stapler.StaplerResponse;
|
||||
import org.kohsuke.stapler.interceptor.RequirePOST;
|
||||
|
||||
@Extension
|
||||
public class TokenReloadAction implements UnprotectedRootAction {
|
||||
public static final Logger LOGGER = Logger.getLogger(TokenReloadAction.class.getName());
|
||||
|
||||
public static final String URL_NAME = "reload-configuration-as-code";
|
||||
public static final String RELOAD_TOKEN_PROPERTY = "casc.reload.token";
|
||||
public static final String RELOAD_TOKEN_QUERY_PARAMETER = "casc-reload-token";
|
||||
|
||||
@CheckForNull
|
||||
@Override
|
||||
public String getIconFileName() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@CheckForNull
|
||||
@Override
|
||||
public String getDisplayName() {
|
||||
return "Reload Configuration as Code";
|
||||
}
|
||||
|
||||
@CheckForNull
|
||||
@Override
|
||||
public String getUrlName() {
|
||||
return URL_NAME;
|
||||
}
|
||||
|
||||
@RequirePOST
|
||||
public void doIndex(StaplerRequest request, StaplerResponse response) throws IOException {
|
||||
String token = getReloadTokenProperty();
|
||||
|
||||
if (Strings.isNullOrEmpty(token)) {
|
||||
response.sendError(HttpStatus.SC_NOT_FOUND);
|
||||
LOGGER.warning("Configuration reload via token is not enabled");
|
||||
} else {
|
||||
String requestToken = getRequestToken(request);
|
||||
|
||||
if (token.equals(requestToken)) {
|
||||
LOGGER.info("Configuration reload triggered via token");
|
||||
ConfigurationAsCode.get().configure();
|
||||
} else {
|
||||
response.sendError(HttpStatus.SC_UNAUTHORIZED);
|
||||
LOGGER.warning("Invalid token received, not reloading configuration");
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String getRequestToken(HttpServletRequest request) {
|
||||
return request.getParameter(RELOAD_TOKEN_QUERY_PARAMETER);
|
||||
}
|
||||
|
||||
private static String getReloadTokenProperty() {
|
||||
return System.getProperty(RELOAD_TOKEN_PROPERTY);
|
||||
}
|
||||
|
||||
public static boolean tokenReloadEnabled() {
|
||||
return !Strings.isNullOrEmpty(getReloadTokenProperty());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package io.jenkins.plugins.casc;
|
||||
|
||||
import hudson.Extension;
|
||||
import hudson.security.csrf.CrumbExclusion;
|
||||
import java.io.IOException;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
@Extension
|
||||
public class TokenReloadCrumbExclusion extends CrumbExclusion {
|
||||
|
||||
@Override
|
||||
public boolean process(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
|
||||
|
||||
if (TokenReloadAction.tokenReloadEnabled()) {
|
||||
String pathInfo = request.getPathInfo();
|
||||
if (pathInfo != null && pathInfo.equals("/" + TokenReloadAction.URL_NAME + "/")) {
|
||||
chain.doFilter(request, response);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
package io.jenkins.plugins.casc;
|
||||
|
||||
import io.jenkins.plugins.casc.misc.JenkinsConfiguredWithCodeRule;
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.LogRecord;
|
||||
import org.apache.commons.httpclient.HttpStatus;
|
||||
import org.eclipse.jetty.server.Request;
|
||||
import org.eclipse.jetty.server.Response;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.contrib.java.lang.system.RestoreSystemProperties;
|
||||
import org.jvnet.hudson.test.LoggerRule;
|
||||
import org.kohsuke.stapler.RequestImpl;
|
||||
import org.kohsuke.stapler.ResponseImpl;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class TokenReloadActionTest {
|
||||
|
||||
private Date lastTimeLoaded;
|
||||
|
||||
private TokenReloadAction tokenReloadAction;
|
||||
|
||||
@Rule
|
||||
public final RestoreSystemProperties restoreSystemProperties = new RestoreSystemProperties();
|
||||
|
||||
@Rule
|
||||
public final LoggerRule loggerRule = new LoggerRule();
|
||||
|
||||
@Rule
|
||||
public JenkinsConfiguredWithCodeRule j = new JenkinsConfiguredWithCodeRule();
|
||||
|
||||
private ServletResponseSpy response;
|
||||
|
||||
private class ServletResponseSpy extends Response {
|
||||
private int error = HttpStatus.SC_OK;
|
||||
|
||||
public ServletResponseSpy() {
|
||||
super(null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendError(int sc) throws IOException {
|
||||
error = sc;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public int getStatus() {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
private class RequestStub extends Request {
|
||||
private final String authorization;
|
||||
|
||||
public RequestStub(String authorization) {
|
||||
super(null, null);
|
||||
this.authorization = authorization;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getParameter(String name) {
|
||||
if (TokenReloadAction.RELOAD_TOKEN_QUERY_PARAMETER.equals(name)) {
|
||||
return authorization;
|
||||
}
|
||||
return super.getHeader(name); }
|
||||
}
|
||||
|
||||
private RequestImpl newRequest(String authorization) {
|
||||
return new RequestImpl(null, new RequestStub(authorization), Collections.emptyList(), null);
|
||||
}
|
||||
|
||||
private boolean configWasReloaded() {
|
||||
return !lastTimeLoaded.equals(ConfigurationAsCode.get().getLastTimeLoaded());
|
||||
}
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
tokenReloadAction = new TokenReloadAction();
|
||||
response = new ServletResponseSpy();
|
||||
loggerRule.record(TokenReloadAction.class, Level.ALL);
|
||||
loggerRule.capture(3);
|
||||
lastTimeLoaded = ConfigurationAsCode.get().getLastTimeLoaded();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reloadIsDisabledByDefault() throws IOException {
|
||||
System.clearProperty("casc.reload.token");
|
||||
|
||||
RequestImpl request = newRequest(null);
|
||||
tokenReloadAction.doIndex(request, new ResponseImpl(null, response));
|
||||
|
||||
assertEquals(HttpStatus.SC_NOT_FOUND, response.getStatus());
|
||||
|
||||
List<LogRecord> messages = loggerRule.getRecords();
|
||||
assertEquals(1, messages.size());
|
||||
assertEquals("Configuration reload via token is not enabled", messages.get(0).getMessage());
|
||||
assertEquals(Level.WARNING, messages.get(0).getLevel());
|
||||
assertFalse(configWasReloaded());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reloadReturnsUnauthorizedIfTokenDoesNotMatch() throws IOException {
|
||||
System.setProperty("casc.reload.token", "someSecretValue");
|
||||
|
||||
RequestImpl request = newRequest(null);
|
||||
tokenReloadAction.doIndex(request, new ResponseImpl(null, response));
|
||||
|
||||
assertEquals(HttpStatus.SC_UNAUTHORIZED, response.getStatus());
|
||||
|
||||
assertFalse(configWasReloaded());
|
||||
|
||||
List<LogRecord> messages = loggerRule.getRecords();
|
||||
assertEquals(1, messages.size());
|
||||
assertEquals("Invalid token received, not reloading configuration", messages.get(0).getMessage());
|
||||
assertEquals(Level.WARNING, messages.get(0).getLevel());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reloadReturnsOkWhenCalledWithValidToken() throws IOException {
|
||||
System.setProperty("casc.reload.token", "someSecretValue");
|
||||
|
||||
tokenReloadAction.doIndex(newRequest("someSecretValue"), new ResponseImpl(null, response));
|
||||
|
||||
assertEquals(HttpStatus.SC_OK, response.getStatus());
|
||||
|
||||
assertTrue(configWasReloaded());
|
||||
|
||||
List<LogRecord> messages = loggerRule.getRecords();
|
||||
assertEquals(1, messages.size());
|
||||
assertEquals("Configuration reload triggered via token", messages.get(0).getMessage());
|
||||
assertEquals(Level.INFO, messages.get(0).getLevel());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package io.jenkins.plugins.casc;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.eclipse.jetty.server.Request;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.contrib.java.lang.system.RestoreSystemProperties;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class TokenReloadCrumbExclusionTest {
|
||||
|
||||
@Rule
|
||||
public final RestoreSystemProperties restoreSystemProperties = new RestoreSystemProperties();
|
||||
|
||||
private Request newRequestWithPath(String hello) {
|
||||
return new Request(null, null) {
|
||||
@Override
|
||||
public String getPathInfo() {
|
||||
return hello;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Test
|
||||
public void crumbExclusionIsDisabledByDefault() throws Exception {
|
||||
System.clearProperty("casc.reload.token");
|
||||
|
||||
TokenReloadCrumbExclusion crumbExclusion = new TokenReloadCrumbExclusion();
|
||||
|
||||
assertFalse(crumbExclusion.process(newRequestWithPath("/reload-configuration-as-code/"), null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void crumbExclusionChecksRequestPath() throws Exception {
|
||||
System.setProperty("casc.reload.token", "someSecretValue");
|
||||
|
||||
TokenReloadCrumbExclusion crumbExclusion = new TokenReloadCrumbExclusion();
|
||||
|
||||
assertFalse(crumbExclusion.process(newRequestWithPath("/reload-configuration-as-code/2"), null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void crumbExclustionAllowsReloadIfEnabledAndRequestPathMatch() throws Exception {
|
||||
System.setProperty("casc.reload.token", "someSecretValue");
|
||||
|
||||
TokenReloadCrumbExclusion crumbExclusion = new TokenReloadCrumbExclusion();
|
||||
|
||||
AtomicBoolean callProcessed = new AtomicBoolean(false);
|
||||
assertTrue(crumbExclusion.process(newRequestWithPath("/reload-configuration-as-code/"), null, (request, response) -> callProcessed.set(true)));
|
||||
|
||||
assertTrue(callProcessed.get());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue