Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example.solidconnection.alarm.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "discord.db-backup-fail-alarm")
public record DbBackupAlarmProperties(
String webhookUrl,
String mentionRoleId
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.solidconnection.alarm.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "internal-alarm")
public record InternalAlarmAuthProperties(
String token
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.example.solidconnection.alarm.controller;

import com.example.solidconnection.alarm.dto.DbBackupAlarmRequest;
import com.example.solidconnection.alarm.service.DbBackupAlarmService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/internal/alarms")
@RequiredArgsConstructor
public class DbBackupAlarmController {

private static final String INTERNAL_ALARM_TOKEN_HEADER = "X-Internal-Alarm-Token";

private final DbBackupAlarmService dbBackupAlarmService;

// DB EC2 의 백업 실패 이벤트를 받아 Discord 로 알리는 내부 전용 api
@PostMapping("/db-backup")
public ResponseEntity<Void> alarmBackupFailure(
@RequestHeader(value = INTERNAL_ALARM_TOKEN_HEADER, required = false) String token,
@Valid @RequestBody DbBackupAlarmRequest dbBackupAlarmRequest
) {
dbBackupAlarmService.alarmBackupFailure(token, dbBackupAlarmRequest);
return ResponseEntity.accepted().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.example.solidconnection.alarm.domain;

import lombok.Getter;

@Getter
public enum DbBackupAlarmType {

DUMP_FAILED("전체 덤프 실패"),
BINLOG_UPLOAD_FAILED("바이너리 로그 업로드 실패"),
BINLOG_GAP_DETECTED("바이너리 로그 누락"),
BINLOG_UPLOAD_DELAYED("바이너리 로그 업로드 지연"),
;

private final String displayName;

DbBackupAlarmType(String displayName) {
this.displayName = displayName;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.example.solidconnection.alarm.dto;

import com.example.solidconnection.alarm.domain.DbBackupAlarmType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;

public record DbBackupAlarmRequest(

@NotNull
DbBackupAlarmType type,

@NotBlank
@Size(max = 64)
String instanceId,

@NotNull
Instant occurredAt,

@Size(max = 1000)
String detail
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package com.example.solidconnection.alarm.service;

import static com.example.solidconnection.common.exception.ErrorCode.INTERNAL_ALARM_UNAUTHORIZED;

import com.example.solidconnection.alarm.config.DbBackupAlarmProperties;
import com.example.solidconnection.alarm.config.InternalAlarmAuthProperties;
import com.example.solidconnection.alarm.dto.DbBackupAlarmRequest;
import com.example.solidconnection.common.discord.DiscordWebhookSender;
import com.example.solidconnection.common.exception.CustomException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

/*
* - DB EC2 는 private subnet 에 있어 Discord 로 직접 요청할 수 없다.
* - 따라서 백업 실패 이벤트를 전달받아 Discord 로 중계한다.
* */
@Service
@RequiredArgsConstructor
@Slf4j
public class DbBackupAlarmService {

private static final String SUPPRESSION_KEY_PREFIX = "db-backup-alarm:";
private static final Duration SUPPRESSION_TTL = Duration.ofMinutes(10);
private static final String ROLE_MENTION_FORMAT = "<@&%s>";
private static final String EMPTY_DETAIL = "-";

private final DiscordWebhookSender discordWebhookSender;
private final DbBackupAlarmProperties dbBackupAlarmProperties;
private final InternalAlarmAuthProperties internalAlarmAuthProperties;
private final RedisTemplate<String, String> redisTemplate;

@Value("${spring.profiles.active:}")
private String environment;

public void alarmBackupFailure(String token, DbBackupAlarmRequest request) {
validateToken(token);

String suppressionKey = buildSuppressionKey(request);
if (isSuppressed(suppressionKey)) {
return;
}
boolean isSent = discordWebhookSender.send(
dbBackupAlarmProperties.webhookUrl(),
buildMessage(request),
mentionableRoleIds()
);
if (!isSent) {
releaseSuppression(suppressionKey);
}
Comment thread
lsy1307 marked this conversation as resolved.
}

/*
* - 토큰이 설정되지 않은 환경에서는 모든 요청을 거부한다.
* - 설정 누락과 토큰 불일치를 같은 응답으로 처리해 내부 상태가 드러나지 않게 한다.
* */
private void validateToken(String token) {
String configuredToken = internalAlarmAuthProperties.token();
if (configuredToken == null || configuredToken.isBlank()) {
log.error("내부 알림 인증 토큰이 설정되지 않아 요청을 거부했습니다.");
throw new CustomException(INTERNAL_ALARM_UNAUTHORIZED);
}
if (token == null || !MessageDigest.isEqual(
token.getBytes(StandardCharsets.UTF_8),
configuredToken.getBytes(StandardCharsets.UTF_8))) {
throw new CustomException(INTERNAL_ALARM_UNAUTHORIZED);
}
}

private String buildSuppressionKey(DbBackupAlarmRequest request) {
return SUPPRESSION_KEY_PREFIX + request.type().name() + ":" + request.instanceId();
}

/*
* - 같은 유형과 인스턴스의 알림이 반복되면 일정 시간 동안 전송하지 않는다.
* - Redis 를 사용할 수 없을 때는 알림 누락을 막기 위해 억제하지 않는다.
* */
private boolean isSuppressed(String suppressionKey) {
try {
Boolean isFirstAlarm = redisTemplate.opsForValue().setIfAbsent(suppressionKey, "1", SUPPRESSION_TTL);
return !Boolean.TRUE.equals(isFirstAlarm);
} catch (Exception e) {
log.error("백업 알림 중복 억제 상태를 확인하지 못해 알림을 그대로 전송합니다. key={}", suppressionKey, e);
return false;
}
}

private String buildMessage(DbBackupAlarmRequest request) {
return buildRoleMention() + "[%s] MySQL 백업 알림: %s\n인스턴스: %s\n발생 시각: %s\n상세: %s"
.formatted(
environment.toUpperCase(),
request.type().getDisplayName(),
request.instanceId(),
request.occurredAt(),
resolveDetail(request.detail())
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

/*
* - 멘션할 역할이 설정되지 않으면 멘션 없이 알림만 보낸다.
* */
private String buildRoleMention() {
String mentionRoleId = dbBackupAlarmProperties.mentionRoleId();
if (mentionRoleId == null || mentionRoleId.isBlank()) {
return "";
}
return ROLE_MENTION_FORMAT.formatted(mentionRoleId) + "\n";
}

private String resolveDetail(String detail) {
if (detail == null || detail.isBlank()) {
return EMPTY_DETAIL;
}
return detail;
}

private List<String> mentionableRoleIds() {
String mentionRoleId = dbBackupAlarmProperties.mentionRoleId();
if (mentionRoleId == null || mentionRoleId.isBlank()) {
return List.of();
}
return List.of(mentionRoleId);
}

/*
* - 전송에 실패하면 억제 상태를 되돌려 다음 백업 주기의 알림이 막히지 않게 한다.
* */
private void releaseSuppression(String suppressionKey) {
try {
redisTemplate.delete(suppressionKey);
} catch (Exception e) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
log.error("백업 알림 중복 억제 상태를 해제하지 못했습니다. key={}", suppressionKey, e);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,19 @@
package com.example.solidconnection.common.discord;

import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

@Component
@RequiredArgsConstructor
@EnableAsync
@Slf4j
public class DiscordNotifier {

private static final String ADMIN_PAGE_URL = "https://admins.solid-connection.com";

private final RestTemplate restTemplate;
private final DiscordWebhookSender discordWebhookSender;

@Value("${discord.webhook-url:}")
private String webhookUrl;
Expand All @@ -33,14 +26,7 @@ public void notify(DiscordNotificationType type, String applicantInfo) {
if (webhookUrl.isBlank()) {
return;
}
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, String>> request = new HttpEntity<>(Map.of("content", buildMessage(type, applicantInfo)), headers);
restTemplate.postForEntity(webhookUrl, request, Void.class);
} catch (Exception e) {
log.error("Discord 검수 알림 전송 실패. type={}, applicantInfo={}", type, applicantInfo, e);
}
discordWebhookSender.send(webhookUrl, buildMessage(type, applicantInfo));
}

private String buildMessage(DiscordNotificationType type, String applicantInfo) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.example.solidconnection.common.discord;

import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

/*
* - Discord Webhook 으로 메시지를 전송한다.
* - 알림 전송 실패가 호출한 기능을 실패시키지 않도록 예외를 격리하고, 전송 여부를 반환해 후속 처리를 맡긴다.
* - 채널별로 webhook url 이 다르므로 url 을 인자로 받는다.
* */
@Component
@RequiredArgsConstructor
@Slf4j
public class DiscordWebhookSender {

private final RestTemplate restTemplate;

public boolean send(String webhookUrl, String content) {
return send(webhookUrl, content, List.of());
}

/*
* - mentionableRoleIds 에 지정한 역할만 멘션할 수 있다.
* - @everyone 과 @here 는 항상 차단되므로 content 에 섞여 들어와도 채널 전체를 호출하지 않는다.
* */
public boolean send(String webhookUrl, String content, List<String> mentionableRoleIds) {
if (webhookUrl == null || webhookUrl.isBlank()) {
log.error("Discord webhook url 이 설정되지 않아 알림을 전송하지 못했습니다.");
return false;
}
try {
restTemplate.postForEntity(webhookUrl, buildRequest(content, mentionableRoleIds), Void.class);
return true;
} catch (Exception e) {
log.error("Discord 알림 전송에 실패했습니다.", e);
return false;
}
}
Comment thread
lsy1307 marked this conversation as resolved.

private HttpEntity<Map<String, Object>> buildRequest(String content, List<String> mentionableRoleIds) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> body = Map.of(
"content", content,
"allowed_mentions", Map.of(
"parse", List.of(),
"roles", mentionableRoleIds
)
);
return new HttpEntity<>(body, headers);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ public enum ErrorCode {
// import
INVALID_MARKDOWN_FORMAT(HttpStatus.BAD_REQUEST.value(), "올바른 마크다운 표 형식이 아닙니다."),

// internal alarm
INTERNAL_ALARM_UNAUTHORIZED(HttpStatus.UNAUTHORIZED.value(), "요청을 인증할 수 없습니다."),

// general
JSON_PARSING_FAILED(HttpStatus.BAD_REQUEST.value(), "JSON 파싱을 할 수 없습니다."),
JWT_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "JWT 토큰을 처리할 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers("/connect/**").authenticated()
.requestMatchers("/admin/auth/**").permitAll()
.requestMatchers("/admin/**").hasRole(ADMIN.name())
// 내부 전용 경로는 사용자 토큰이 없는 인프라가 호출하므로 공유 토큰으로 직접 인증하고, 외부 접근은 nginx 에서 차단한다.
.requestMatchers("/internal/**").permitAll()
.anyRequest().permitAll()
)
.exceptionHandling(exception -> exception
Expand Down
Loading
Loading