Implement extensible manifest for updates in Foundation.

This commit is contained in:
Alex Zenla 2023-03-13 21:01:26 -07:00
parent 58aa162aa3
commit 90690666c5
Signed by: alex
GPG Key ID: C0780728420EBFE5
12 changed files with 139 additions and 212 deletions

View File

@ -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<ExtensibleManifestItem>
)
/**
* 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<String>,
/**
* The files that are required to install the item.
*/
val files: List<ExtensibleManifestItemFile>
)
/**
* 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
)

View File

@ -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(),

View File

@ -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()
}
}

View File

@ -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<String> = listOf("*")
)

View File

@ -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<String, JsonElement>
)

View File

@ -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<FoundationCorePlugin>(
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()
}
}

View File

@ -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<String>,
)

View File

@ -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<ExtensibleManifestItem, Plugin?>
)

View File

@ -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"
)
}
}

View File

@ -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()

View File

@ -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 <reified T> fetchFile(url: String, strategy: DeserializationStrategy<T>): 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()
}
}

View File

@ -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:
- "*"