Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ Use a managed PostgreSQL database reachable from Cloud Run, commonly Cloud SQL f

The container listens on `SERVER_PORT=8080`. Liquibase runs on startup when `LIQUIBASE_ENABLED=true`, so deploy only one migration-capable revision at a time or run migrations separately for high-availability production releases.

## Health Probes

Configure the deployment platform to call these unauthenticated endpoints on the application port:

- Liveness: `GET /api/actuator/health/liveness`
- Readiness: `GET /api/actuator/health/readiness`

Both return an Actuator health response such as `{"status":"UP"}` when healthy. Liveness checks only the application process lifecycle, so a temporary database, cache, or network failure does not trigger a restart. Readiness checks the database in every environment and also Redis in staging and production, where Redis-backed rate limiting is required; it returns a non-2xx response when a required dependency is unavailable.

Only these two health probe URLs are public. The aggregate health endpoint and all other Actuator endpoints require authentication; non-health Actuator endpoints are not exposed over HTTP.

Example manual deployment after an image has been pushed:

```bash
Expand Down
4 changes: 4 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public SecurityFilterChain securityFilterChain(
.requestMatchers("/v3/api-docs/**", "/api/v3/api-docs/**").permitAll()
.requestMatchers("/swagger-ui/**", "/api/swagger-ui/**").permitAll()
.requestMatchers("/swagger-ui.html", "/api/swagger-ui.html").permitAll()
.requestMatchers("/v1/health", "/api/v1/health").permitAll()
.requestMatchers("/actuator/health/liveness", "/actuator/health/readiness").permitAll()
.requestMatchers("/v1/auth/**", "/api/v1/auth/**").permitAll()
.requestMatchers("/ws", "/ws/**").permitAll()
.requestMatchers(HttpMethod.POST, "/v1/users/register", "/api/v1/users/register").permitAll()
Expand Down
18 changes: 0 additions & 18 deletions src/main/java/com/loopin/api/common/health/HealthController.java

This file was deleted.

7 changes: 7 additions & 0 deletions src/main/resources/application-production.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,12 @@ spring:
hibernate:
format_sql: false

management:
endpoint:
health:
group:
readiness:
include: readinessState,db,redis

cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS}
7 changes: 7 additions & 0 deletions src/main/resources/application-staging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,12 @@ spring:
liquibase:
contexts: staging

management:
endpoint:
health:
group:
readiness:
include: readinessState,db,redis

cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS}
20 changes: 20 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ spring:
format_sql: ${JPA_FORMAT_SQL:false}
default_batch_fetch_size: 20

management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
# Keep health responses safe for the publicly reachable probe endpoints.
show-details: never
probes:
enabled: true
group:
# Liveness is process-only so transient database, cache, or network failures
# never cause the deployment platform to restart a healthy JVM.
liveness:
include: livenessState
# The database is required for the API to serve its core requests.
readiness:
include: readinessState,db

# Local Content Check Rule Targets
moderation:
banned-words: ${BANNED_WORDS_LIST:scam,spam,offensiveword1,offensiveword2}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.loopin.api.common.health;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.health.actuate.endpoint.HealthEndpointGroups;
import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
import org.springframework.core.io.ClassPathResource;
import org.springframework.test.web.servlet.MockMvc;

import java.util.Properties;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
@Import(ActuatorHealthEndpointTest.FailingDownstreamConfiguration.class)
class ActuatorHealthEndpointTest {

@Autowired
private MockMvc mockMvc;

@Autowired
private HealthEndpointGroups healthEndpointGroups;

@Test
void livenessIsPublicAndStaysUpWhenADownstreamDependencyFails() throws Exception {
mockMvc.perform(get("/api/actuator/health/liveness").contextPath("/api"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("UP"));
}

@Test
void readinessIsPublicAndReportsApplicationReadiness() throws Exception {
mockMvc.perform(get("/api/actuator/health/readiness").contextPath("/api"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("UP"));
}

@Test
void readinessIncludesTheDatabaseButLivenessDoesNot() {
assertTrue(healthEndpointGroups.get("readiness").isMember("db"));
assertFalse(healthEndpointGroups.get("liveness").isMember("db"));
}

@Test
void stagingAndProductionReadinessGroupsRequireRedis() {
assertEquals("readinessState,db,redis", readinessMembers("application-staging.yaml"));
assertEquals("readinessState,db,redis", readinessMembers("application-production.yaml"));
}

@Test
void aggregateHealthAndSensitiveActuatorEndpointsAreNotPublic() throws Exception {
mockMvc.perform(get("/api/actuator/health").contextPath("/api"))
.andExpect(status().isUnauthorized());

mockMvc.perform(get("/api/actuator/env").contextPath("/api"))
.andExpect(status().isUnauthorized());
}

@TestConfiguration
static class FailingDownstreamConfiguration {

@Bean
HealthIndicator temporaryDownstreamHealthIndicator() {
return () -> Health.down().withDetail("reason", "temporary failure").build();
}
}

private String readinessMembers(String resourceName) {
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
yaml.setResources(new ClassPathResource(resourceName));
Properties properties = yaml.getObject();
return properties.getProperty("management.endpoint.health.group.readiness.include");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;

import static org.hamcrest.Matchers.is;
Expand All @@ -31,6 +32,7 @@ class DevAuthIntegrationTest extends AbstractIntegrationTest {
private UserRepository userRepository;

@Autowired
@Qualifier("requestMappingHandlerMapping")
private RequestMappingHandlerMapping handlerMapping;

@BeforeEach
Expand Down
16 changes: 16 additions & 0 deletions src/test/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,22 @@ spring:
hibernate:
ddl-auto: create-drop

management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
show-details: never
probes:
enabled: true
group:
liveness:
include: livenessState
readiness:
include: readinessState,db

jwt:
secret: aVerySecretKeyThatIsAtLeast32BytesLongForHS256SignatureVerification!
expiration: 86400000
Expand Down
Loading