Merge branch 'master' into 'master'

Added reload of ClientSettings in running client

See merge request mangadex-pub/mangadex_at_home!55
This commit is contained in:
carbotaniuman 2020-07-20 04:22:13 +00:00
commit 6b23564f75
4 changed files with 216 additions and 87 deletions

View file

@ -6,6 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- [2020-07-13] Added reloading client setting without stopping client by [@radonbark]
### Changed ### Changed

View file

@ -19,25 +19,12 @@ along with this MangaDex@Home. If not, see <http://www.gnu.org/licenses/>.
package mdnet.base package mdnet.base
import ch.qos.logback.classic.LoggerContext import ch.qos.logback.classic.LoggerContext
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import java.io.FileReader
import java.io.FileWriter
import java.io.IOException
import java.util.regex.Pattern
import kotlin.system.exitProcess import kotlin.system.exitProcess
import mdnet.BuildInfo import mdnet.BuildInfo
import mdnet.base.settings.ClientSettings
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
object Main { object Main {
private val LOGGER = LoggerFactory.getLogger(Main::class.java) private val LOGGER = LoggerFactory.getLogger(Main::class.java)
private val JACKSON: ObjectMapper = jacksonObjectMapper().enable(SerializationFeature.INDENT_OUTPUT).configure(JsonParser.Feature.ALLOW_COMMENTS, true)
@JvmStatic @JvmStatic
fun main(args: Array<String>) { fun main(args: Array<String>) {
@ -68,25 +55,7 @@ object Main {
dieWithError("Expected one argument: path to config file, or nothing") dieWithError("Expected one argument: path to config file, or nothing")
} }
val settings = try { val client = MangaDexClient(file)
JACKSON.readValue<ClientSettings>(FileReader(file))
} catch (e: UnrecognizedPropertyException) {
dieWithError("'${e.propertyName}' is not a valid setting")
} catch (e: JsonProcessingException) {
dieWithError(e)
} catch (ignored: IOException) {
ClientSettings().also {
LOGGER.warn("Settings file {} not found, generating file", file)
try {
FileWriter(file).use { writer -> JACKSON.writeValue(writer, it) }
} catch (e: IOException) {
dieWithError(e)
}
}
}.apply(::validateSettings)
LOGGER.info { "Client settings loaded: $settings" }
val client = MangaDexClient(settings)
Runtime.getRuntime().addShutdownHook(Thread { client.shutdown() }) Runtime.getRuntime().addShutdownHook(Thread { client.shutdown() })
client.runLoop() client.runLoop()
} }
@ -103,39 +72,4 @@ object Main {
(LoggerFactory.getILoggerFactory() as LoggerContext).stop() (LoggerFactory.getILoggerFactory() as LoggerContext).stop()
exitProcess(1) exitProcess(1)
} }
private fun validateSettings(settings: ClientSettings) {
if (!isSecretValid(settings.clientSecret)) dieWithError("Config Error: API Secret is invalid, must be 52 alphanumeric characters")
if (settings.clientPort == 0) {
dieWithError("Config Error: Invalid port number")
}
if (settings.clientPort in Constants.RESTRICTED_PORTS) {
dieWithError("Config Error: Unsafe port number")
}
if (settings.maxCacheSizeInMebibytes < 1024) {
dieWithError("Config Error: Invalid max cache size, must be >= 1024 MiB (1GiB)")
}
if (settings.threads < 4) {
dieWithError("Config Error: Invalid number of threads, must be >= 4")
}
if (settings.maxMebibytesPerHour < 0) {
dieWithError("Config Error: Max bandwidth must be >= 0")
}
if (settings.maxKilobitsPerSecond < 0) {
dieWithError("Config Error: Max burst rate must be >= 0")
}
if (settings.gracefulShutdownWaitSeconds < 15) {
dieWithError("Config Error: Graceful shutdown wait be >= 15")
}
if (settings.webSettings != null) {
if (settings.webSettings.uiPort == 0) {
dieWithError("Config Error: Invalid UI port number")
}
}
}
private const val CLIENT_KEY_LENGTH = 52
private fun isSecretValid(clientSecret: String): Boolean {
return Pattern.matches("^[a-zA-Z0-9]{$CLIENT_KEY_LENGTH}$", clientSecret)
}
} }

View file

@ -20,10 +20,14 @@ along with this MangaDex@Home. If not, see <http://www.gnu.org/licenses/>.
package mdnet.base package mdnet.base
import ch.qos.logback.classic.LoggerContext import ch.qos.logback.classic.LoggerContext
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import java.io.File import java.io.File
import java.io.FileReader
import java.io.FileWriter
import java.io.IOException import java.io.IOException
import java.time.Instant import java.time.Instant
import java.util.* import java.util.*
@ -32,6 +36,7 @@ import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import java.util.regex.Pattern
import mdnet.base.Main.dieWithError import mdnet.base.Main.dieWithError
import mdnet.base.data.Statistics import mdnet.base.data.Statistics
import mdnet.base.server.getServer import mdnet.base.server.getServer
@ -43,23 +48,28 @@ import mdnet.cache.HeaderMismatchException
import org.http4k.server.Http4kServer import org.http4k.server.Http4kServer
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
private const val CLIENT_KEY_LENGTH = 52
// Exception class to handle when Client Settings have invalid values
class ClientSettingsException(message: String) : Exception(message)
sealed class State sealed class State
// server is not running // server is not running
object Uninitialized : State() data class Uninitialized(val clientSettings: ClientSettings) : State()
// server has shut down // server has shut down
object Shutdown : State() object Shutdown : State()
// server is in the process of shutting down // server is in the process of shutting down
data class GracefulShutdown(val lastRunning: Running, val counts: Int = 0, val nextState: State = Uninitialized, val action: () -> Unit = {}) : State() data class GracefulShutdown(val lastRunning: Running, val counts: Int = 0, val nextState: State = Uninitialized(lastRunning.clientSettings), val action: () -> Unit = {}) : State()
// server is currently running // server is currently running
data class Running(val server: Http4kServer, val settings: ServerSettings) : State() data class Running(val server: Http4kServer, val settings: ServerSettings, val clientSettings: ClientSettings) : State()
// clientSettings must only be accessed from the thread on the executorService
class MangaDexClient(private val clientSettings: ClientSettings) { class MangaDexClient(private val clientSettingsFile: String) {
// this must remain singlethreaded because of how the state mechanism works // this must remain singlethreaded because of how the state mechanism works
private val executorService = Executors.newSingleThreadScheduledExecutor() private val executorService = Executors.newSingleThreadScheduledExecutor()
// state must only be accessed from the thread on the executorService // state must only be accessed from the thread on the executorService
private var state: State = Uninitialized private var state: State
private val serverHandler: ServerHandler = ServerHandler(clientSettings) private var serverHandler: ServerHandler
private val statsMap: MutableMap<Instant, Statistics> = Collections private val statsMap: MutableMap<Instant, Statistics> = Collections
.synchronizedMap(object : LinkedHashMap<Instant, Statistics>(240) { .synchronizedMap(object : LinkedHashMap<Instant, Statistics>(240) {
override fun removeEldestEntry(eldest: Map.Entry<Instant, Statistics>): Boolean { override fun removeEldestEntry(eldest: Map.Entry<Instant, Statistics>): Boolean {
@ -74,6 +84,32 @@ class MangaDexClient(private val clientSettings: ClientSettings) {
private val cache: DiskLruCache private val cache: DiskLruCache
init { init {
// Read ClientSettings
val clientSettings = try {
readClientSettings()
} catch (e: UnrecognizedPropertyException) {
dieWithError("'${e.propertyName}' is not a valid setting")
} catch (e: JsonProcessingException) {
dieWithError(e)
} catch (ignored: IOException) {
ClientSettings().also {
LOGGER.warn { "Settings file $clientSettingsFile not found, generating file" }
try {
FileWriter(clientSettingsFile).use { writer -> JACKSON.writeValue(writer, it) }
} catch (e: IOException) {
dieWithError(e)
}
}
} catch (e: ClientSettingsException) {
dieWithError(e)
}
// Initialize things that depend on Client Settings
LOGGER.info { "Client settings loaded: $clientSettings" }
state = Uninitialized(clientSettings)
serverHandler = ServerHandler(clientSettings)
// Initialize everything else
try { try {
cache = DiskLruCache.open( cache = DiskLruCache.open(
File("cache"), 1, 1, File("cache"), 1, 1,
@ -94,11 +130,7 @@ class MangaDexClient(private val clientSettings: ClientSettings) {
fun runLoop() { fun runLoop() {
loginAndStartServer() loginAndStartServer()
statsMap[Instant.now()] = statistics.get() statsMap[Instant.now()] = statistics.get()
startWebUi()
if (clientSettings.webSettings != null) {
webUi = getUiServer(clientSettings.webSettings, statistics, statsMap)
webUi!!.start()
}
LOGGER.info { "Mangadex@Home Client initialized. Starting normal operation." } LOGGER.info { "Mangadex@Home Client initialized. Starting normal operation." }
executorService.scheduleAtFixedRate({ executorService.scheduleAtFixedRate({
@ -140,11 +172,11 @@ class MangaDexClient(private val clientSettings: ClientSettings) {
} }
}, 1, 1, TimeUnit.HOURS) }, 1, 1, TimeUnit.HOURS)
val timesToWait = clientSettings.gracefulShutdownWaitSeconds / 15
executorService.scheduleAtFixedRate({ executorService.scheduleAtFixedRate({
try { try {
val state = this.state val state = this.state
if (state is GracefulShutdown) { if (state is GracefulShutdown) {
val timesToWait = state.lastRunning.clientSettings.gracefulShutdownWaitSeconds / 15
when { when {
state.counts == 0 -> { state.counts == 0 -> {
LOGGER.info { "Starting graceful shutdown" } LOGGER.info { "Starting graceful shutdown" }
@ -174,7 +206,7 @@ class MangaDexClient(private val clientSettings: ClientSettings) {
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
LOGGER.warn("Main loop failed", e) LOGGER.warn(e) { "Main loop failed" }
} }
}, 15, 15, TimeUnit.SECONDS) }, 15, 15, TimeUnit.SECONDS)
@ -183,7 +215,7 @@ class MangaDexClient(private val clientSettings: ClientSettings) {
val state = this.state val state = this.state
if (state is Running) { if (state is Running) {
val currentBytesSent = statistics.get().bytesSent - lastBytesSent val currentBytesSent = statistics.get().bytesSent - lastBytesSent
if (clientSettings.maxMebibytesPerHour != 0L && clientSettings.maxMebibytesPerHour * 1024 * 1024 /* MiB to bytes */ < currentBytesSent) { if (state.clientSettings.maxMebibytesPerHour != 0L && state.clientSettings.maxMebibytesPerHour * 1024 * 1024 /* MiB to bytes */ < currentBytesSent) {
LOGGER.info { "Shutting down server as hourly bandwidth limit reached" } LOGGER.info { "Shutting down server as hourly bandwidth limit reached" }
this.state = GracefulShutdown(lastRunning = state) this.state = GracefulShutdown(lastRunning = state)
@ -195,6 +227,18 @@ class MangaDexClient(private val clientSettings: ClientSettings) {
LOGGER.warn(e) { "Graceful shutdown checker failed" } LOGGER.warn(e) { "Graceful shutdown checker failed" }
} }
}, 45, 45, TimeUnit.SECONDS) }, 45, 45, TimeUnit.SECONDS)
// Check every minute to see if client settings have changed
executorService.scheduleWithFixedDelay({
try {
val state = this.state
if (state is Running) {
reloadClientSettings()
}
} catch (e: Exception) {
LOGGER.warn(e) { "Reload of ClientSettings failed" }
}
}, 60, 60, TimeUnit.SECONDS)
} }
private fun pingControl() { private fun pingControl() {
@ -223,11 +267,11 @@ class MangaDexClient(private val clientSettings: ClientSettings) {
} }
private fun loginAndStartServer() { private fun loginAndStartServer() {
this.state as Uninitialized val state = this.state as Uninitialized
val serverSettings = serverHandler.loginToControl() val serverSettings = serverHandler.loginToControl()
?: dieWithError("Failed to get a login response from server - check API secret for validity") ?: dieWithError("Failed to get a login response from server - check API secret for validity")
val server = getServer(cache, serverSettings, clientSettings, statistics, isHandled).start() val server = getServer(cache, serverSettings, state.clientSettings, statistics, isHandled).start()
if (serverSettings.latestBuild > Constants.CLIENT_BUILD) { if (serverSettings.latestBuild > Constants.CLIENT_BUILD) {
LOGGER.warn { LOGGER.warn {
@ -235,7 +279,7 @@ class MangaDexClient(private val clientSettings: ClientSettings) {
} }
} }
state = Running(server, serverSettings) this.state = Running(server, serverSettings, state.clientSettings)
LOGGER.info { "Internal HTTP server was successfully started" } LOGGER.info { "Internal HTTP server was successfully started" }
} }
@ -243,7 +287,7 @@ class MangaDexClient(private val clientSettings: ClientSettings) {
serverHandler.logoutFromControl() serverHandler.logoutFromControl()
} }
private fun stopServer(nextState: State = Uninitialized) { private fun stopServer(nextState: State) {
val state = this.state.let { val state = this.state.let {
when (it) { when (it) {
is Running -> is Running ->
@ -262,6 +306,32 @@ class MangaDexClient(private val clientSettings: ClientSettings) {
this.state = nextState this.state = nextState
} }
/**
* Starts the WebUI if the ClientSettings demand it.
* Because this method checks if the WebUI is needed,
* it can be safely called before checking yourself
*/
private fun startWebUi() {
val state = this.state
// Grab the client settings if available
val clientSettings = state.let {
when (it) {
is Running ->
it.clientSettings
is Uninitialized ->
it.clientSettings
else ->
null
}
}
// Only start the Web UI if the settings demand it
if (clientSettings?.webSettings != null) {
webUi = getUiServer(clientSettings.webSettings, statistics, statsMap)
webUi!!.start()
}
}
fun shutdown() { fun shutdown() {
LOGGER.info { "Mangadex@Home Client stopping" } LOGGER.info { "Mangadex@Home Client stopping" }
@ -296,6 +366,126 @@ class MangaDexClient(private val clientSettings: ClientSettings) {
(LoggerFactory.getILoggerFactory() as LoggerContext).stop() (LoggerFactory.getILoggerFactory() as LoggerContext).stop()
} }
/**
* Reloads the client configuration and restarts the
* Web UI and/or the server if needed
*/
private fun reloadClientSettings() {
val state = this.state as Running
LOGGER.info { "Reloading client settings" }
try {
val newSettings = readClientSettings()
if (newSettings == state.clientSettings) {
LOGGER.info { "Client Settings have not changed" }
return
}
// Setting loaded without issue. Figure out
// if there are changes that require a restart
val restartServer = newSettings.clientSecret != state.clientSettings.clientSecret ||
newSettings.clientHostname != state.clientSettings.clientHostname ||
newSettings.clientPort != state.clientSettings.clientPort ||
newSettings.clientExternalPort != state.clientSettings.clientExternalPort ||
newSettings.threads != state.clientSettings.threads ||
newSettings.devSettings?.isDev != state.clientSettings.devSettings?.isDev
val stopWebUi = newSettings.webSettings != state.clientSettings.webSettings ||
newSettings.webSettings?.uiPort != state.clientSettings.webSettings?.uiPort ||
newSettings.webSettings?.uiHostname != state.clientSettings.webSettings?.uiHostname
val startWebUi = (stopWebUi && newSettings.webSettings != null)
// Stop the the WebUI if needed
if (stopWebUi) {
LOGGER.info { "Stopping WebUI to reload ClientSettings" }
webUi?.close()
webUi = null
}
if (restartServer) {
// If we are restarting the server
// We must do it gracefully and set
// the new settings later
LOGGER.info { "Stopping Server to reload ClientSettings" }
this.state = GracefulShutdown(state, nextState = Uninitialized(clientSettings = newSettings), action = {
serverHandler = ServerHandler(newSettings)
LOGGER.info { "Reloaded ClientSettings: $newSettings" }
LOGGER.info { "Starting Server after reloading ClientSettings" }
loginAndStartServer()
// Start the WebUI if we had to stop it
// and still want it
if (startWebUi) {
LOGGER.info { "Starting WebUI after reloading ClientSettings" }
startWebUi()
LOGGER.info { "Started WebUI after reloading ClientSettings" }
}
})
} else {
// If we aren't restarting the server
// We can update the settings now
this.state = state.copy(clientSettings = newSettings)
serverHandler.setClientSettings(newSettings)
LOGGER.info { "Reloaded ClientSettings: $newSettings" }
// Start the WebUI if we had to stop it
// and still want it
if (startWebUi) {
LOGGER.info { "Starting WebUI after reloading ClientSettings" }
startWebUi()
LOGGER.info { "Started WebUI after reloading ClientSettings" }
}
}
} catch (e: UnrecognizedPropertyException) {
LOGGER.warn { "Settings file is invalid: '$e.propertyName' is not a valid setting" }
} catch (e: JsonProcessingException) {
LOGGER.warn { "Settings file is invalid: $e.message" }
} catch (e: IOException) {
LOGGER.warn { "Settings file is could not be found: $e.message" }
} catch (e: ClientSettingsException) {
LOGGER.warn { "Can't reload client settings: $e.message" }
}
}
private fun validateSettings(settings: ClientSettings) {
if (!isSecretValid(settings.clientSecret)) throw ClientSettingsException("Config Error: API Secret is invalid, must be 52 alphanumeric characters")
if (settings.clientPort == 0) {
throw ClientSettingsException("Config Error: Invalid port number")
}
if (settings.clientPort in Constants.RESTRICTED_PORTS) {
throw ClientSettingsException("Config Error: Unsafe port number")
}
if (settings.maxCacheSizeInMebibytes < 1024) {
throw ClientSettingsException("Config Error: Invalid max cache size, must be >= 1024 MiB (1GiB)")
}
if (settings.threads < 4) {
throw ClientSettingsException("Config Error: Invalid number of threads, must be >= 4")
}
if (settings.maxMebibytesPerHour < 0) {
throw ClientSettingsException("Config Error: Max bandwidth must be >= 0")
}
if (settings.maxKilobitsPerSecond < 0) {
throw ClientSettingsException("Config Error: Max burst rate must be >= 0")
}
if (settings.gracefulShutdownWaitSeconds < 15) {
throw ClientSettingsException("Config Error: Graceful shutdown wait must be >= 15")
}
if (settings.webSettings != null) {
if (settings.webSettings.uiPort == 0) {
throw ClientSettingsException("Config Error: Invalid UI port number")
}
}
}
private fun readClientSettings(): ClientSettings {
return JACKSON.readValue<ClientSettings>(FileReader(clientSettingsFile)).apply(::validateSettings)
}
private fun isSecretValid(clientSecret: String): Boolean {
return Pattern.matches("^[a-zA-Z0-9]{$CLIENT_KEY_LENGTH}$", clientSecret)
}
companion object { companion object {
private val LOGGER = LoggerFactory.getLogger(MangaDexClient::class.java) private val LOGGER = LoggerFactory.getLogger(MangaDexClient::class.java)
private val JACKSON: ObjectMapper = jacksonObjectMapper() private val JACKSON: ObjectMapper = jacksonObjectMapper()

View file

@ -40,7 +40,7 @@ object ServerHandlerJackson : ConfigurableJackson(
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
) )
class ServerHandler(private val settings: ClientSettings) { class ServerHandler(private var settings: ClientSettings) {
private val client = ApacheClient() private val client = ApacheClient()
fun logoutFromControl(): Boolean { fun logoutFromControl(): Boolean {
@ -109,6 +109,10 @@ class ServerHandler(private val settings: ClientSettings) {
SERVER_ADDRESS_DEV SERVER_ADDRESS_DEV
} }
fun setClientSettings(clientSettings: ClientSettings) {
this.settings = clientSettings
}
companion object { companion object {
private val LOGGER = LoggerFactory.getLogger(ServerHandler::class.java) private val LOGGER = LoggerFactory.getLogger(ServerHandler::class.java)
private val STRING_ANY_MAP_LENS = Body.auto<Map<String, Any>>().toLens() private val STRING_ANY_MAP_LENS = Body.auto<Map<String, Any>>().toLens()