From 90690666c5d7f07fb97191bd23f39b30d0c6969a Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Mon, 13 Mar 2023 21:01:26 -0700 Subject: [PATCH] Implement extensible manifest for updates in Foundation. --- .../foundation/concrete/ExtensibleManifest.kt | 60 +++++++++ .../foundation/core/FoundationCorePlugin.kt | 2 - .../core/features/dev/DevFeature.kt | 16 --- .../core/features/dev/DevUpdateConfig.kt | 10 -- .../core/features/dev/DevUpdatePayload.kt | 13 -- .../core/features/dev/DevUpdateServer.kt | 117 ------------------ .../core/features/update/ModuleManifest.kt | 9 -- .../core/features/update/UpdatePlan.kt | 8 ++ .../core/features/update/UpdateResolver.kt | 47 +++++++ .../core/features/update/UpdateService.kt | 34 +++-- .../core/features/update/UpdateUtil.kt | 23 ++-- .../src/main/resources/devupdate.yaml | 12 -- 12 files changed, 139 insertions(+), 212 deletions(-) create mode 100644 foundation-core/src/main/kotlin/gay/pizza/foundation/concrete/ExtensibleManifest.kt delete mode 100644 foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/dev/DevFeature.kt delete mode 100644 foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/dev/DevUpdateConfig.kt delete mode 100644 foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/dev/DevUpdatePayload.kt delete mode 100644 foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/dev/DevUpdateServer.kt delete mode 100644 foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/ModuleManifest.kt create mode 100644 foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/UpdatePlan.kt create mode 100644 foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/UpdateResolver.kt delete mode 100644 foundation-core/src/main/resources/devupdate.yaml diff --git a/foundation-core/src/main/kotlin/gay/pizza/foundation/concrete/ExtensibleManifest.kt b/foundation-core/src/main/kotlin/gay/pizza/foundation/concrete/ExtensibleManifest.kt new file mode 100644 index 0000000..a6f67d4 --- /dev/null +++ b/foundation-core/src/main/kotlin/gay/pizza/foundation/concrete/ExtensibleManifest.kt @@ -0,0 +1,60 @@ +package gay.pizza.foundation.concrete + +import kotlinx.serialization.Serializable + +/** + * The extensible update manifest format. + */ +@Serializable +data class ExtensibleManifest( + /** + * The items the manifest describes. + */ + val items: List +) + +/** + * An item in the update manifest. + */ +@Serializable +data class ExtensibleManifestItem( + /** + * The name of the item. + */ + val name: String, + /** + * The type of item. + */ + val type: String, + /** + * The version of the item. + */ + val version: String, + /** + * The dependencies of the item. + */ + val dependencies: List, + /** + * The files that are required to install the item. + */ + val files: List +) + +/** + * A file built from the item. + */ +@Serializable +data class ExtensibleManifestItemFile( + /** + * The name of the file. + */ + val name: String, + /** + * A type of file. + */ + val type: String, + /** + * The relative path to download the file. + */ + val path: String +) diff --git a/foundation-core/src/main/kotlin/gay/pizza/foundation/core/FoundationCorePlugin.kt b/foundation-core/src/main/kotlin/gay/pizza/foundation/core/FoundationCorePlugin.kt index a71fdf7..669c980 100644 --- a/foundation-core/src/main/kotlin/gay/pizza/foundation/core/FoundationCorePlugin.kt +++ b/foundation-core/src/main/kotlin/gay/pizza/foundation/core/FoundationCorePlugin.kt @@ -2,7 +2,6 @@ package gay.pizza.foundation.core import gay.pizza.foundation.core.abstraction.FoundationPlugin import gay.pizza.foundation.core.features.backup.BackupFeature -import gay.pizza.foundation.core.features.dev.DevFeature import gay.pizza.foundation.core.features.gameplay.GameplayFeature import gay.pizza.foundation.core.features.persist.PersistenceFeature import gay.pizza.foundation.core.features.player.PlayerFeature @@ -49,7 +48,6 @@ class FoundationCorePlugin : IFoundationCore, FoundationPlugin() { SchedulerFeature(), PersistenceFeature(), BackupFeature(), - DevFeature(), GameplayFeature(), PlayerFeature(), StatsFeature(), diff --git a/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/dev/DevFeature.kt b/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/dev/DevFeature.kt deleted file mode 100644 index a04bc9b..0000000 --- a/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/dev/DevFeature.kt +++ /dev/null @@ -1,16 +0,0 @@ -package gay.pizza.foundation.core.features.dev - -import gay.pizza.foundation.core.abstraction.Feature - -class DevFeature : Feature() { - private lateinit var devUpdateServer: DevUpdateServer - - override fun enable() { - devUpdateServer = DevUpdateServer(plugin) - devUpdateServer.enable() - } - - override fun disable() { - devUpdateServer.disable() - } -} diff --git a/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/dev/DevUpdateConfig.kt b/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/dev/DevUpdateConfig.kt deleted file mode 100644 index 931dcb7..0000000 --- a/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/dev/DevUpdateConfig.kt +++ /dev/null @@ -1,10 +0,0 @@ -package gay.pizza.foundation.core.features.dev - -import kotlinx.serialization.Serializable - -@Serializable -class DevUpdateConfig( - val port: Int = 8484, - val token: String, - val ipAllowList: List = listOf("*") -) diff --git a/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/dev/DevUpdatePayload.kt b/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/dev/DevUpdatePayload.kt deleted file mode 100644 index 4ef9b48..0000000 --- a/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/dev/DevUpdatePayload.kt +++ /dev/null @@ -1,13 +0,0 @@ -package gay.pizza.foundation.core.features.dev - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement - -@Serializable -class DevUpdatePayload( - @SerialName("object_kind") - val objectKind: String, - @SerialName("object_attributes") - val objectAttributes: Map -) diff --git a/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/dev/DevUpdateServer.kt b/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/dev/DevUpdateServer.kt deleted file mode 100644 index dc85948..0000000 --- a/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/dev/DevUpdateServer.kt +++ /dev/null @@ -1,117 +0,0 @@ -package gay.pizza.foundation.core.features.dev - -import com.charleskorn.kaml.Yaml -import com.sun.net.httpserver.HttpExchange -import com.sun.net.httpserver.HttpServer -import gay.pizza.foundation.shared.copyDefaultConfig -import gay.pizza.foundation.core.FoundationCorePlugin -import gay.pizza.foundation.core.features.update.UpdateService -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.decodeFromStream -import kotlinx.serialization.json.jsonPrimitive -import java.net.InetSocketAddress -import kotlin.io.path.inputStream - -class DevUpdateServer(val plugin: FoundationCorePlugin) { - private lateinit var config: DevUpdateConfig - private var server: HttpServer? = null - - private val json = Json { - prettyPrint = true - prettyPrintIndent = " " - ignoreUnknownKeys = true - } - - fun enable() { - val configPath = copyDefaultConfig( - plugin.slF4JLogger, - plugin.pluginDataPath, - "devupdate.yaml" - ) - - config = Yaml.default.decodeFromStream(DevUpdateConfig.serializer(), configPath.inputStream()) - start() - } - - private fun start() { - if (config.token.isEmpty()) { - return - } - - if (config.token.length < 8) { - plugin.slF4JLogger.warn("DevUpdateServer Token was too short (must be 8 or more characters)") - return - } - - val server = HttpServer.create() - server.createContext("/").setHandler { exchange -> - handle(exchange) - } - server.bind(InetSocketAddress("0.0.0.0", config.port), 0) - server.start() - this.server = server - plugin.slF4JLogger.info("DevUpdateServer listening on port ${config.port}") - } - - private fun handle(exchange: HttpExchange) { - val ip = exchange.remoteAddress.address.hostAddress - if (!config.ipAllowList.contains("*") && !config.ipAllowList.contains(ip)) { - plugin.slF4JLogger.warn("DevUpdateServer received request from IP $ip which is not allowed.") - exchange.close() - return - } - - plugin.slF4JLogger.info("DevUpdateServer Request $ip ${exchange.requestMethod} ${exchange.requestURI.path}") - if (exchange.requestMethod != "POST") { - exchange.respond(405, "Method not allowed.") - return - } - - if (exchange.requestURI.path != "/webhook/update") { - exchange.respond(404, "Not Found.") - return - } - - if (exchange.requestURI.query != config.token) { - exchange.respond(401, "Unauthorized.") - return - } - - val payload: DevUpdatePayload - try { - payload = json.decodeFromStream(exchange.requestBody) - } catch (e: Exception) { - plugin.slF4JLogger.error("Failed to decode request body.", e) - exchange.respond(400, "Bad Request") - return - } - - if (payload.objectKind != "pipeline" || - payload.objectAttributes["ref"]?.jsonPrimitive?.content != "main" || - payload.objectAttributes["status"]?.jsonPrimitive?.content != "success" - ) { - exchange.respond(200, "Event was not relevant for update.") - return - } - - exchange.respond(200, "Success.") - plugin.slF4JLogger.info("DevUpdate Started") - UpdateService.updatePlugins(plugin.server.consoleSender) { - plugin.server.scheduler.runTask(plugin) { -> - plugin.server.shutdown() - } - } - } - - fun disable() { - server?.stop(1) - } - - private fun HttpExchange.respond(code: Int, content: String) { - val encoded = content.encodeToByteArray() - sendResponseHeaders(code, encoded.size.toLong()) - responseBody.write(encoded) - responseBody.close() - close() - } -} diff --git a/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/ModuleManifest.kt b/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/ModuleManifest.kt deleted file mode 100644 index d295582..0000000 --- a/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/ModuleManifest.kt +++ /dev/null @@ -1,9 +0,0 @@ -package gay.pizza.foundation.core.features.update - -import kotlinx.serialization.Serializable - -@Serializable -data class ModuleManifest( - val version: String, - val artifacts: List, -) diff --git a/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/UpdatePlan.kt b/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/UpdatePlan.kt new file mode 100644 index 0000000..18afdc9 --- /dev/null +++ b/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/UpdatePlan.kt @@ -0,0 +1,8 @@ +package gay.pizza.foundation.core.features.update + +import gay.pizza.foundation.concrete.ExtensibleManifestItem +import org.bukkit.plugin.Plugin + +class UpdatePlan( + val items: Map +) diff --git a/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/UpdateResolver.kt b/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/UpdateResolver.kt new file mode 100644 index 0000000..56fd076 --- /dev/null +++ b/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/UpdateResolver.kt @@ -0,0 +1,47 @@ +package gay.pizza.foundation.core.features.update + +import gay.pizza.foundation.concrete.ExtensibleManifest +import kotlinx.serialization.json.Json +import org.bukkit.Server +import java.net.URL + +class UpdateResolver { + fun fetchCurrentManifest(): ExtensibleManifest { + val jsonContentString = latestManifestUrl.openStream().readAllBytes().decodeToString() + return jsonRelaxed.decodeFromString(ExtensibleManifest.serializer(), jsonContentString) + } + + fun resolve(manifest: ExtensibleManifest, server: Server): UpdatePlan { + val installedPlugins = server.pluginManager.plugins.associateBy { + val key = it.name.lowercase() + val nameOverride = pluginToManifestNameMappings[key] + nameOverride ?: key + } + val installSet = manifest.items + .filter { installedPlugins.containsKey(it.name) } + .associateWith { installedPlugins[it.name] } + .toMutableMap() + + var lastCount = 0 + while (lastCount < installSet.size) { + lastCount = installSet.size + val installSetNames = installSet.keys.map { it.name } + val totalDependencySet = installSet.keys.flatMap { it.dependencies }.toSet() + for (dependencyName in totalDependencySet.filter { !installSetNames.contains(it) }) { + val newDependency = installSet.keys.firstOrNull { it.name == dependencyName } ?: + throw RuntimeException("Unresolved Dependency: $dependencyName") + installSet[newDependency] = null + } + } + return UpdatePlan(installSet) + } + + companion object { + internal val latestManifestUrl = URL("https://artifacts.gay.pizza/foundation/manifest.json") + private val jsonRelaxed = Json { ignoreUnknownKeys = true } + + private val pluginToManifestNameMappings = mapOf( + "foundation" to "foundation-core" + ) + } +} diff --git a/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/UpdateService.kt b/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/UpdateService.kt index 3084356..c6e5b95 100644 --- a/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/UpdateService.kt +++ b/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/UpdateService.kt @@ -4,7 +4,6 @@ import org.bukkit.command.CommandSender import kotlin.io.path.name import kotlin.io.path.toPath -// TODO: Switch to a class and use dependency injection with koin. object UpdateService { fun updatePlugins(sender: CommandSender, onFinish: (() -> Unit)? = null) { val updateDir = sender.server.pluginsFolder.resolve("update") @@ -16,29 +15,24 @@ object UpdateService { val updatePath = updateDir.toPath() Thread { - val modules = UpdateUtil.fetchManifest() - val plugins = sender.server.pluginManager.plugins.associateBy { it.name.lowercase() } - + val resolver = UpdateResolver() + val manifest = resolver.fetchCurrentManifest() + val plan = resolver.resolve(manifest, sender.server) sender.sendMessage("Updates:") - modules.forEach { (name, manifest) -> - // Foolish naming problem. Don't want to fix it right now. - val plugin = if (name == "foundation-core") { - plugins["foundation"] ?: plugins[name.lowercase()] - } else { - plugins[name.lowercase()] + plan.items.forEach { (item, plugin) -> + val pluginJarFileItem = item.files.firstOrNull { it.type == "plugin-jar" } + if (pluginJarFileItem == null) { + sender.sendMessage("WARNING: ${item.name} is required but plugin-jar file not found in manifest. Skipping.") + return@forEach } + val maybeExistingPluginFileName = plugin?.javaClass?.protectionDomain?.codeSource?.location?.toURI()?.toPath()?.name + val fileName = maybeExistingPluginFileName ?: "${item.name}.jar" + val artifactPath = pluginJarFileItem.path - if (plugin == null) { - sender.sendMessage("Plugin in manifest, but not installed: $name (${manifest.version})") - } else { - val fileName = plugin.javaClass.protectionDomain.codeSource.location.toURI().toPath().name - val artifactPath = manifest.artifacts.getOrNull(0) ?: return@forEach - - sender.sendMessage("${plugin.name}: Updating ${plugin.description.version} to ${manifest.version}") - UpdateUtil.downloadArtifact(artifactPath, updatePath.resolve(fileName)) - } + sender.sendMessage("${item.name}: Updating ${plugin?.description?.version ?: "[not-installed]"} to ${item.version}") + UpdateUtil.downloadArtifact(artifactPath, updatePath.resolve(fileName)) } - sender.sendMessage("Restart to take effect") + sender.sendMessage("Restart for updates to take effect.") if (onFinish != null) onFinish() }.start() diff --git a/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/UpdateUtil.kt b/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/UpdateUtil.kt index 29985b9..8b9dc56 100644 --- a/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/UpdateUtil.kt +++ b/foundation-core/src/main/kotlin/gay/pizza/foundation/core/features/update/UpdateUtil.kt @@ -1,8 +1,6 @@ package gay.pizza.foundation.core.features.update import kotlinx.serialization.DeserializationStrategy -import kotlinx.serialization.builtins.MapSerializer -import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.Json import java.net.URI import java.net.http.HttpClient @@ -13,16 +11,11 @@ import java.nio.file.Path object UpdateUtil { private val client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build() - // TODO(liv): Add environment variable override. Document it. - private const val basePath = - "https://artifacts.gay.pizza/foundation" - private const val manifestPath = "build/manifests/update.json" - - fun fetchManifest() = fetchFile( - getUrl(manifestPath), MapSerializer(String.serializer(), ModuleManifest.serializer()), - ) - - fun getUrl(path: String) = "$basePath/$path" + fun getUrl(path: String): String = + UpdateResolver.latestManifestUrl + .toURI() + .resolve(path) + .toString() private inline fun fetchFile(url: String, strategy: DeserializationStrategy): T { val request = HttpRequest @@ -43,16 +36,20 @@ object UpdateUtil { } fun downloadArtifact(path: String, outPath: Path) { + val uri = URI.create(getUrl(path)) val request = HttpRequest .newBuilder() .GET() - .uri(URI.create(getUrl(path))) + .uri(uri) .build() val response = client.send( request, HttpResponse.BodyHandlers.ofFile(outPath) ) + if (response.statusCode() != 200) { + throw RuntimeException("Failed to download URL $uri (Status Code: ${response.statusCode()})") + } response.body() } } diff --git a/foundation-core/src/main/resources/devupdate.yaml b/foundation-core/src/main/resources/devupdate.yaml deleted file mode 100644 index cd46761..0000000 --- a/foundation-core/src/main/resources/devupdate.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# Server port to listen on. -port: 8484 - -# An authentication token. Should be random and 8 or more characters. -# If empty, the DevUpdate server is not enabled. -token: "" - -# IP address allow list. -# If * is specified, all addresses are allowed. -# Specify IP addresses as a string that should be allowed to update the server. -ipAllowList: - - "*"