mirror of
https://github.com/GayPizzaSpecifications/foundation.git
synced 2025-08-02 21:20:55 +00:00
Implement extensible manifest for updates in Foundation.
This commit is contained in:
parent
58aa162aa3
commit
90690666c5
@ -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
|
||||
)
|
@ -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(),
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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("*")
|
||||
)
|
@ -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>
|
||||
)
|
@ -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()
|
||||
}
|
||||
}
|
@ -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>,
|
||||
)
|
@ -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?>
|
||||
)
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
@ -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()
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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:
|
||||
- "*"
|
Loading…
Reference in New Issue
Block a user