mirror of
https://github.com/slhaf/Partner.git
synced 2026-05-12 16:53:04 +08:00
refactor(partnerctl): split control commands into dedicated classes and extract shared runtime utilities
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||
<parent>
|
||||
<artifactId>Partner</artifactId>
|
||||
<groupId>work.slhaf</groupId>
|
||||
<artifactId>partner</artifactId>
|
||||
<groupId>work.slhaf.partner</groupId>
|
||||
<version>0.5.0</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<artifactId>Partner-Main</artifactId>
|
||||
<artifactId>partner-core</artifactId>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
@@ -29,13 +28,49 @@
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-maven-plugin</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>compile</id>
|
||||
<phase>process-sources</phase>
|
||||
<goals>
|
||||
<goal>compile</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<skipTests>true</skipTests>
|
||||
<sourceDirs>
|
||||
<sourceDir>${project.basedir}/src/main/java</sourceDir>
|
||||
</sourceDirs>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>test-compile</id>
|
||||
<phase>test-compile</phase>
|
||||
<goals>
|
||||
<goal>test-compile</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<sourceDirs>
|
||||
<sourceDir>${project.basedir}/src/test/java</sourceDir>
|
||||
</sourceDirs>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<jvmTarget>${maven.compiler.target}</jvmTarget>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-test</artifactId>
|
||||
<version>2.2.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<properties>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
<configuration>
|
||||
<!-- 新增控制台输出 -->
|
||||
<property name="PARTNER_HOME" value="${PARTNER_HOME:-${user.home}/.partner}"/>
|
||||
<property name="PARTNER_LOG_DIR" value="${PARTNER_HOME}/state/trace/log"/>
|
||||
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- 同时输出到文件和控制台 -->
|
||||
<appender name="PARTNER_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${PARTNER_LOG_DIR}/partner-core.log</file>
|
||||
<append>true</append>
|
||||
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${PARTNER_LOG_DIR}/partner-core.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
|
||||
<maxFileSize>20MB</maxFileSize>
|
||||
<maxHistory>14</maxHistory>
|
||||
<totalSizeCap>1GB</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="DEBUG">
|
||||
<appender-ref ref="ROLLING_FILE"/>
|
||||
<appender-ref ref="CONSOLE"/> <!-- 关键:添加这一行 -->
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="PARTNER_FILE"/>
|
||||
</root>
|
||||
</configuration>
|
||||
@@ -1,365 +0,0 @@
|
||||
package work.slhaf.partner.ctl.commands
|
||||
|
||||
import picocli.CommandLine
|
||||
import work.slhaf.partner.ctl.i18n.I18n.text
|
||||
import work.slhaf.partner.ctl.support.CommandInterrupted
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
@CommandLine.Command(name = "run", description = ["Start Partner agent."])
|
||||
class RunCommand : Runnable {
|
||||
|
||||
@CommandLine.Option(names = ["-d", "--background"], description = ["Run Partner in background."])
|
||||
var background: Boolean = false
|
||||
|
||||
override fun run() {
|
||||
val home = resolvePartnerHome()
|
||||
val partnerJar = resolvePartnerJar(home)
|
||||
|
||||
if (!Files.isRegularFile(partnerJar)) {
|
||||
throw CommandInterrupted(text("control.run.error.jarNotFound", partnerJar))
|
||||
}
|
||||
|
||||
if (background) {
|
||||
runInBackground(home, partnerJar)
|
||||
} else {
|
||||
runInForeground(home, partnerJar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@CommandLine.Command(name = "shutdown", description = ["Shutdown Partner agent."])
|
||||
class ShutdownCommand : Runnable {
|
||||
|
||||
@CommandLine.Option(
|
||||
names = ["--timeout"],
|
||||
description = ["Seconds to wait after graceful termination before failing or forcing shutdown."]
|
||||
)
|
||||
var timeoutSeconds: Long = 10
|
||||
|
||||
@CommandLine.Option(
|
||||
names = ["-f", "--force"],
|
||||
description = ["Forcefully kill matching Partner process if it does not exit before timeout."]
|
||||
)
|
||||
var force: Boolean = false
|
||||
|
||||
override fun run() {
|
||||
val home = resolvePartnerHome()
|
||||
val partnerJar = resolvePartnerJar(home)
|
||||
val pidFile = resolvePidFile(home)
|
||||
val processes = findPartnerProcesses(home, partnerJar)
|
||||
|
||||
if (processes.isEmpty()) {
|
||||
cleanupStalePidFile(pidFile)
|
||||
println(text("control.shutdown.info.notRunning", partnerJar))
|
||||
return
|
||||
}
|
||||
|
||||
var failed = false
|
||||
processes.forEach { process ->
|
||||
val pid = process.pid()
|
||||
println(text("control.shutdown.info.stopping", pid))
|
||||
process.destroy()
|
||||
|
||||
val stopped = waitForExit(process, timeoutSeconds)
|
||||
if (stopped) {
|
||||
println(text("control.shutdown.success.stopped", pid))
|
||||
deletePidFileIfMatches(pidFile, pid)
|
||||
return@forEach
|
||||
}
|
||||
|
||||
if (force) {
|
||||
println(text("control.shutdown.warn.force", pid))
|
||||
process.destroyForcibly()
|
||||
if (waitForExit(process, timeoutSeconds)) {
|
||||
println(text("control.shutdown.success.stopped", pid))
|
||||
deletePidFileIfMatches(pidFile, pid)
|
||||
} else {
|
||||
failed = true
|
||||
println(text("control.shutdown.error.notStopped", pid))
|
||||
}
|
||||
} else {
|
||||
failed = true
|
||||
println(text("control.shutdown.error.notStoppedUseForce", pid))
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
throw CommandInterrupted(text("control.shutdown.error.failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@CommandLine.Command(name = "log", description = ["Show Partner logs."])
|
||||
class LogCommand : Runnable {
|
||||
|
||||
@CommandLine.Option(names = ["--tail"], description = ["Number of log lines to show before exiting or following."])
|
||||
var tailLines: Int = 200
|
||||
|
||||
@CommandLine.Option(names = ["-f", "--follow"], description = ["Follow appended log output."])
|
||||
var follow: Boolean = false
|
||||
|
||||
override fun run() {
|
||||
val home = resolvePartnerHome()
|
||||
val logFile = resolveLogFile(home)
|
||||
|
||||
if (!Files.exists(logFile)) {
|
||||
if (!follow) {
|
||||
println(text("control.log.info.notFound", logFile))
|
||||
return
|
||||
}
|
||||
println(text("control.log.info.waiting", logFile))
|
||||
waitForLogFile(logFile)
|
||||
}
|
||||
|
||||
printLastLines(logFile, tailLines)
|
||||
|
||||
if (follow) {
|
||||
followLog(logFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runInForeground(home: Path, partnerJar: Path) {
|
||||
val logFile = resolveLogFile(home)
|
||||
Files.createDirectories(logFile.parent)
|
||||
|
||||
val process = createPartnerProcessBuilder(home, partnerJar)
|
||||
.redirectInput(ProcessBuilder.Redirect.INHERIT)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
||||
val shutdownHook = Thread {
|
||||
if (process.isAlive) {
|
||||
process.destroy()
|
||||
}
|
||||
}
|
||||
Runtime.getRuntime().addShutdownHook(shutdownHook)
|
||||
|
||||
val logOutput = Files.newOutputStream(
|
||||
logFile,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.APPEND,
|
||||
)
|
||||
|
||||
val copier = thread(name = "partnerctl-run-log-tee") {
|
||||
logOutput.use { output ->
|
||||
tee(process.inputStream, output)
|
||||
}
|
||||
}
|
||||
|
||||
val exitCode = process.waitFor()
|
||||
copier.join()
|
||||
runCatching { Runtime.getRuntime().removeShutdownHook(shutdownHook) }
|
||||
|
||||
if (exitCode != 0) {
|
||||
throw CommandInterrupted(text("control.run.error.exited", exitCode), exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
private fun runInBackground(home: Path, partnerJar: Path) {
|
||||
val pidFile = resolvePidFile(home)
|
||||
val logFile = resolveLogFile(home)
|
||||
val existingProcess = findPartnerProcesses(home, partnerJar).firstOrNull()
|
||||
|
||||
if (existingProcess != null) {
|
||||
throw CommandInterrupted(text("control.run.error.alreadyRunning", existingProcess.pid()))
|
||||
}
|
||||
|
||||
Files.createDirectories(pidFile.parent)
|
||||
Files.createDirectories(logFile.parent)
|
||||
|
||||
val process = createPartnerProcessBuilder(home, partnerJar)
|
||||
.redirectOutput(ProcessBuilder.Redirect.appendTo(logFile.toFile()))
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
||||
Files.writeString(
|
||||
pidFile,
|
||||
process.pid().toString(),
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
)
|
||||
|
||||
println(text("control.run.success.backgroundStarted", process.pid()))
|
||||
println(text("control.run.info.logFile", logFile))
|
||||
}
|
||||
|
||||
private fun createPartnerProcessBuilder(home: Path, partnerJar: Path): ProcessBuilder {
|
||||
return ProcessBuilder("java", "-jar", partnerJar.toString())
|
||||
.apply {
|
||||
environment()["PARTNER_HOME"] = home.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolvePartnerHome(): Path {
|
||||
val home = System.getenv("PARTNER_HOME")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { Paths.get(it) }
|
||||
?: Paths.get(System.getProperty("user.home"), ".partner")
|
||||
|
||||
return home.toAbsolutePath().normalize()
|
||||
}
|
||||
|
||||
private fun resolvePartnerJar(home: Path): Path {
|
||||
return home.resolve("resource").resolve("partner-core.jar").toAbsolutePath().normalize()
|
||||
}
|
||||
|
||||
private fun resolvePidFile(home: Path): Path {
|
||||
return home.resolve("state").resolve("run").resolve("partner.pid").toAbsolutePath().normalize()
|
||||
}
|
||||
|
||||
private fun resolveLogFile(home: Path): Path {
|
||||
return home.resolve("state").resolve("trace").resolve("log").resolve("partner-core.log").toAbsolutePath().normalize()
|
||||
}
|
||||
|
||||
private fun findPartnerProcesses(home: Path, partnerJar: Path): List<ProcessHandle> {
|
||||
val pidFile = resolvePidFile(home)
|
||||
val pidProcess = readPidProcess(pidFile, partnerJar)
|
||||
if (pidProcess != null) {
|
||||
return listOf(pidProcess)
|
||||
}
|
||||
|
||||
cleanupStalePidFile(pidFile)
|
||||
return findPartnerProcessesByCommandLine(partnerJar)
|
||||
}
|
||||
|
||||
private fun readPidProcess(pidFile: Path, partnerJar: Path): ProcessHandle? {
|
||||
if (!Files.isRegularFile(pidFile)) return null
|
||||
|
||||
val pid = Files.readString(pidFile).trim().toLongOrNull() ?: return null
|
||||
val process = ProcessHandle.of(pid).orElse(null) ?: return null
|
||||
|
||||
return if (process.isAlive && isPartnerProcess(process, partnerJar)) {
|
||||
process
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanupStalePidFile(pidFile: Path) {
|
||||
if (!Files.isRegularFile(pidFile)) return
|
||||
val pid = Files.readString(pidFile).trim().toLongOrNull()
|
||||
val process = pid?.let { ProcessHandle.of(it).orElse(null) }
|
||||
if (process == null || !process.isAlive) {
|
||||
Files.deleteIfExists(pidFile)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deletePidFileIfMatches(pidFile: Path, pid: Long) {
|
||||
if (!Files.isRegularFile(pidFile)) return
|
||||
val recordedPid = Files.readString(pidFile).trim().toLongOrNull()
|
||||
if (recordedPid == pid) {
|
||||
Files.deleteIfExists(pidFile)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findPartnerProcessesByCommandLine(partnerJar: Path): List<ProcessHandle> {
|
||||
val currentPid = ProcessHandle.current().pid()
|
||||
return ProcessHandle.allProcesses()
|
||||
.filter { it.pid() != currentPid }
|
||||
.filter { it.isAlive }
|
||||
.filter { isPartnerProcess(it, partnerJar) }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun isPartnerProcess(process: ProcessHandle, partnerJar: Path): Boolean {
|
||||
val jarPath = partnerJar.toString()
|
||||
val info = process.info()
|
||||
val arguments = info.arguments().orElse(emptyArray()) ?: emptyArray()
|
||||
val commandLine = info.commandLine().orElse("") ?: ""
|
||||
|
||||
return arguments.any { it == jarPath } || commandLine.contains(jarPath)
|
||||
}
|
||||
|
||||
private fun waitForExit(process: ProcessHandle, timeoutSeconds: Long): Boolean {
|
||||
return try {
|
||||
process.onExit().get(timeoutSeconds, TimeUnit.SECONDS)
|
||||
true
|
||||
} catch (_: TimeoutException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun tee(input: InputStream, logOutput: OutputStream) {
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
System.out.write(buffer, 0, read)
|
||||
System.out.flush()
|
||||
logOutput.write(buffer, 0, read)
|
||||
logOutput.flush()
|
||||
}
|
||||
}
|
||||
|
||||
private fun printLastLines(logFile: Path, lineCount: Int) {
|
||||
if (lineCount <= 0) return
|
||||
|
||||
val lines = ArrayDeque<String>()
|
||||
Files.newBufferedReader(logFile).use { reader ->
|
||||
while (true) {
|
||||
val line = reader.readLine() ?: break
|
||||
if (lines.size == lineCount) {
|
||||
lines.removeFirst()
|
||||
}
|
||||
lines.addLast(line)
|
||||
}
|
||||
}
|
||||
|
||||
lines.forEach(::println)
|
||||
}
|
||||
|
||||
private fun waitForLogFile(logFile: Path) {
|
||||
while (!Files.exists(logFile)) {
|
||||
Thread.sleep(LOG_FOLLOW_INTERVAL_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun followLog(logFile: Path) {
|
||||
var position = Files.size(logFile)
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
|
||||
while (true) {
|
||||
if (!Files.exists(logFile)) {
|
||||
Thread.sleep(LOG_FOLLOW_INTERVAL_MILLIS)
|
||||
continue
|
||||
}
|
||||
|
||||
val size = Files.size(logFile)
|
||||
if (size < position) {
|
||||
position = 0
|
||||
}
|
||||
|
||||
if (size > position) {
|
||||
Files.newInputStream(logFile).use { input ->
|
||||
var skipped = input.skip(position)
|
||||
while (skipped < position) {
|
||||
val current = input.skip(position - skipped)
|
||||
if (current <= 0) break
|
||||
skipped += current
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
System.out.write(buffer, 0, read)
|
||||
System.out.flush()
|
||||
position += read
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Thread.sleep(LOG_FOLLOW_INTERVAL_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
private const val LOG_FOLLOW_INTERVAL_MILLIS = 500L
|
||||
@@ -0,0 +1,101 @@
|
||||
package work.slhaf.partner.ctl.commands
|
||||
|
||||
import picocli.CommandLine
|
||||
import work.slhaf.partner.ctl.commands.control.resolveLogFile
|
||||
import work.slhaf.partner.ctl.commands.control.resolvePartnerHome
|
||||
import work.slhaf.partner.ctl.i18n.I18n.text
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
@CommandLine.Command(name = "log", description = ["Show Partner logs."])
|
||||
class LogCommand : Runnable {
|
||||
|
||||
@CommandLine.Option(names = ["--tail"], description = ["Number of log lines to show before exiting or following."])
|
||||
var tailLines: Int = 200
|
||||
|
||||
@CommandLine.Option(names = ["-f", "--follow"], description = ["Follow appended log output."])
|
||||
var follow: Boolean = false
|
||||
|
||||
override fun run() {
|
||||
val home = resolvePartnerHome()
|
||||
val logFile = resolveLogFile(home)
|
||||
|
||||
if (!Files.exists(logFile)) {
|
||||
if (!follow) {
|
||||
println(text("control.log.info.notFound", logFile))
|
||||
return
|
||||
}
|
||||
println(text("control.log.info.waiting", logFile))
|
||||
waitForLogFile(logFile)
|
||||
}
|
||||
|
||||
printLastLines(logFile, tailLines)
|
||||
|
||||
if (follow) {
|
||||
followLog(logFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun printLastLines(logFile: Path, lineCount: Int) {
|
||||
if (lineCount <= 0) return
|
||||
|
||||
val lines = ArrayDeque<String>()
|
||||
Files.newBufferedReader(logFile).use { reader ->
|
||||
while (true) {
|
||||
val line = reader.readLine() ?: break
|
||||
if (lines.size == lineCount) {
|
||||
lines.removeFirst()
|
||||
}
|
||||
lines.addLast(line)
|
||||
}
|
||||
}
|
||||
|
||||
lines.forEach(::println)
|
||||
}
|
||||
|
||||
private fun waitForLogFile(logFile: Path) {
|
||||
while (!Files.exists(logFile)) {
|
||||
Thread.sleep(LOG_FOLLOW_INTERVAL_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun followLog(logFile: Path) {
|
||||
var position = Files.size(logFile)
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
|
||||
while (true) {
|
||||
if (!Files.exists(logFile)) {
|
||||
Thread.sleep(LOG_FOLLOW_INTERVAL_MILLIS)
|
||||
continue
|
||||
}
|
||||
|
||||
val size = Files.size(logFile)
|
||||
if (size < position) {
|
||||
position = 0
|
||||
}
|
||||
|
||||
if (size > position) {
|
||||
Files.newInputStream(logFile).use { input ->
|
||||
var skipped = input.skip(position)
|
||||
while (skipped < position) {
|
||||
val current = input.skip(position - skipped)
|
||||
if (current <= 0) break
|
||||
skipped += current
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
System.out.write(buffer, 0, read)
|
||||
System.out.flush()
|
||||
position += read
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Thread.sleep(LOG_FOLLOW_INTERVAL_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
private const val LOG_FOLLOW_INTERVAL_MILLIS = 500L
|
||||
@@ -0,0 +1,122 @@
|
||||
package work.slhaf.partner.ctl.commands
|
||||
|
||||
import picocli.CommandLine
|
||||
import work.slhaf.partner.ctl.commands.control.*
|
||||
import work.slhaf.partner.ctl.i18n.I18n.text
|
||||
import work.slhaf.partner.ctl.support.CommandInterrupted
|
||||
import java.io.OutputStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@CommandLine.Command(name = "run", description = ["Start Partner agent."])
|
||||
class RunCommand : Runnable {
|
||||
|
||||
@CommandLine.Option(names = ["-d", "--background"], description = ["Run Partner in background."])
|
||||
var background: Boolean = false
|
||||
|
||||
override fun run() {
|
||||
val home = resolvePartnerHome()
|
||||
val partnerJar = resolvePartnerJar(home)
|
||||
|
||||
if (!Files.isRegularFile(partnerJar)) {
|
||||
throw CommandInterrupted(text("control.run.error.jarNotFound", partnerJar))
|
||||
}
|
||||
|
||||
if (background) {
|
||||
runInBackground(home, partnerJar)
|
||||
} else {
|
||||
runInForeground(home, partnerJar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runInForeground(home: Path, partnerJar: Path) {
|
||||
val logFile = resolveLogFile(home)
|
||||
Files.createDirectories(logFile.parent)
|
||||
|
||||
val process = createPartnerProcessBuilder(home, partnerJar)
|
||||
.inheritIO()
|
||||
.start()
|
||||
|
||||
appendControlLog(logFile, text("control.run.log.foregroundStarting", process.pid(), partnerJar))
|
||||
|
||||
val shutdownHook = Thread {
|
||||
if (process.isAlive) {
|
||||
process.destroy()
|
||||
}
|
||||
}
|
||||
Runtime.getRuntime().addShutdownHook(shutdownHook)
|
||||
|
||||
val exitCode = process.waitFor()
|
||||
runCatching { Runtime.getRuntime().removeShutdownHook(shutdownHook) }
|
||||
appendControlLog(logFile, text("control.run.log.exited", exitCode))
|
||||
|
||||
if (exitCode != 0) {
|
||||
throw CommandInterrupted(text("control.run.error.exited", exitCode), exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
private fun runInBackground(home: Path, partnerJar: Path) {
|
||||
val pidFile = resolvePidFile(home)
|
||||
val logFile = resolveLogFile(home)
|
||||
val existingProcess = findPartnerProcesses(home, partnerJar).firstOrNull()
|
||||
|
||||
if (existingProcess != null) {
|
||||
throw CommandInterrupted(text("control.run.error.alreadyRunning", existingProcess.pid()))
|
||||
}
|
||||
|
||||
Files.createDirectories(pidFile.parent)
|
||||
Files.createDirectories(logFile.parent)
|
||||
|
||||
val process = createPartnerProcessBuilder(home, partnerJar)
|
||||
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
|
||||
.redirectError(ProcessBuilder.Redirect.DISCARD)
|
||||
.start()
|
||||
|
||||
appendControlLog(logFile, text("control.run.log.backgroundStarting", process.pid(), partnerJar))
|
||||
|
||||
Thread.sleep(BACKGROUND_START_CHECK_MILLIS)
|
||||
if (!process.isAlive) {
|
||||
val exitCode = process.exitValue()
|
||||
appendControlLog(logFile, text("control.run.log.exited", exitCode))
|
||||
Files.deleteIfExists(pidFile)
|
||||
throw CommandInterrupted(text("control.run.error.exited", exitCode), exitCode)
|
||||
}
|
||||
|
||||
Files.writeString(
|
||||
pidFile,
|
||||
process.pid().toString(),
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
)
|
||||
|
||||
println(text("control.run.success.backgroundStarted", process.pid()))
|
||||
println(text("control.run.info.logFile", logFile))
|
||||
}
|
||||
|
||||
private fun createPartnerProcessBuilder(home: Path, partnerJar: Path): ProcessBuilder {
|
||||
return ProcessBuilder("java", "-DPARTNER_HOME=$home", "-jar", partnerJar.toString())
|
||||
.apply {
|
||||
environment()["PARTNER_HOME"] = home.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun appendControlLog(logFile: Path, message: String) {
|
||||
Files.createDirectories(logFile.parent)
|
||||
Files.newOutputStream(
|
||||
logFile,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.APPEND,
|
||||
).use { output ->
|
||||
writeControlLog(output, message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeControlLog(output: OutputStream, message: String) {
|
||||
output.write("[partnerctl ${LocalDateTime.now()}] $message\n".toByteArray())
|
||||
output.flush()
|
||||
}
|
||||
|
||||
private const val BACKGROUND_START_CHECK_MILLIS = 500L
|
||||
@@ -0,0 +1,68 @@
|
||||
package work.slhaf.partner.ctl.commands
|
||||
|
||||
import picocli.CommandLine
|
||||
import work.slhaf.partner.ctl.commands.control.*
|
||||
import work.slhaf.partner.ctl.i18n.I18n
|
||||
import work.slhaf.partner.ctl.support.CommandInterrupted
|
||||
|
||||
@CommandLine.Command(name = "shutdown", description = ["Shutdown Partner agent."])
|
||||
class ShutdownCommand : Runnable {
|
||||
|
||||
@CommandLine.Option(
|
||||
names = ["--timeout"],
|
||||
description = ["Seconds to wait after graceful termination before failing or forcing shutdown."]
|
||||
)
|
||||
var timeoutSeconds: Long = 10
|
||||
|
||||
@CommandLine.Option(
|
||||
names = ["-f", "--force"],
|
||||
description = ["Forcefully kill matching Partner process if it does not exit before timeout."]
|
||||
)
|
||||
var force: Boolean = false
|
||||
|
||||
override fun run() {
|
||||
val home = resolvePartnerHome()
|
||||
val partnerJar = resolvePartnerJar(home)
|
||||
val pidFile = resolvePidFile(home)
|
||||
val processes = findPartnerProcesses(home, partnerJar)
|
||||
|
||||
if (processes.isEmpty()) {
|
||||
cleanupStalePidFile(pidFile)
|
||||
println(I18n.text("control.shutdown.info.notRunning", partnerJar))
|
||||
return
|
||||
}
|
||||
|
||||
var failed = false
|
||||
processes.forEach { process ->
|
||||
val pid = process.pid()
|
||||
println(I18n.text("control.shutdown.info.stopping", pid))
|
||||
process.destroy()
|
||||
|
||||
val stopped = waitForExit(process, timeoutSeconds)
|
||||
if (stopped) {
|
||||
println(I18n.text("control.shutdown.success.stopped", pid))
|
||||
deletePidFileIfMatches(pidFile, pid)
|
||||
return@forEach
|
||||
}
|
||||
|
||||
if (force) {
|
||||
println(I18n.text("control.shutdown.warn.force", pid))
|
||||
process.destroyForcibly()
|
||||
if (waitForExit(process, timeoutSeconds)) {
|
||||
println(I18n.text("control.shutdown.success.stopped", pid))
|
||||
deletePidFileIfMatches(pidFile, pid)
|
||||
} else {
|
||||
failed = true
|
||||
println(I18n.text("control.shutdown.error.notStopped", pid))
|
||||
}
|
||||
} else {
|
||||
failed = true
|
||||
println(I18n.text("control.shutdown.error.notStoppedUseForce", pid))
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
throw CommandInterrupted(I18n.text("control.shutdown.error.failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package work.slhaf.partner.ctl.commands.control
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
fun resolvePartnerHome(): Path {
|
||||
val home = System.getenv("PARTNER_HOME")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { Paths.get(it) }
|
||||
?: Paths.get(System.getProperty("user.home"), ".partner")
|
||||
|
||||
return home.toAbsolutePath().normalize()
|
||||
}
|
||||
|
||||
fun resolvePartnerJar(home: Path): Path {
|
||||
return home.resolve("resource").resolve("partner-core.jar").toAbsolutePath().normalize()
|
||||
}
|
||||
|
||||
fun resolvePidFile(home: Path): Path {
|
||||
return home.resolve("state").resolve("run").resolve("partner.pid").toAbsolutePath().normalize()
|
||||
}
|
||||
|
||||
fun resolveLogFile(home: Path): Path {
|
||||
return home.resolve("state").resolve("trace").resolve("log").resolve("partner-core.log").toAbsolutePath().normalize()
|
||||
}
|
||||
|
||||
fun findPartnerProcesses(home: Path, partnerJar: Path): List<ProcessHandle> {
|
||||
val pidFile = resolvePidFile(home)
|
||||
val pidProcess = readPidProcess(pidFile, partnerJar)
|
||||
if (pidProcess != null) {
|
||||
return listOf(pidProcess)
|
||||
}
|
||||
|
||||
cleanupStalePidFile(pidFile)
|
||||
return findPartnerProcessesByCommandLine(partnerJar)
|
||||
}
|
||||
|
||||
fun cleanupStalePidFile(pidFile: Path) {
|
||||
if (!Files.isRegularFile(pidFile)) return
|
||||
val pid = Files.readString(pidFile).trim().toLongOrNull()
|
||||
val process = pid?.let { ProcessHandle.of(it).orElse(null) }
|
||||
if (process == null || !process.isAlive) {
|
||||
Files.deleteIfExists(pidFile)
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePidFileIfMatches(pidFile: Path, pid: Long) {
|
||||
if (!Files.isRegularFile(pidFile)) return
|
||||
val recordedPid = Files.readString(pidFile).trim().toLongOrNull()
|
||||
if (recordedPid == pid) {
|
||||
Files.deleteIfExists(pidFile)
|
||||
}
|
||||
}
|
||||
|
||||
fun waitForExit(process: ProcessHandle, timeoutSeconds: Long): Boolean {
|
||||
return try {
|
||||
process.onExit().get(timeoutSeconds, TimeUnit.SECONDS)
|
||||
true
|
||||
} catch (_: TimeoutException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun readPidProcess(pidFile: Path, partnerJar: Path): ProcessHandle? {
|
||||
if (!Files.isRegularFile(pidFile)) return null
|
||||
|
||||
val pid = Files.readString(pidFile).trim().toLongOrNull() ?: return null
|
||||
val process = ProcessHandle.of(pid).orElse(null) ?: return null
|
||||
|
||||
return if (process.isAlive && isPartnerProcess(process, partnerJar)) {
|
||||
process
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun findPartnerProcessesByCommandLine(partnerJar: Path): List<ProcessHandle> {
|
||||
val currentPid = ProcessHandle.current().pid()
|
||||
return ProcessHandle.allProcesses()
|
||||
.filter { it.pid() != currentPid }
|
||||
.filter { it.isAlive }
|
||||
.filter { isPartnerProcess(it, partnerJar) }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun isPartnerProcess(process: ProcessHandle, partnerJar: Path): Boolean {
|
||||
val jarPath = partnerJar.toString()
|
||||
val info = process.info()
|
||||
val arguments = info.arguments().orElse(emptyArray()) ?: emptyArray()
|
||||
val commandLine = info.commandLine().orElse("") ?: ""
|
||||
|
||||
return arguments.any { it == jarPath } || commandLine.contains(jarPath)
|
||||
}
|
||||
@@ -95,6 +95,9 @@ control.run.error.exited=Partner exited with code {0}
|
||||
control.run.error.alreadyRunning=Partner is already running. pid={0}
|
||||
control.run.success.backgroundStarted=Partner started in background. pid={0}
|
||||
control.run.info.logFile=Log file: {0}
|
||||
control.run.log.foregroundStarting=Starting Partner in foreground. pid={0}, jar={1}
|
||||
control.run.log.backgroundStarting=Starting Partner in background. pid={0}, jar={1}
|
||||
control.run.log.exited=Partner process exited with code {0}
|
||||
control.log.info.notFound=Partner log file does not exist: {0}
|
||||
control.log.info.waiting=Waiting for Partner log file: {0}
|
||||
control.shutdown.info.notRunning=No running Partner process found for {0}
|
||||
|
||||
@@ -95,6 +95,9 @@ control.run.error.exited=Partner 退出,退出码:{0}
|
||||
control.run.error.alreadyRunning=Partner 已经在运行。pid={0}
|
||||
control.run.success.backgroundStarted=Partner 已在后台启动。pid={0}
|
||||
control.run.info.logFile=日志文件:{0}
|
||||
control.run.log.foregroundStarting=正在前台启动 Partner。pid={0},jar={1}
|
||||
control.run.log.backgroundStarting=正在后台启动 Partner。pid={0},jar={1}
|
||||
control.run.log.exited=Partner 进程已退出,退出码:{0}
|
||||
control.log.info.notFound=Partner 日志文件不存在:{0}
|
||||
control.log.info.waiting=正在等待 Partner 日志文件:{0}
|
||||
control.shutdown.info.notRunning=没有找到对应 {0} 的运行中 Partner 进程
|
||||
|
||||
Reference in New Issue
Block a user