mirror of
https://github.com/GayPizzaSpecifications/foundation.git
synced 2025-08-03 05:30:55 +00:00
Implement extensible manifest for updates in Foundation.
This commit is contained in:
@ -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.abstraction.FoundationPlugin
|
||||||
import gay.pizza.foundation.core.features.backup.BackupFeature
|
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.gameplay.GameplayFeature
|
||||||
import gay.pizza.foundation.core.features.persist.PersistenceFeature
|
import gay.pizza.foundation.core.features.persist.PersistenceFeature
|
||||||
import gay.pizza.foundation.core.features.player.PlayerFeature
|
import gay.pizza.foundation.core.features.player.PlayerFeature
|
||||||
@ -49,7 +48,6 @@ class FoundationCorePlugin : IFoundationCore, FoundationPlugin() {
|
|||||||
SchedulerFeature(),
|
SchedulerFeature(),
|
||||||
PersistenceFeature(),
|
PersistenceFeature(),
|
||||||
BackupFeature(),
|
BackupFeature(),
|
||||||
DevFeature(),
|
|
||||||
GameplayFeature(),
|
GameplayFeature(),
|
||||||
PlayerFeature(),
|
PlayerFeature(),
|
||||||
StatsFeature(),
|
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.name
|
||||||
import kotlin.io.path.toPath
|
import kotlin.io.path.toPath
|
||||||
|
|
||||||
// TODO: Switch to a class and use dependency injection with koin.
|
|
||||||
object UpdateService {
|
object UpdateService {
|
||||||
fun updatePlugins(sender: CommandSender, onFinish: (() -> Unit)? = null) {
|
fun updatePlugins(sender: CommandSender, onFinish: (() -> Unit)? = null) {
|
||||||
val updateDir = sender.server.pluginsFolder.resolve("update")
|
val updateDir = sender.server.pluginsFolder.resolve("update")
|
||||||
@ -16,29 +15,24 @@ object UpdateService {
|
|||||||
val updatePath = updateDir.toPath()
|
val updatePath = updateDir.toPath()
|
||||||
|
|
||||||
Thread {
|
Thread {
|
||||||
val modules = UpdateUtil.fetchManifest()
|
val resolver = UpdateResolver()
|
||||||
val plugins = sender.server.pluginManager.plugins.associateBy { it.name.lowercase() }
|
val manifest = resolver.fetchCurrentManifest()
|
||||||
|
val plan = resolver.resolve(manifest, sender.server)
|
||||||
sender.sendMessage("Updates:")
|
sender.sendMessage("Updates:")
|
||||||
modules.forEach { (name, manifest) ->
|
plan.items.forEach { (item, plugin) ->
|
||||||
// Foolish naming problem. Don't want to fix it right now.
|
val pluginJarFileItem = item.files.firstOrNull { it.type == "plugin-jar" }
|
||||||
val plugin = if (name == "foundation-core") {
|
if (pluginJarFileItem == null) {
|
||||||
plugins["foundation"] ?: plugins[name.lowercase()]
|
sender.sendMessage("WARNING: ${item.name} is required but plugin-jar file not found in manifest. Skipping.")
|
||||||
} else {
|
return@forEach
|
||||||
plugins[name.lowercase()]
|
|
||||||
}
|
}
|
||||||
|
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("${item.name}: Updating ${plugin?.description?.version ?: "[not-installed]"} to ${item.version}")
|
||||||
sender.sendMessage("Plugin in manifest, but not installed: $name (${manifest.version})")
|
UpdateUtil.downloadArtifact(artifactPath, updatePath.resolve(fileName))
|
||||||
} 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("Restart to take effect")
|
sender.sendMessage("Restart for updates to take effect.")
|
||||||
|
|
||||||
if (onFinish != null) onFinish()
|
if (onFinish != null) onFinish()
|
||||||
}.start()
|
}.start()
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
package gay.pizza.foundation.core.features.update
|
package gay.pizza.foundation.core.features.update
|
||||||
|
|
||||||
import kotlinx.serialization.DeserializationStrategy
|
import kotlinx.serialization.DeserializationStrategy
|
||||||
import kotlinx.serialization.builtins.MapSerializer
|
|
||||||
import kotlinx.serialization.builtins.serializer
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.net.http.HttpClient
|
import java.net.http.HttpClient
|
||||||
@ -13,16 +11,11 @@ import java.nio.file.Path
|
|||||||
object UpdateUtil {
|
object UpdateUtil {
|
||||||
private val client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build()
|
private val client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build()
|
||||||
|
|
||||||
// TODO(liv): Add environment variable override. Document it.
|
fun getUrl(path: String): String =
|
||||||
private const val basePath =
|
UpdateResolver.latestManifestUrl
|
||||||
"https://artifacts.gay.pizza/foundation"
|
.toURI()
|
||||||
private const val manifestPath = "build/manifests/update.json"
|
.resolve(path)
|
||||||
|
.toString()
|
||||||
fun fetchManifest() = fetchFile(
|
|
||||||
getUrl(manifestPath), MapSerializer(String.serializer(), ModuleManifest.serializer()),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun getUrl(path: String) = "$basePath/$path"
|
|
||||||
|
|
||||||
private inline fun <reified T> fetchFile(url: String, strategy: DeserializationStrategy<T>): T {
|
private inline fun <reified T> fetchFile(url: String, strategy: DeserializationStrategy<T>): T {
|
||||||
val request = HttpRequest
|
val request = HttpRequest
|
||||||
@ -43,16 +36,20 @@ object UpdateUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun downloadArtifact(path: String, outPath: Path) {
|
fun downloadArtifact(path: String, outPath: Path) {
|
||||||
|
val uri = URI.create(getUrl(path))
|
||||||
val request = HttpRequest
|
val request = HttpRequest
|
||||||
.newBuilder()
|
.newBuilder()
|
||||||
.GET()
|
.GET()
|
||||||
.uri(URI.create(getUrl(path)))
|
.uri(uri)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val response = client.send(
|
val response = client.send(
|
||||||
request,
|
request,
|
||||||
HttpResponse.BodyHandlers.ofFile(outPath)
|
HttpResponse.BodyHandlers.ofFile(outPath)
|
||||||
)
|
)
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
throw RuntimeException("Failed to download URL $uri (Status Code: ${response.statusCode()})")
|
||||||
|
}
|
||||||
response.body()
|
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:
|
|
||||||
- "*"
|
|
Reference in New Issue
Block a user