[JENKINS-75530] Implement dedicated healthcheck endpoint

This commit is contained in:
Vincent Latombe 2025-04-08 15:55:14 +02:00
parent 4bd12d8549
commit 7488f84d5d
No known key found for this signature in database
GPG Key ID: B8C3AD051BE6D52F
3 changed files with 237 additions and 0 deletions

View File

@ -0,0 +1,25 @@
package jenkins.health;
import hudson.ExtensionPoint;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.Beta;
/**
* Defines a health check that is critical to the operation of Jenkins.
* @since XXX
*/
@Restricted(Beta.class)
public interface HealthCheck extends ExtensionPoint {
/**
* @return the name of the health check. Must be unique among health check implementations.
*/
default String getName() {
return getClass().getName();
}
/**
* @return true if the health check passed.
*/
boolean check();
}

View File

@ -0,0 +1,58 @@
package jenkins.health;
import hudson.Extension;
import hudson.ExtensionList;
import hudson.init.InitMilestone;
import hudson.init.Initializer;
import hudson.model.InvisibleAction;
import hudson.model.UnprotectedRootAction;
import java.util.HashMap;
import java.util.logging.Logger;
import net.sf.json.JSONArray;
import net.sf.json.JSONObject;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.DoNotUse;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.json.JsonHttpResponse;
/**
* Provides a health check action for Jenkins.
*/
@Extension
@Restricted(DoNotUse.class)
public final class HealthCheckAction extends InvisibleAction implements UnprotectedRootAction {
private static final Logger LOGGER = Logger.getLogger(HealthCheckAction.class.getName());
@Override
public String getUrlName() {
return "healthCheck";
}
@Initializer(after = InitMilestone.EXTENSIONS_AUGMENTED)
public static void init() {
var names = new HashMap<String, String>();
for (var healthCheck : ExtensionList.lookup(HealthCheck.class)) {
var name = healthCheck.getName();
var previousValue = names.put(name, healthCheck.getClass().getName());
if (previousValue != null) {
LOGGER.warning(() -> "Ignoring duplicate health check with name " + name + " from " + healthCheck.getClass().getName() + " as it is already defined by " + previousValue);
}
}
}
public HttpResponse doIndex() {
boolean success = true;
var checks = new JSONArray();
var names = new HashMap<String, String>();
for (var healthCheck : ExtensionList.lookup(HealthCheck.class)) {
var check = healthCheck.check();
var name = healthCheck.getName();
var previousValue = names.put(name, healthCheck.getClass().getName());
if (previousValue == null) {
success &= check;
checks.add(new JSONObject().element("name", name).element("result", check));
}
}
return new JsonHttpResponse(new JSONObject().element("status", success).element("data", checks), success ? 200 : 503);
}
}

View File

@ -0,0 +1,154 @@
package jenkins.health;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertEquals;
import java.util.logging.Level;
import net.sf.json.JSONArray;
import net.sf.json.JSONObject;
import org.junit.Rule;
import org.junit.Test;
import org.jvnet.hudson.test.JenkinsRule;
import org.jvnet.hudson.test.LoggerRule;
import org.jvnet.hudson.test.TestExtension;
public class HealthCheckActionTest {
@Rule
public JenkinsRule r = new JenkinsRule();
@Rule
public LoggerRule loggingRule = new LoggerRule().record(HealthCheckAction.class, Level.WARNING).capture(10);
@Test
public void healthCheck() throws Exception {
try (var webClient = r.createWebClient()) {
var page = webClient.goTo("healthCheck", "application/json");
assertThat(page.getWebResponse().getStatusCode(), is(200));
assertEquals(new JSONObject().element("status", true).element("data", new JSONArray()), JSONObject.fromObject(page.getWebResponse().getContentAsString()));
}
}
@Test
public void healthCheckSuccessExtension() throws Exception {
try (var webClient = r.createWebClient()) {
var page = webClient.goTo("healthCheck", "application/json");
assertThat(page.getWebResponse().getStatusCode(), is(200));
assertEquals(JSONObject.fromObject("""
{
"status": true,
"data": [
{
"name": "success",
"result": true
}
]
}
"""), JSONObject.fromObject(page.getWebResponse().getContentAsString()));
}
}
@TestExtension({"healthCheckSuccessExtension", "healthCheckFailingExtension"})
public static class SuccessHealthCheck implements HealthCheck {
@Override
public String getName() {
return "success";
}
@Override
public boolean check() {
return true;
}
}
@Test
public void healthCheckFailingExtension() throws Exception {
try (var webClient = r.createWebClient()) {
webClient.getOptions().setThrowExceptionOnFailingStatusCode(false);
webClient.getOptions().setPrintContentOnFailingStatusCode(false);
var page = webClient.goTo("healthCheck", "application/json");
assertThat(page.getWebResponse().getStatusCode(), is(503));
assertEquals(JSONObject.fromObject("""
{
"status": false,
"data": [
{
"name": "failing",
"result": false
},
{
"name": "success",
"result": true
}
]
}
"""), JSONObject.fromObject(page.getWebResponse().getContentAsString()));
}
}
@TestExtension("healthCheckFailingExtension")
public static class FailingHealthCheck implements HealthCheck {
@Override
public String getName() {
return "failing";
}
@Override
public boolean check() {
return false;
}
}
@Test
public void duplicateHealthCheckExtension() throws Exception {
try (var webClient = r.createWebClient()) {
var page = webClient.goTo("healthCheck", "application/json");
assertThat(page.getWebResponse().getStatusCode(), is(200));
assertEquals(JSONObject.fromObject("""
{
"status": true,
"data": [
{
"name": "dupe",
"result": true
}
]
}
"""), JSONObject.fromObject(page.getWebResponse().getContentAsString()));
}
// TestExtension doesn't have ordinal, but I think by default extensions are ordered alphabetically
assertThat(loggingRule.getMessages(), contains("Ignoring duplicate health check with name dupe from " + HealthCheck2.class.getName() + " as it is already defined by " + HealthCheck1.class.getName()));
}
@TestExtension("duplicateHealthCheckExtension")
public static class HealthCheck1 implements HealthCheck {
@Override
public String getName() {
return "dupe";
}
@Override
public boolean check() {
return true;
}
}
@TestExtension("duplicateHealthCheckExtension")
public static class HealthCheck2 implements HealthCheck {
@Override
public String getName() {
return "dupe";
}
@Override
public boolean check() {
return false;
}
}
}