feat(admin): 서비스 프로세스 기동·중지·재기동 제어
- LouisVuitton에 /admin/services (상태 통합), /admin/services/{id}/start|stop|restart, /log 추가
- 중지=하트비트 pid를 ProcessHandle 강제종료+행 삭제, 기동=동일 JDK(java.home)로 jar 실행·로그 캡처
- 제어 대상 화이트리스트(Sequencer·Chanel·Dior·Hermes·Prada), ADMIN 인증+rtgs.control.enabled 게이트
- 인프라(PostgreSQL/Kafka) TCP 도달성 포함. 로컬 프로토타입 전용
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
package kr.or.bok.rtgs.louisvuitton
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 프로세스 제어 API(관리자 화면). admin 하위 경로라 기존 ADMIN JWT 인터셉터로 자동 보호.
|
||||||
|
* 제어(start·stop·restart)는 rtgs.control.enabled 및 화이트리스트(controllable)로 이중 게이트.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
class ServiceControlController(
|
||||||
|
private val control: ServiceControlService,
|
||||||
|
) {
|
||||||
|
/** 서비스 7 + 인프라 통합 상태(조회는 항상 허용). */
|
||||||
|
@GetMapping("/admin/services")
|
||||||
|
fun services(): Map<String, Any?> = control.status()
|
||||||
|
|
||||||
|
@PostMapping("/admin/services/{id}/start")
|
||||||
|
fun start(@PathVariable id: String) = guarded { control.start(id) }
|
||||||
|
|
||||||
|
@PostMapping("/admin/services/{id}/stop")
|
||||||
|
fun stop(@PathVariable id: String) = guarded { control.stop(id) }
|
||||||
|
|
||||||
|
@PostMapping("/admin/services/{id}/restart")
|
||||||
|
fun restart(@PathVariable id: String) = guarded { control.restart(id) }
|
||||||
|
|
||||||
|
@GetMapping("/admin/services/{id}/log")
|
||||||
|
fun log(@PathVariable id: String, @RequestParam(defaultValue = "120") lines: Int): Map<String, Any?> =
|
||||||
|
control.tailLog(id, lines)
|
||||||
|
|
||||||
|
private fun guarded(action: () -> Map<String, Any?>): ResponseEntity<Map<String, Any?>> {
|
||||||
|
if (!control.enabled) return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
|
.body(mapOf("ok" to false, "message" to "서비스 제어 비활성화됨(rtgs.control.enabled=false)"))
|
||||||
|
return try {
|
||||||
|
ResponseEntity.ok(action())
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
ResponseEntity.badRequest().body(mapOf("ok" to false, "message" to (e.message ?: "잘못된 요청")))
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
ResponseEntity.status(HttpStatus.CONFLICT).body(mapOf("ok" to false, "message" to (e.message ?: "충돌")))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(mapOf("ok" to false, "message" to (e.message ?: "오류")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package kr.or.bok.rtgs.louisvuitton
|
||||||
|
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.io.File
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
|
||||||
|
/** 서비스 메타(고정). controllable=false는 관리자 화면에서 제어 불가(선행 필수: 자기자신/관문). */
|
||||||
|
data class ServiceMeta(val id: String, val name: String, val port: Int, val controllable: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 프로세스 제어(기동/중지/재기동) — **로컬 프로토타입 전용**.
|
||||||
|
* 중지는 service_heartbeat에 기록된 pid를 ProcessHandle로 강제종료(선저널+Kafka 재생으로 무손실 승계).
|
||||||
|
* 기동은 LouisVuitton이 도는 동일 JDK(java.home)로 <svc>-0.1.0.jar를 ProcessBuilder로 실행.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class ServiceControlService(
|
||||||
|
private val jdbc: JdbcTemplate,
|
||||||
|
@Value("\${rtgs.center-id:DC1}") private val centerId: String,
|
||||||
|
@Value("\${rtgs.center-count:1}") private val centerCount: String,
|
||||||
|
@Value("\${rtgs.control.enabled:true}") val enabled: Boolean,
|
||||||
|
@Value("\${rtgs.control.backend-dir:C:/ai-dev/workspace/rtgs/backend}") private val backendDir: String,
|
||||||
|
@Value("\${rtgs.control.log-dir:C:/ai-dev/home/temp/rtgs-logs}") private val logDir: String,
|
||||||
|
) {
|
||||||
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
|
private val services = listOf(
|
||||||
|
ServiceMeta("sequencer", "Sequencer", 8090, true),
|
||||||
|
ServiceMeta("chanel", "Chanel", 8091, true),
|
||||||
|
ServiceMeta("dior", "Dior", 8092, true),
|
||||||
|
ServiceMeta("hermes", "Hermes", 8093, true),
|
||||||
|
ServiceMeta("prada", "Prada", 8094, true),
|
||||||
|
ServiceMeta("gucci", "Gucci", 8095, false),
|
||||||
|
ServiceMeta("louisvuitton", "Louis Vuitton", 8099, false),
|
||||||
|
)
|
||||||
|
private val byId = services.associateBy { it.id }
|
||||||
|
private val upThresholdMs = 15_000L
|
||||||
|
|
||||||
|
/** 서비스 7 + 인프라(PostgreSQL/Kafka) 통합 상태. */
|
||||||
|
fun status(): Map<String, Any?> {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val hb = jdbc.queryForList("SELECT service, last_seen, pid FROM service_heartbeat WHERE center = ?", centerId)
|
||||||
|
.associateBy { it["service"] as String }
|
||||||
|
val svc = services.map { m ->
|
||||||
|
val row = hb[m.id]
|
||||||
|
val last = (row?.get("last_seen") as? Number)?.toLong()
|
||||||
|
val ageMs = last?.let { now - it }
|
||||||
|
val up = ageMs != null && ageMs <= upThresholdMs
|
||||||
|
mapOf(
|
||||||
|
"id" to m.id, "name" to m.name, "port" to m.port, "controllable" to m.controllable,
|
||||||
|
"status" to if (up) "UP" else "DOWN",
|
||||||
|
"pid" to (row?.get("pid") as? Number)?.toLong(), "ageMs" to ageMs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val infra = listOf(
|
||||||
|
mapOf("id" to "postgres", "name" to "PostgreSQL", "port" to 5433, "reachable" to tcpReachable(5433)),
|
||||||
|
mapOf("id" to "kafka", "name" to "Kafka", "port" to 9092, "reachable" to tcpReachable(9092)),
|
||||||
|
)
|
||||||
|
return mapOf("services" to svc, "infra" to infra, "controlEnabled" to enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start(id: String): Map<String, Any?> {
|
||||||
|
val m = requireControllable(id)
|
||||||
|
if (isUp(id)) return mapOf("ok" to false, "message" to "${m.name} 이미 실행 중")
|
||||||
|
val jar = File("$backendDir/${m.id}/build/libs/${m.id}-0.1.0.jar")
|
||||||
|
require(jar.exists()) { "jar 없음: ${jar.path} (먼저 빌드 필요)" }
|
||||||
|
|
||||||
|
File(logDir).mkdirs()
|
||||||
|
val javaExe = File(System.getProperty("java.home"), "bin/java.exe")
|
||||||
|
val pb = ProcessBuilder(javaExe.path, "-jar", jar.path)
|
||||||
|
pb.environment()["CENTER_ID"] = centerId
|
||||||
|
pb.environment()["CENTER_COUNT"] = centerCount
|
||||||
|
pb.directory(File(backendDir))
|
||||||
|
pb.redirectErrorStream(true)
|
||||||
|
pb.redirectOutput(ProcessBuilder.Redirect.appendTo(File(logDir, "${m.id}.log")))
|
||||||
|
val proc = pb.start()
|
||||||
|
log.warn("SERVICE START {} pid={} jar={}", m.id, proc.pid(), jar.path)
|
||||||
|
return mapOf("ok" to true, "message" to "${m.name} 기동 요청됨 (pid ${proc.pid()})", "pid" to proc.pid())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(id: String): Map<String, Any?> {
|
||||||
|
val m = requireControllable(id)
|
||||||
|
val pid = pidOf(id) ?: return mapOf("ok" to false, "message" to "${m.name} 이미 중지(하트비트 없음)")
|
||||||
|
check(pid != ProcessHandle.current().pid()) { "자기 자신은 종료 불가" }
|
||||||
|
val killed = ProcessHandle.of(pid).map { it.destroyForcibly() }.orElse(false)
|
||||||
|
jdbc.update("DELETE FROM service_heartbeat WHERE service = ? AND center = ?", id, centerId)
|
||||||
|
log.warn("SERVICE STOP {} pid={} killed={}", m.id, pid, killed)
|
||||||
|
return mapOf("ok" to true, "message" to "${m.name} 중지 (pid $pid)", "killed" to killed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restart(id: String): Map<String, Any?> {
|
||||||
|
val m = requireControllable(id)
|
||||||
|
val pid = pidOf(id)
|
||||||
|
if (pid != null && pid != ProcessHandle.current().pid()) {
|
||||||
|
ProcessHandle.of(pid).ifPresent { h ->
|
||||||
|
h.destroyForcibly()
|
||||||
|
var waited = 0
|
||||||
|
while (h.isAlive && waited < 10_000) { Thread.sleep(200); waited += 200 }
|
||||||
|
}
|
||||||
|
jdbc.update("DELETE FROM service_heartbeat WHERE service = ? AND center = ?", id, centerId)
|
||||||
|
}
|
||||||
|
Thread.sleep(500)
|
||||||
|
val r = start(id)
|
||||||
|
return r + ("message" to "${m.name} 재기동됨")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tailLog(id: String, lines: Int): Map<String, Any?> {
|
||||||
|
byId[id] ?: throw IllegalArgumentException("알 수 없는 서비스: $id")
|
||||||
|
val logFile = File(logDir, "$id.log")
|
||||||
|
if (!logFile.exists()) return mapOf("id" to id, "found" to false, "lines" to emptyList<String>())
|
||||||
|
val all = logFile.readLines()
|
||||||
|
val n = lines.coerceIn(1, 1000)
|
||||||
|
val tail = if (all.size <= n) all else all.subList(all.size - n, all.size)
|
||||||
|
return mapOf("id" to id, "found" to true, "lines" to tail, "path" to logFile.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireControllable(id: String): ServiceMeta {
|
||||||
|
val m = byId[id] ?: throw IllegalArgumentException("알 수 없는 서비스: $id")
|
||||||
|
require(m.controllable) { "제어 불가(선행 필수 서비스): ${m.name}" }
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isUp(id: String): Boolean {
|
||||||
|
val last = jdbc.queryForList("SELECT last_seen FROM service_heartbeat WHERE service = ? AND center = ?", id, centerId)
|
||||||
|
.firstOrNull()?.let { (it["last_seen"] as? Number)?.toLong() } ?: return false
|
||||||
|
return System.currentTimeMillis() - last <= upThresholdMs
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pidOf(id: String): Long? =
|
||||||
|
jdbc.queryForList("SELECT pid FROM service_heartbeat WHERE service = ? AND center = ?", id, centerId)
|
||||||
|
.firstOrNull()?.let { (it["pid"] as? Number)?.toLong() }
|
||||||
|
|
||||||
|
private fun tcpReachable(port: Int): Boolean = try {
|
||||||
|
Socket().use { it.connect(InetSocketAddress("localhost", port), 1000); true }
|
||||||
|
} catch (e: Exception) { false }
|
||||||
|
}
|
||||||
@@ -20,6 +20,12 @@ server:
|
|||||||
|
|
||||||
rtgs:
|
rtgs:
|
||||||
center-id: ${CENTER_ID:DC1}
|
center-id: ${CENTER_ID:DC1}
|
||||||
|
center-count: ${CENTER_COUNT:1}
|
||||||
|
# 서비스 프로세스 제어(관리자 화면 기동/중지/재기동) — 로컬 프로토타입 전용
|
||||||
|
control:
|
||||||
|
enabled: ${RTGS_CONTROL_ENABLED:true}
|
||||||
|
backend-dir: ${RTGS_BACKEND_DIR:C:/ai-dev/workspace/rtgs/backend}
|
||||||
|
log-dir: ${RTGS_CONTROL_LOG_DIR:C:/ai-dev/home/temp/rtgs-logs}
|
||||||
|
|
||||||
gucci:
|
gucci:
|
||||||
jwt-secret: ${GUCCI_JWT_SECRET:rtgs-gucci-dev-secret-key-please-change-0123456789} # Gucci와 동일 키로 토큰 검증
|
jwt-secret: ${GUCCI_JWT_SECRET:rtgs-gucci-dev-secret-key-please-change-0123456789} # Gucci와 동일 키로 토큰 검증
|
||||||
|
|||||||
Reference in New Issue
Block a user