]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-18484 - Add api/v2/system/health endpoint
authorAntoine Vigneau <antoine.vigneau@sonarsource.com>
Fri, 17 Feb 2023 09:33:06 +0000 (10:33 +0100)
committersonartech <sonartech@sonarsource.com>
Tue, 21 Feb 2023 12:02:56 +0000 (12:02 +0000)
server/sonar-webserver-webapi-v2/build.gradle
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/SafeModeWebConfig.java
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/HealthController.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/controller/DefautLivenessControllerTest.java
server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/controller/HealthControllerTest.java [new file with mode: 0644]

index 6366dad07d7cbfd237363536e7d5876c969865a1..c206e6616a270d2a0856371d667e33b0994582e3 100644 (file)
@@ -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'))
 
index dd6fd559af015fda4abd6eb0fa38d84f475c9873..b0280a7746dd17f190b8ae4e953f782216ade39d 100644 (file)
 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() {
   }
index 45153c377cc3422d57fd795bcb6d8e66bb9f60f5..79d5a93f62d957c9f5134c165e18e12311101c73 100644 (file)
@@ -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);
+  }
 }
index 9fb7f717c6a0dca1b072cd8f9ff7f0d083dcd03a..897cc07abb9043ab23d65373c42fa4b7554417ea 100644 (file)
 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 (file)
index 0000000..f5d65d7
--- /dev/null
@@ -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();
+  }
+}
index c7c4762466b5e5b41d20794a2e7024c8ea489a6d..b9d031cf1fbb5fee87ee6a623e5f981dd5cdbb00 100644 (file)
@@ -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 (file)
index 0000000..b7e62f4
--- /dev/null
@@ -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"));
+  }
+}