From: Antoine Vigneau Date: Fri, 17 Feb 2023 09:33:06 +0000 (+0100) Subject: SONAR-18484 - Add api/v2/system/health endpoint X-Git-Tag: 10.0.0.68432~214 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=23fdc3b251a454441ebfe5b44c5a1e7387975df6;p=sonarqube.git SONAR-18484 - Add api/v2/system/health endpoint --- diff --git a/server/sonar-webserver-webapi-v2/build.gradle b/server/sonar-webserver-webapi-v2/build.gradle index 6366dad07d7..c206e6616a2 100644 --- a/server/sonar-webserver-webapi-v2/build.gradle +++ b/server/sonar-webserver-webapi-v2/build.gradle @@ -13,9 +13,9 @@ dependencies { // We are not suppose to have a v1 dependency. The ideal would be to have another common module between webapi and webapi-v2 but that needs a lot of refactoring. api project(':server:sonar-webserver-webapi') - testImplementation 'org.mockito:mockito-core' testImplementation 'org.springframework:spring-test' + testImplementation 'org.skyscreamer:jsonassert:1.5.1' testImplementation testFixtures(project(':server:sonar-server-common')) diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java index dd6fd559af0..b0280a7746d 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java @@ -20,8 +20,11 @@ package org.sonar.server.v2; public class WebApiEndpoints { + private static final String SYSTEM_ENDPOINTS = "/system/"; - public static final String LIVENESS_ENDPOINT = "/system/liveness"; + public static final String LIVENESS_ENDPOINT = SYSTEM_ENDPOINTS + "/liveness"; + + public static final String HEALTH_ENDPOINT = SYSTEM_ENDPOINTS + "/health"; private WebApiEndpoints() { } diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java index 45153c377cc..79d5a93f62d 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java @@ -22,12 +22,15 @@ package org.sonar.server.v2.config; import org.sonar.server.health.CeStatusNodeCheck; import org.sonar.server.health.DbConnectionNodeCheck; import org.sonar.server.health.EsStatusNodeCheck; +import org.sonar.server.health.HealthChecker; import org.sonar.server.health.WebServerStatusNodeCheck; +import org.sonar.server.platform.NodeInformation; import org.sonar.server.platform.ws.LivenessChecker; import org.sonar.server.platform.ws.LivenessCheckerImpl; import org.sonar.server.user.SystemPasscode; import org.sonar.server.user.UserSession; import org.sonar.server.v2.controller.DefautLivenessController; +import org.sonar.server.v2.controller.HealthController; import org.sonar.server.v2.controller.LivenessController; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -47,4 +50,10 @@ public class PlatformLevel4WebConfig { public LivenessController livenessController(LivenessChecker livenessChecker, UserSession userSession, SystemPasscode systemPasscode) { return new DefautLivenessController(livenessChecker, systemPasscode, userSession); } + + @Bean + public HealthController healthController(HealthChecker healthChecker, SystemPasscode systemPasscode, NodeInformation nodeInformation, + UserSession userSession) { + return new HealthController(healthChecker, systemPasscode, nodeInformation, userSession); + } } diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/SafeModeWebConfig.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/SafeModeWebConfig.java index 9fb7f717c6a..897cc07abb9 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/SafeModeWebConfig.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/SafeModeWebConfig.java @@ -20,10 +20,12 @@ package org.sonar.server.v2.config; import org.sonar.server.health.DbConnectionNodeCheck; +import org.sonar.server.health.HealthChecker; import org.sonar.server.platform.ws.LivenessChecker; import org.sonar.server.platform.ws.SafeModeLivenessCheckerImpl; import org.sonar.server.user.SystemPasscode; import org.sonar.server.v2.controller.DefautLivenessController; +import org.sonar.server.v2.controller.HealthController; import org.sonar.server.v2.controller.LivenessController; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -44,4 +46,9 @@ public class SafeModeWebConfig { public LivenessController livenessController(LivenessChecker livenessChecker, SystemPasscode systemPasscode) { return new DefautLivenessController(livenessChecker, systemPasscode, null); } + + @Bean + public HealthController healthController(HealthChecker healthChecker, SystemPasscode systemPasscode) { + return new HealthController(healthChecker, systemPasscode); + } } diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/HealthController.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/HealthController.java new file mode 100644 index 00000000000..f5d65d7fc91 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/HealthController.java @@ -0,0 +1,82 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.v2.controller; + +import org.sonar.server.exceptions.ForbiddenException; +import org.sonar.server.exceptions.ServerException; +import org.sonar.server.health.Health; +import org.sonar.server.health.HealthChecker; +import org.sonar.server.platform.NodeInformation; +import org.sonar.server.user.SystemPasscode; +import org.sonar.server.user.UserSession; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static java.net.HttpURLConnection.HTTP_NOT_IMPLEMENTED; + +/* +This controller does not support the cluster mode. +This is not the final implementation, as we have to first define what are endpoint contracts. +*/ +@RestController +@RequestMapping("/system/health") +public class HealthController { + + private final HealthChecker healthChecker; + private final SystemPasscode systemPasscode; + private final NodeInformation nodeInformation; + private final UserSession userSession; + + public HealthController(HealthChecker healthChecker, SystemPasscode systemPasscode, NodeInformation nodeInformation, + UserSession userSession) { + this.healthChecker = healthChecker; + this.systemPasscode = systemPasscode; + this.nodeInformation = nodeInformation; + this.userSession = userSession; + } + + public HealthController(HealthChecker healthChecker, SystemPasscode systemPasscode) { + this(healthChecker, systemPasscode, null, null); + } + + @GetMapping + public Health getHealth(@RequestHeader(value = "X-Sonar-Passcode", required = false) String requestPassCode) { + if (systemPasscode.isValidPasscode(requestPassCode) || isSystemAdmin()) { + return getHealth(); + } + throw new ForbiddenException("Insufficient privileges"); + } + + private Health getHealth() { + if (nodeInformation == null || nodeInformation.isStandalone()) { + return healthChecker.checkNode(); + } + throw new ServerException(HTTP_NOT_IMPLEMENTED, "Unsupported in cluster mode"); + } + + private boolean isSystemAdmin() { + if (userSession == null) { + return false; + } + return userSession.isSystemAdministrator(); + } +} diff --git a/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/controller/DefautLivenessControllerTest.java b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/controller/DefautLivenessControllerTest.java index c7c4762466b..b9d031cf1fb 100644 --- a/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/controller/DefautLivenessControllerTest.java +++ b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/controller/DefautLivenessControllerTest.java @@ -33,6 +33,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import static org.mockito.Mockito.when; +import static org.sonar.server.user.SystemPasscodeImpl.PASSCODE_HTTP_HEADER; import static org.sonar.server.v2.WebApiEndpoints.LIVENESS_ENDPOINT; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -50,15 +51,25 @@ public class DefautLivenessControllerTest { @InjectMocks private DefautLivenessController defautLivenessController; - MockMvc mockMvc; + private MockMvc mockMvc; @Before public void setUp() { - this.mockMvc = MockMvcBuilders.standaloneSetup(defautLivenessController) + mockMvc = MockMvcBuilders.standaloneSetup(defautLivenessController) .setControllerAdvice(RestResponseEntityExceptionHandler.class) .build(); } + @Test + public void livenessCheck_inSafeModeWithoutUserSessionAndPasscode_returnsForbidden() throws Exception { + LivenessController safeModeLivenessController = new DefautLivenessController(livenessChecker, systemPasscode, null); + MockMvc mockMvcSafeMode = MockMvcBuilders.standaloneSetup(safeModeLivenessController) + .setControllerAdvice(RestResponseEntityExceptionHandler.class) + .build(); + mockMvcSafeMode.perform(get(LIVENESS_ENDPOINT)) + .andExpect(status().isForbidden()); + } + @Test public void livenessCheck_should_returnForbiddenWithNoCredentials() throws Exception { mockMvc.perform(get(LIVENESS_ENDPOINT)) @@ -69,7 +80,7 @@ public class DefautLivenessControllerTest { public void livenessCheck_should_returnForbiddenWithWrongPasscodeAndNoAdminCredentials() throws Exception { when(systemPasscode.isValidPasscode(PASSCODE)).thenReturn(false); when(userSession.isSystemAdministrator()).thenReturn(false); - mockMvc.perform(get(LIVENESS_ENDPOINT).header("X-Sonar-Passcode", PASSCODE)) + mockMvc.perform(get(LIVENESS_ENDPOINT).header(PASSCODE_HTTP_HEADER, PASSCODE)) .andExpect(status().isForbidden()); } @@ -77,7 +88,7 @@ public class DefautLivenessControllerTest { public void livenessCheck_should_returnNoContentWithSystemPasscode() throws Exception { when(systemPasscode.isValidPasscode(PASSCODE)).thenReturn(true); when(livenessChecker.liveness()).thenReturn(true); - mockMvc.perform(get(LIVENESS_ENDPOINT).header("X-Sonar-Passcode", PASSCODE)) + mockMvc.perform(get(LIVENESS_ENDPOINT).header(PASSCODE_HTTP_HEADER, PASSCODE)) .andExpect(status().isNoContent()); } @@ -85,7 +96,7 @@ public class DefautLivenessControllerTest { public void livenessCheck_should_returnNoContentWithWhenUserIsAdmin() throws Exception { when(userSession.isSystemAdministrator()).thenReturn(true); when(livenessChecker.liveness()).thenReturn(true); - mockMvc.perform(get(LIVENESS_ENDPOINT).header("X-Sonar-Passcode", PASSCODE)) + mockMvc.perform(get(LIVENESS_ENDPOINT).header(PASSCODE_HTTP_HEADER, PASSCODE)) .andExpect(status().isNoContent()); } @@ -93,7 +104,7 @@ public class DefautLivenessControllerTest { public void livenessCheck_should_returnServerErrorWhenLivenessCheckFails() throws Exception { when(systemPasscode.isValidPasscode(PASSCODE)).thenReturn(true); when(livenessChecker.liveness()).thenReturn(false); - mockMvc.perform(get(LIVENESS_ENDPOINT).header("X-Sonar-Passcode", PASSCODE)) + mockMvc.perform(get(LIVENESS_ENDPOINT).header(PASSCODE_HTTP_HEADER, PASSCODE)) .andExpect(status().isInternalServerError()); } diff --git a/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/controller/HealthControllerTest.java b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/controller/HealthControllerTest.java new file mode 100644 index 00000000000..b7e62f413ce --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/controller/HealthControllerTest.java @@ -0,0 +1,157 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.v2.controller; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.sonar.server.exceptions.UnauthorizedException; +import org.sonar.server.health.Health; +import org.sonar.server.health.HealthChecker; +import org.sonar.server.platform.NodeInformation; +import org.sonar.server.user.SystemPasscode; +import org.sonar.server.user.UserSession; +import org.sonar.server.v2.common.RestResponseEntityExceptionHandler; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.Mockito.when; +import static org.sonar.server.user.SystemPasscodeImpl.PASSCODE_HTTP_HEADER; +import static org.sonar.server.v2.WebApiEndpoints.HEALTH_ENDPOINT; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@RunWith(MockitoJUnitRunner.class) +public class HealthControllerTest { + + private static final String PASSCODE = "1234"; + private static final Health HEALTH_RESULT = Health.builder(). + setStatus(Health.Status.YELLOW) + .addCause("One cause") + .build(); + @Mock + private HealthChecker healthChecker; + @Mock + private UserSession userSession; + @Mock + private SystemPasscode systemPasscode; + @Mock + private NodeInformation nodeInformation; + + private HealthController level4HealthController; + + private HealthController safeModeHealthController; + + private MockMvc level4mockMvc; + + private MockMvc safeModeMockMvc; + + @Before + public void setUp() { + level4HealthController = new HealthController(healthChecker, systemPasscode, nodeInformation, userSession); + level4mockMvc = MockMvcBuilders.standaloneSetup(level4HealthController) + .setControllerAdvice(RestResponseEntityExceptionHandler.class) + .build(); + + safeModeHealthController = new HealthController(healthChecker, systemPasscode); + safeModeMockMvc = MockMvcBuilders.standaloneSetup(safeModeHealthController) + .setControllerAdvice(RestResponseEntityExceptionHandler.class) + .build(); + + when(healthChecker.checkNode()).thenReturn(HEALTH_RESULT); + } + + @Test + public void getHealth_inSafeModeWithoutUserSessionAndPasscode_returnsForbidden() throws Exception { + safeModeMockMvc.perform(get(HEALTH_ENDPOINT)) + .andExpect(status().isForbidden()); + } + + @Test + public void getHealth_inSafeModeWithValidPasscode_succeeds() throws Exception { + when(systemPasscode.isValidPasscode(PASSCODE)).thenReturn(true); + + safeModeMockMvc.perform(get(HEALTH_ENDPOINT).header(PASSCODE_HTTP_HEADER, PASSCODE)) + .andExpect(status().isOk()); + } + + @Test + public void getHealth_should_returnForbiddenWithNoCredentials() throws Exception { + level4mockMvc.perform(get(HEALTH_ENDPOINT)) + .andExpect(status().isForbidden()); + } + + @Test + public void getHealth_should_returnForbiddenWithWrongPasscodeAndNoAdminCredentials() throws Exception { + when(systemPasscode.isValidPasscode(PASSCODE)).thenReturn(false); + when(userSession.isSystemAdministrator()).thenReturn(false); + level4mockMvc.perform(get(HEALTH_ENDPOINT).header(PASSCODE_HTTP_HEADER, PASSCODE)) + .andExpect(status().isForbidden()); + } + + @Test + public void getHealth_withValidPasscodeAndStandaloneNode_returnHealth() throws Exception { + when(systemPasscode.isValidPasscode(PASSCODE)).thenReturn(true); + when(nodeInformation.isStandalone()).thenReturn(true); + level4mockMvc.perform(get(HEALTH_ENDPOINT).header(PASSCODE_HTTP_HEADER, PASSCODE)) + .andExpect(status().isOk()) + .andExpect(content().json(""" + { + "status":"YELLOW", + "causes":[ + "One cause" + ] + }""")); + } + + @Test + public void getHealth_asSysadminAndStandaloneNode_returnHealth() throws Exception { + when(userSession.isSystemAdministrator()).thenReturn(true); + when(nodeInformation.isStandalone()).thenReturn(true); + level4mockMvc.perform(get(HEALTH_ENDPOINT)) + .andExpect(status().isOk()) + .andExpect(content().json(""" + { + "status":"YELLOW", + "causes":[ + "One cause" + ] + }""")); + } + + @Test + public void getHealth_whenUnauthorizedExceptionThrown_returnHttpUnauthorized() throws Exception { + when(userSession.isSystemAdministrator()).thenThrow(new UnauthorizedException("unauthorized")); + level4mockMvc.perform(get(HEALTH_ENDPOINT)) + .andExpect(status().isUnauthorized()); + } + + @Test + public void getHealth_should_returnServerErrorForCluster() throws Exception { + when(systemPasscode.isValidPasscode(PASSCODE)).thenReturn(true); + when(nodeInformation.isStandalone()).thenReturn(false); + level4mockMvc.perform(get(HEALTH_ENDPOINT).header(PASSCODE_HTTP_HEADER, PASSCODE)) + .andExpect(status().isNotImplemented()) + .andExpect(content().string("Unsupported in cluster mode")); + } +}