diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 1fbf799..6d85acb 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -4,6 +4,8 @@ dependencies { implementation(platform("gg.grounds:grounds-dependencies:0.1.0")) api("com.google.protobuf:protobuf-java") + implementation("tools.jackson.core:jackson-databind:3.1.0") + implementation("tools.jackson.module:jackson-module-kotlin:3.1.0") testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifest.kt b/common/src/main/kotlin/gg/grounds/permissions/catalog/PermissionManifest.kt similarity index 58% rename from velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifest.kt rename to common/src/main/kotlin/gg/grounds/permissions/catalog/PermissionManifest.kt index a22ab21..2fb0b68 100644 --- a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifest.kt +++ b/common/src/main/kotlin/gg/grounds/permissions/catalog/PermissionManifest.kt @@ -1,4 +1,4 @@ -package gg.grounds.permissions.velocity +package gg.grounds.permissions.catalog import java.io.InputStream import tools.jackson.databind.DeserializationFeature @@ -6,53 +6,59 @@ import tools.jackson.databind.ObjectMapper import tools.jackson.databind.json.JsonMapper import tools.jackson.module.kotlin.KotlinModule -data class PermissionManifest(val permissions: List) { +data class PermissionManifest(val source: String, val permissions: List) { init { + require(source.isNotBlank()) { "Permission manifest source must not be blank." } require(permissions.isNotEmpty()) { "Permission manifest must declare at least one permission." } require(permissions.map(PermissionManifestEntry::key).toSet().size == permissions.size) { "Permission manifest must not declare duplicate keys." } + permissions.forEach { permission -> + require(permission.key.isNotBlank()) { "Permission manifest keys must not be blank." } + require(permission.label.isNotBlank()) { + "Permission manifest labels must not be blank." + } + require(permission.description.isNotBlank()) { + "Permission manifest descriptions must not be blank (key=${permission.key})." + } + require(permission.supportedScopes.isNotEmpty()) { + "Permission manifest scopes must not be empty (key=${permission.key})." + } + } } companion object { - private const val RESOURCE_NAME = "META-INF/grounds-permissions.json" + const val RESOURCE_PATH = "META-INF/grounds/permissions.json" + private val mapper: ObjectMapper = JsonMapper.builder() .addModule(KotlinModule.Builder().build()) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .build() - fun loadResource(): PermissionManifest { - val input = - PermissionManifest::class.java.classLoader.getResourceAsStream(RESOURCE_NAME) - ?: error("Missing required permission manifest resource $RESOURCE_NAME") - return input.use(::load) - } - fun load(input: InputStream): PermissionManifest = - mapper.readValue(input, PermissionManifest::class.java).validated() - } - - private fun validated(): PermissionManifest { - permissions.forEach { permission -> - require(permission.key.isNotBlank()) { "Permission manifest keys must not be blank." } - require(permission.label.isNotBlank()) { - "Permission manifest labels must not be blank." + try { + mapper.readValue(input, PermissionManifest::class.java) + } catch (exception: Exception) { + throw IllegalArgumentException("Invalid permission manifest.", exception) } - require(permission.supportedScopes.isNotEmpty()) { - "Permission manifest scopes must not be empty (key=${permission.key})." + + fun loadResource(classLoader: ClassLoader): PermissionManifest? = + classLoader.getResourceAsStream(RESOURCE_PATH)?.use(::load) + + fun loadRequiredResource(classLoader: ClassLoader): PermissionManifest = + requireNotNull(loadResource(classLoader)) { + "Missing required permission manifest resource $RESOURCE_PATH" } - } - return this } } data class PermissionManifestEntry( val key: String, val label: String, - val description: String = "", + val description: String, val supportedScopes: List, ) diff --git a/common/src/main/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollector.kt b/common/src/main/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollector.kt new file mode 100644 index 0000000..c6aae69 --- /dev/null +++ b/common/src/main/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollector.kt @@ -0,0 +1,34 @@ +package gg.grounds.permissions.catalog + +data class ManifestOrigin(val id: String, val version: String, val classLoader: ClassLoader) + +data class CollectedPermissionManifest(val origin: ManifestOrigin, val manifest: PermissionManifest) + +data class PermissionManifestCollectionFailure(val origin: ManifestOrigin, val reason: String) + +data class PermissionManifestCollection( + val manifests: List, + val failures: List, +) + +class PermissionManifestCollector { + fun collect(origins: Iterable): PermissionManifestCollection { + val manifests = mutableListOf() + val failures = mutableListOf() + + origins.forEach { origin -> + try { + val manifest = PermissionManifest.loadResource(origin.classLoader) ?: return@forEach + manifests += CollectedPermissionManifest(origin, manifest) + } catch (exception: IllegalArgumentException) { + failures += + PermissionManifestCollectionFailure( + origin = origin, + reason = exception.message ?: exception::class.java.simpleName, + ) + } + } + + return PermissionManifestCollection(manifests = manifests, failures = failures) + } +} diff --git a/common/src/test/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollectorTest.kt b/common/src/test/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollectorTest.kt new file mode 100644 index 0000000..a9598d2 --- /dev/null +++ b/common/src/test/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollectorTest.kt @@ -0,0 +1,192 @@ +package gg.grounds.permissions.catalog + +import java.io.ByteArrayInputStream +import java.io.InputStream +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PermissionManifestCollectorTest { + @Test + fun `loads the established manifest shape`() { + val manifest = + PermissionManifest.load( + """ + { + "source": "plugin-agones", + "permissions": [ + { + "key": "grounds.command.agones", + "label": "Use Agones command", + "description": "Allows using the Agones command.", + "supportedScopes": ["GLOBAL", "SERVER_TYPE", "SERVER"] + } + ] + } + """ + .trimIndent() + .byteInputStream() + ) + + assertEquals("plugin-agones", manifest.source) + assertEquals("grounds.command.agones", manifest.permissions.single().key) + assertEquals( + listOf( + PermissionManifestScope.GLOBAL, + PermissionManifestScope.SERVER_TYPE, + PermissionManifestScope.SERVER, + ), + manifest.permissions.single().supportedScopes, + ) + } + + @Test + fun `skips origins without a manifest resource`() { + val collection = + PermissionManifestCollector() + .collect(listOf(ManifestOrigin("missing-plugin", "1.0.0", ResourceClassLoader()))) + + assertTrue(collection.manifests.isEmpty()) + assertTrue(collection.failures.isEmpty()) + } + + @Test + fun `reports duplicate permission keys without collecting the manifest`() { + val collection = + PermissionManifestCollector() + .collect( + listOf( + ManifestOrigin( + "duplicate-plugin", + "1.0.0", + ResourceClassLoader( + """ + { + "source": "duplicate-plugin", + "permissions": [ + { "key": "grounds.command.duplicate", "label": "Duplicate", "description": "Allows duplicate command.", "supportedScopes": ["GLOBAL"] }, + { "key": "grounds.command.duplicate", "label": "Duplicate again", "description": "Allows duplicate command again.", "supportedScopes": ["GLOBAL"] } + ] + } + """ + .trimIndent() + ), + ) + ) + ) + + assertTrue(collection.manifests.isEmpty()) + assertEquals("duplicate-plugin", collection.failures.single().origin.id) + } + + @Test + fun `reports manifests with missing or blank sources`() { + val collection = + PermissionManifestCollector() + .collect( + listOf( + ManifestOrigin( + "missing-source", + "1.0.0", + ResourceClassLoader( + """ + { + "permissions": [ + { "key": "grounds.command.missing-source", "label": "Missing source", "description": "Requires a source.", "supportedScopes": ["GLOBAL"] } + ] + } + """ + .trimIndent() + ), + ), + ManifestOrigin( + "blank-source", + "1.0.0", + ResourceClassLoader( + """ + { + "source": " ", + "permissions": [ + { "key": "grounds.command.blank-source", "label": "Blank source", "description": "Requires a source.", "supportedScopes": ["GLOBAL"] } + ] + } + """ + .trimIndent() + ), + ), + ) + ) + + assertTrue(collection.manifests.isEmpty()) + assertEquals( + listOf("missing-source", "blank-source"), + collection.failures.map { it.origin.id }, + ) + } + + @Test + fun `reports manifests with blank required permission entry fields`() { + val collection = + PermissionManifestCollector() + .collect( + listOf("key", "label", "description").map { field -> + ManifestOrigin( + "blank-$field", + "1.0.0", + ResourceClassLoader( + """ + { + "source": "blank-$field", + "permissions": [ + { + "key": "${if (field == "key") " " else "grounds.command.blank-$field"}", + "label": "${if (field == "label") " " else "Use command"}", + "description": "${if (field == "description") " " else "Allows using the command."}", + "supportedScopes": ["GLOBAL"] + } + ] + } + """ + .trimIndent() + ), + ) + } + ) + + assertTrue(collection.manifests.isEmpty()) + assertEquals( + listOf("blank-key", "blank-label", "blank-description"), + collection.failures.map { it.origin.id }, + ) + } + + @Test + fun `rejects unsupported scopes`() { + assertThrows(IllegalArgumentException::class.java) { + PermissionManifest.load( + """ + { + "source": "invalid-plugin", + "permissions": [ + { "key": "grounds.command.invalid", "label": "Invalid", "description": "Allows invalid command.", "supportedScopes": ["PROJECT"] } + ] + } + """ + .trimIndent() + .byteInputStream() + ) + } + } + + private class ResourceClassLoader(manifest: String? = null) : ClassLoader() { + private val bytes = manifest?.encodeToByteArray() + + override fun getResourceAsStream(name: String): InputStream? = + if (name == PermissionManifest.RESOURCE_PATH && bytes != null) { + ByteArrayInputStream(bytes) + } else { + null + } + } +} diff --git a/minestom/build.gradle.kts b/minestom/build.gradle.kts index 97c0bad..a501a1e 100644 --- a/minestom/build.gradle.kts +++ b/minestom/build.gradle.kts @@ -13,7 +13,7 @@ buildConfig { dependencies { implementation(platform("gg.grounds:grounds-dependencies:0.1.0")) - api("gg.grounds:grounds-minestom-runtime-runtime-api:0.1.0") + api("gg.grounds:grounds-minestom-runtime-runtime-api:0.3.0") implementation(project(":common")) implementation("io.grpc:grpc-netty-shaded:1.79.0") implementation("io.grpc:grpc-stub") diff --git a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModule.kt b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModule.kt index ae5b259..8babdeb 100644 --- a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModule.kt +++ b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModule.kt @@ -5,9 +5,13 @@ import gg.grounds.permissions.InMemoryPermissionSnapshots import gg.grounds.permissions.PermissionCheckScope import gg.grounds.permissions.Permissions import gg.grounds.permissions.SnapshotPermissions +import gg.grounds.permissions.catalog.CollectedPermissionManifest import gg.grounds.runtime.GroundsModule import gg.grounds.runtime.GroundsServerContext import java.time.Clock +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException import net.minestom.server.MinecraftServer import net.minestom.server.event.Event import net.minestom.server.event.EventNode @@ -18,6 +22,8 @@ class GroundsPermissionsModule(private val clock: Clock = Clock.systemUTC()) : G private val logger: Logger = LoggerFactory.getLogger(GroundsPermissionsModule::class.java) private val snapshots = InMemoryPermissionSnapshots() private var client: PermissionSnapshotClient? = null + private var catalogClient: PermissionCatalogClient? = null + private var catalogExecutor: ExecutorService? = null private var eventNode: EventNode? = null override val id: String = MODULE_ID @@ -31,6 +37,11 @@ class GroundsPermissionsModule(private val clock: Clock = Clock.systemUTC()) : G fallbackServerType = ctx.serverType.name.lowercase(), ) val snapshotClient = GrpcPermissionSnapshotClient.create(config.grpcTarget) + val manifestClient = GrpcPermissionCatalogClient.create(config.grpcTarget) + val manifestExecutor = + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "grounds-permissions-manifest-catalog").apply { isDaemon = true } + } val permissions = SnapshotPermissions( snapshots = snapshots, @@ -53,9 +64,18 @@ class GroundsPermissionsModule(private val clock: Clock = Clock.systemUTC()) : G MinecraftServer.getGlobalEventHandler().addChild(node) eventNode = node client = snapshotClient + catalogClient = manifestClient + catalogExecutor = manifestExecutor ctx.onShutdown { stop() } + registerActivePermissionManifests( + activeProviders = ctx.activeModuleProviders, + manifestClient = manifestClient, + manifestExecutor = manifestExecutor, + context = config.context, + ) + logger.info( "Installed permissions module (serverType={}, serverId={}, target={})", config.context.serverType, @@ -67,8 +87,61 @@ class GroundsPermissionsModule(private val clock: Clock = Clock.systemUTC()) : G override fun stop() { eventNode?.let(MinecraftServer.getGlobalEventHandler()::removeChild) eventNode = null + catalogExecutor?.shutdownNow() + catalogExecutor = null client?.close() client = null + catalogClient?.close() + catalogClient = null + } + + private fun registerActivePermissionManifests( + activeProviders: Iterable, + manifestClient: PermissionCatalogClient, + manifestExecutor: ExecutorService, + context: PermissionSnapshotContext, + ) { + try { + manifestExecutor.execute { + val collection = collectActivePermissionManifests(activeProviders) + collection.failures.forEach { failure -> + logger.warn( + "Skipped malformed permission manifest (originId={}, originVersion={}, reason={})", + failure.origin.id, + failure.origin.version, + failure.reason, + ) + } + val registration = + MinestomPermissionManifestRegistrar(manifestClient, context) + .register(collection.manifests) + registration.registered.forEach(::logManifestRegistration) + registration.failures.forEach { failure -> + logger.warn( + "Failed to register permission catalog manifest (originId={}, source={}, attempts={}, reason={})", + failure.collected.origin.id, + failure.collected.manifest.source, + failure.attempts, + failure.reason, + ) + } + } + } catch (exception: RejectedExecutionException) { + logger.warn( + "Skipped permission catalog registration (reason={})", + exception::class.java.simpleName, + ) + } + } + + private fun logManifestRegistration(collected: CollectedPermissionManifest) { + logger.info( + "Registered permission catalog manifest successfully (originId={}, source={}, version={}, permissionCount={})", + collected.origin.id, + collected.manifest.source, + collected.origin.version, + collected.manifest.permissions.size, + ) } companion object { diff --git a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionCatalogClient.kt b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionCatalogClient.kt new file mode 100644 index 0000000..f7edd4d --- /dev/null +++ b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionCatalogClient.kt @@ -0,0 +1,109 @@ +package gg.grounds.permissions.minestom + +import gg.grounds.grpc.permissions.PermissionCatalogServiceGrpc +import gg.grounds.grpc.permissions.PermissionManifestEntry as GrpcPermissionManifestEntry +import gg.grounds.grpc.permissions.PermissionScopeKind +import gg.grounds.grpc.permissions.RegisterPermissionManifestRequest +import gg.grounds.permissions.catalog.PermissionManifest +import gg.grounds.permissions.catalog.PermissionManifestEntry +import gg.grounds.permissions.catalog.PermissionManifestScope +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import io.grpc.Status +import io.grpc.StatusRuntimeException +import java.util.concurrent.TimeUnit + +sealed interface PermissionManifestRegistrationResult { + data object Accepted : PermissionManifestRegistrationResult + + data class Unavailable(val reason: String) : PermissionManifestRegistrationResult +} + +interface PermissionCatalogClient : AutoCloseable { + fun register( + manifest: PermissionManifest, + sourceVersion: String, + context: PermissionSnapshotContext, + ): PermissionManifestRegistrationResult + + override fun close() {} +} + +class GrpcPermissionCatalogClient private constructor(private val channel: ManagedChannel) : + PermissionCatalogClient { + private val stub = PermissionCatalogServiceGrpc.newBlockingStub(channel) + + override fun register( + manifest: PermissionManifest, + sourceVersion: String, + context: PermissionSnapshotContext, + ): PermissionManifestRegistrationResult = + try { + val reply = + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .registerPermissionManifest( + RegisterPermissionManifestRequest.newBuilder() + .setSource(manifest.source) + .setSourceVersion(sourceVersion) + .setServerType(context.serverType.orEmpty()) + .setServerId(context.serverId.orEmpty()) + .addAllPermissions( + manifest.permissions.map(PermissionManifestEntry::toGrpc) + ) + .build() + ) + if (reply.accepted) { + PermissionManifestRegistrationResult.Accepted + } else { + PermissionManifestRegistrationResult.Unavailable( + reply.message.ifBlank { "rejected" } + ) + } + } catch (exception: StatusRuntimeException) { + PermissionManifestRegistrationResult.Unavailable(exception.status.toSafeReason()) + } catch (exception: RuntimeException) { + PermissionManifestRegistrationResult.Unavailable( + exception.message ?: exception::class.java.name + ) + } + + override fun close() { + channel.shutdown() + try { + if (!channel.awaitTermination(3, TimeUnit.SECONDS)) { + channel.shutdownNow() + channel.awaitTermination(3, TimeUnit.SECONDS) + } + } catch (exception: InterruptedException) { + Thread.currentThread().interrupt() + channel.shutdownNow() + } + } + + companion object { + fun create(target: String): GrpcPermissionCatalogClient = + GrpcPermissionCatalogClient( + ManagedChannelBuilder.forTarget(target).usePlaintext().build() + ) + + private const val DEFAULT_TIMEOUT_MS = 2000L + } +} + +private fun PermissionManifestEntry.toGrpc(): GrpcPermissionManifestEntry = + GrpcPermissionManifestEntry.newBuilder() + .setKey(key) + .setLabel(label) + .setDescription(description) + .addAllSupportedScopes(supportedScopes.map(PermissionManifestScope::toGrpc)) + .build() + +private fun PermissionManifestScope.toGrpc(): PermissionScopeKind = + when (this) { + PermissionManifestScope.GLOBAL -> PermissionScopeKind.PERMISSION_SCOPE_KIND_GLOBAL + PermissionManifestScope.SERVER_TYPE -> PermissionScopeKind.PERMISSION_SCOPE_KIND_SERVER_TYPE + PermissionManifestScope.SERVER -> PermissionScopeKind.PERMISSION_SCOPE_KIND_SERVER + } + +private fun Status.toSafeReason(): String = code.toString() diff --git a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionManifestRegistration.kt b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionManifestRegistration.kt new file mode 100644 index 0000000..2c7fee7 --- /dev/null +++ b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionManifestRegistration.kt @@ -0,0 +1,96 @@ +package gg.grounds.permissions.minestom + +import gg.grounds.permissions.catalog.CollectedPermissionManifest +import gg.grounds.permissions.catalog.ManifestOrigin +import gg.grounds.permissions.catalog.PermissionManifestCollection +import gg.grounds.permissions.catalog.PermissionManifestCollector +import gg.grounds.runtime.ActiveGroundsModuleProvider + +data class MinestomPermissionManifestRegistrationFailure( + val collected: CollectedPermissionManifest, + val attempts: Int, + val reason: String, +) + +data class MinestomPermissionManifestRegistrationReport( + val registered: List, + val failures: List, +) + +fun collectActivePermissionManifests( + activeProviders: Iterable +): PermissionManifestCollection = + PermissionManifestCollector() + .collect( + activeProviders.map { provider -> + ManifestOrigin( + id = provider.id, + version = provider.version, + classLoader = provider.classLoader, + ) + } + ) + +class MinestomPermissionManifestRegistrar( + private val client: PermissionCatalogClient, + private val context: PermissionSnapshotContext, + private val sleep: (Long) -> Unit = { duration -> Thread.sleep(duration) }, +) { + fun register( + manifests: Iterable + ): MinestomPermissionManifestRegistrationReport { + val registered = mutableListOf() + val failures = mutableListOf() + + manifests.forEach manifestLoop@{ collected -> + repeat(MAX_ATTEMPTS) { attempt -> + when ( + val result = + client.register( + manifest = collected.manifest, + sourceVersion = collected.origin.version, + context = context, + ) + ) { + PermissionManifestRegistrationResult.Accepted -> { + registered += collected + return@manifestLoop + } + is PermissionManifestRegistrationResult.Unavailable -> { + if (attempt == MAX_ATTEMPTS - 1) { + failures += + MinestomPermissionManifestRegistrationFailure( + collected = collected, + attempts = MAX_ATTEMPTS, + reason = result.reason, + ) + return@manifestLoop + } + try { + sleep((attempt + 1) * BACKOFF_MS) + } catch (exception: InterruptedException) { + Thread.currentThread().interrupt() + failures += + MinestomPermissionManifestRegistrationFailure( + collected = collected, + attempts = attempt + 1, + reason = "interrupted", + ) + return@manifestLoop + } + } + } + } + } + + return MinestomPermissionManifestRegistrationReport( + registered = registered, + failures = failures, + ) + } + + private companion object { + const val MAX_ATTEMPTS = 5 + const val BACKOFF_MS = 250L + } +} diff --git a/minestom/src/test/kotlin/gg/grounds/permissions/minestom/PermissionManifestDiscoveryTest.kt b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/PermissionManifestDiscoveryTest.kt new file mode 100644 index 0000000..8b702e8 --- /dev/null +++ b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/PermissionManifestDiscoveryTest.kt @@ -0,0 +1,157 @@ +package gg.grounds.permissions.minestom + +import gg.grounds.permissions.catalog.ManifestOrigin +import gg.grounds.permissions.catalog.PermissionManifest +import gg.grounds.runtime.ActiveGroundsModuleProvider +import java.io.ByteArrayInputStream +import java.io.InputStream +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PermissionManifestDiscoveryTest { + @Test + fun `collects and registers manifests from active providers while skipping missing and malformed resources`() { + val activeProviders = + listOf( + provider("active-one", "1.0.0", validManifestClassLoader("active-one")), + provider("without-manifest", "1.0.0", ClassLoader.getSystemClassLoader()), + provider("malformed", "1.0.0", malformedManifestClassLoader()), + provider("active-two", "2.0.0", validManifestClassLoader("active-two")), + ) + val client = RecordingCatalogClient() + val collection = collectActivePermissionManifests(activeProviders) + + assertEquals( + listOf("active-one", "active-two"), + collection.manifests.map { it.manifest.source }, + ) + assertEquals(listOf("malformed"), collection.failures.map { it.origin.id }) + + assertDoesNotThrow { + MinestomPermissionManifestRegistrar( + client = client, + context = PermissionSnapshotContext(serverType = "arena", serverId = "arena-1"), + sleep = {}, + ) + .register(collection.manifests) + } + + assertEquals(listOf("active-one", "active-two"), client.registeredSources) + } + + @Test + fun `retries catalog failures five times and reports the final failure without throwing`() { + val client = UnavailableCatalogClient("unavailable") + val sleeps = mutableListOf() + val manifest = + PermissionManifest( + source = "active-module", + permissions = + listOf( + gg.grounds.permissions.catalog.PermissionManifestEntry( + key = "grounds.permissions.read", + label = "Read permissions", + description = "Allows reading permission state.", + supportedScopes = + listOf( + gg.grounds.permissions.catalog.PermissionManifestScope.GLOBAL + ), + ) + ), + ) + + val report = + assertDoesNotThrow { + MinestomPermissionManifestRegistrar( + client = client, + context = PermissionSnapshotContext(serverType = "arena"), + sleep = sleeps::add, + ) + .register( + listOf( + gg.grounds.permissions.catalog.CollectedPermissionManifest( + origin = + ManifestOrigin( + id = "active-module", + version = "1.0.0", + classLoader = javaClass.classLoader, + ), + manifest = manifest, + ) + ) + ) + } + + assertEquals(5, client.attempts) + assertEquals(listOf(250L, 500L, 750L, 1_000L), sleeps) + assertTrue(report.registered.isEmpty()) + assertEquals(1, report.failures.size) + assertEquals("unavailable", report.failures.single().reason) + } + + private fun provider( + id: String, + version: String, + classLoader: ClassLoader, + ): ActiveGroundsModuleProvider = + ActiveGroundsModuleProvider(id = id, version = version, classLoader = classLoader) + + private fun validManifestClassLoader(source: String): ClassLoader = + object : ClassLoader() { + override fun getResourceAsStream(name: String): InputStream? = + if (name == PermissionManifest.RESOURCE_PATH) { + ByteArrayInputStream( + """ + { + "source": "$source", + "permissions": [ + { "key": "grounds.$source.read", "label": "Read", "description": "Allows reading.", "supportedScopes": ["GLOBAL"] } + ] + } + """ + .trimIndent() + .encodeToByteArray() + ) + } else { + null + } + } + + private fun malformedManifestClassLoader(): ClassLoader = + object : ClassLoader() { + override fun getResourceAsStream(name: String): InputStream? = + if (name == PermissionManifest.RESOURCE_PATH) { + ByteArrayInputStream("not-json".encodeToByteArray()) + } else { + null + } + } + + private class RecordingCatalogClient : PermissionCatalogClient { + val registeredSources = mutableListOf() + + override fun register( + manifest: PermissionManifest, + sourceVersion: String, + context: PermissionSnapshotContext, + ): PermissionManifestRegistrationResult { + registeredSources += manifest.source + return PermissionManifestRegistrationResult.Accepted + } + } + + private class UnavailableCatalogClient(private val reason: String) : PermissionCatalogClient { + var attempts = 0 + + override fun register( + manifest: PermissionManifest, + sourceVersion: String, + context: PermissionSnapshotContext, + ): PermissionManifestRegistrationResult { + attempts += 1 + return PermissionManifestRegistrationResult.Unavailable(reason) + } + } +} diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index acd51f6..3ffd14f 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -11,5 +11,6 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.slf4j:slf4j-api") + testImplementation("com.velocitypowered:velocity-api") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/GroundsPermissionsPlugin.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/GroundsPermissionsPlugin.kt index ef0ce55..cf58c11 100644 --- a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/GroundsPermissionsPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/GroundsPermissionsPlugin.kt @@ -12,6 +12,8 @@ import gg.grounds.BuildInfo import gg.grounds.permissions.InMemoryPermissionSnapshots import gg.grounds.permissions.PermissionCheckScope import gg.grounds.permissions.SnapshotPermissions +import gg.grounds.permissions.catalog.PermissionManifest +import gg.grounds.permissions.catalog.PermissionManifestCollector import io.grpc.LoadBalancerRegistry import io.grpc.NameResolverRegistry import io.grpc.internal.DnsNameResolverProvider @@ -65,54 +67,57 @@ constructor( val permissions = SnapshotPermissions(snapshots, defaultScope = config.context.toCheckScope()) - val manifest = PermissionManifest.loadResource() - val commandPermissions = PermissionCommandPermissions.fromManifest(manifest) - val router = - PermissionCommandRouter( - service = - PermissionCommandService( - snapshots = snapshots, - permissions = permissions, - refreshSnapshot = listener::loadSnapshot, - status = - PermissionCommandStatus( - version = BuildInfo.VERSION, - grpcTarget = config.grpcTarget, - context = config.context, - ), - ), - findOnlinePlayer = { identifier -> - VelocityPermissionsCommand.findOnlinePlayer(proxy, identifier) - }, - onlinePlayers = { VelocityPermissionsCommand.onlinePlayers(proxy) }, - defaultScope = - PermissionCheckScopeArgument( - serverType = config.context.serverType, - server = config.context.serverId, - ), - ) - val command = - VelocityPermissionsCommand( - plugin = this, - proxy = proxy, - router = router, - commandPermissions = commandPermissions, - isAuthorized = { source, permission -> - VelocityPermissionsCommand.isPlayerAuthorized(source, permissions, permission) - }, - ) - val commandMeta = - proxy.commandManager.metaBuilder("permissions").aliases("perm").plugin(this).build() - proxy.commandManager.register(commandMeta, command) - this.commandMeta = commandMeta + loadCommandPermissions()?.let { commandPermissions -> + val router = + PermissionCommandRouter( + service = + PermissionCommandService( + snapshots = snapshots, + permissions = permissions, + refreshSnapshot = listener::loadSnapshot, + status = + PermissionCommandStatus( + version = BuildInfo.VERSION, + grpcTarget = config.grpcTarget, + context = config.context, + ), + ), + findOnlinePlayer = { identifier -> + VelocityPermissionsCommand.findOnlinePlayer(proxy, identifier) + }, + onlinePlayers = { VelocityPermissionsCommand.onlinePlayers(proxy) }, + defaultScope = + PermissionCheckScopeArgument( + serverType = config.context.serverType, + server = config.context.serverId, + ), + ) + val command = + VelocityPermissionsCommand( + plugin = this, + proxy = proxy, + router = router, + commandPermissions = commandPermissions, + isAuthorized = { source, permission -> + VelocityPermissionsCommand.isPlayerAuthorized( + source, + permissions, + permission, + ) + }, + ) + val commandMeta = + proxy.commandManager.metaBuilder("permissions").aliases("perm").plugin(this).build() + proxy.commandManager.register(commandMeta, command) + this.commandMeta = commandMeta - logger.info("Registered permissions commands successfully (root=permissions, alias=perm)") + logger.info( + "Registered permissions commands successfully (root=permissions, alias=perm)" + ) + } proxy.scheduler - .buildTask( - this, - Runnable { registerPermissionManifest(manifestClient, manifest, config) }, - ) + .buildTask(this, Runnable { registerActivePermissionManifests(manifestClient, config) }) .schedule() logger.info( @@ -138,31 +143,54 @@ constructor( LoadBalancerRegistry.getDefaultRegistry().register(PickFirstLoadBalancerProvider()) } - private fun registerPermissionManifest( + private fun loadCommandPermissions(): PermissionCommandPermissions? = + try { + PermissionCommandPermissions.fromManifest( + PermissionManifest.loadRequiredResource(javaClass.classLoader) + ) + } catch (exception: IllegalArgumentException) { + logger.warn( + "Skipped permissions command registration (originId=plugin-permissions, reason={})", + exception.message ?: exception::class.java.simpleName, + ) + null + } + + private fun registerActivePermissionManifests( manifestClient: PermissionCatalogClient, - manifest: PermissionManifest, config: VelocityPermissionsConfig, ) { - when ( - val result = - manifestClient.register( - manifest = manifest, - source = "plugin-permissions", - sourceVersion = BuildInfo.VERSION, - context = config.context, - ) - ) { - PermissionManifestRegistrationResult.Accepted -> - logger.info( - "Registered permission catalog manifest successfully (source=plugin-permissions, version={}, permissionCount={})", - BuildInfo.VERSION, - manifest.permissions.size, - ) - is PermissionManifestRegistrationResult.Unavailable -> - logger.warn( - "Failed to register permission catalog manifest (source=plugin-permissions, reason={})", - result.reason, - ) + val collection = + PermissionManifestCollector() + .collect(discoverPermissionManifestOrigins(proxy.pluginManager.plugins)) + collection.failures.forEach { failure -> + logger.warn( + "Skipped malformed permission manifest (originId={}, originVersion={}, reason={})", + failure.origin.id, + failure.origin.version, + failure.reason, + ) + } + val registration = + PermissionManifestRegistrar(manifestClient, config.context) + .register(collection.manifests) + registration.registered.forEach { collected -> + logger.info( + "Registered permission catalog manifest successfully (originId={}, source={}, version={}, permissionCount={})", + collected.origin.id, + collected.manifest.source, + collected.origin.version, + collected.manifest.permissions.size, + ) + } + registration.failures.forEach { failure -> + logger.warn( + "Failed to register permission catalog manifest (originId={}, source={}, attempts={}, reason={})", + failure.collected.origin.id, + failure.collected.manifest.source, + failure.attempts, + failure.reason, + ) } } } diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCatalogClient.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCatalogClient.kt index 198af29..6a888ec 100644 --- a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCatalogClient.kt +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCatalogClient.kt @@ -4,6 +4,9 @@ import gg.grounds.grpc.permissions.PermissionCatalogServiceGrpc import gg.grounds.grpc.permissions.PermissionManifestEntry as GrpcPermissionManifestEntry import gg.grounds.grpc.permissions.PermissionScopeKind import gg.grounds.grpc.permissions.RegisterPermissionManifestRequest +import gg.grounds.permissions.catalog.PermissionManifest +import gg.grounds.permissions.catalog.PermissionManifestEntry +import gg.grounds.permissions.catalog.PermissionManifestScope import io.grpc.ManagedChannel import io.grpc.ManagedChannelBuilder import io.grpc.Status @@ -19,7 +22,6 @@ sealed interface PermissionManifestRegistrationResult { interface PermissionCatalogClient : AutoCloseable { fun register( manifest: PermissionManifest, - source: String, sourceVersion: String, context: PermissionSnapshotContext, ): PermissionManifestRegistrationResult @@ -33,7 +35,6 @@ class GrpcPermissionCatalogClient private constructor(private val channel: Manag override fun register( manifest: PermissionManifest, - source: String, sourceVersion: String, context: PermissionSnapshotContext, ): PermissionManifestRegistrationResult = @@ -43,7 +44,7 @@ class GrpcPermissionCatalogClient private constructor(private val channel: Manag .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) .registerPermissionManifest( RegisterPermissionManifestRequest.newBuilder() - .setSource(source) + .setSource(manifest.source) .setSourceVersion(sourceVersion) .setServerType(context.serverType.orEmpty()) .setServerId(context.serverId.orEmpty()) diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCommandPermissions.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCommandPermissions.kt index 14def9b..ec597e3 100644 --- a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCommandPermissions.kt +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCommandPermissions.kt @@ -1,5 +1,8 @@ package gg.grounds.permissions.velocity +import gg.grounds.permissions.catalog.PermissionManifest +import gg.grounds.permissions.catalog.PermissionManifestEntry + class PermissionCommandPermissions private constructor(private val nodes: Map) { fun forArguments(arguments: Array): String = when { diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscovery.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscovery.kt new file mode 100644 index 0000000..d970933 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscovery.kt @@ -0,0 +1,17 @@ +package gg.grounds.permissions.velocity + +import com.velocitypowered.api.plugin.PluginContainer +import gg.grounds.permissions.catalog.ManifestOrigin + +fun discoverPermissionManifestOrigins( + containers: Collection +): List = + containers.mapNotNull { container -> + val instance = container.instance.orElse(null) ?: return@mapNotNull null + val classLoader = instance.javaClass.classLoader ?: return@mapNotNull null + ManifestOrigin( + id = container.description.id, + version = container.description.version.orElse("unknown"), + classLoader = classLoader, + ) + } diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifestRegistrar.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifestRegistrar.kt new file mode 100644 index 0000000..e427b92 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifestRegistrar.kt @@ -0,0 +1,75 @@ +package gg.grounds.permissions.velocity + +import gg.grounds.permissions.catalog.CollectedPermissionManifest + +data class PermissionManifestRegistrationFailure( + val collected: CollectedPermissionManifest, + val attempts: Int, + val reason: String, +) + +data class PermissionManifestRegistrationReport( + val registered: List, + val failures: List, +) + +class PermissionManifestRegistrar( + private val client: PermissionCatalogClient, + private val context: PermissionSnapshotContext, + private val sleep: (Long) -> Unit = { duration -> Thread.sleep(duration) }, +) { + fun register( + manifests: Iterable + ): PermissionManifestRegistrationReport { + val registered = mutableListOf() + val failures = mutableListOf() + + manifests.forEach manifestLoop@{ collected -> + repeat(MAX_ATTEMPTS) { attempt -> + when ( + val result = + client.register( + manifest = collected.manifest, + sourceVersion = collected.origin.version, + context = context, + ) + ) { + PermissionManifestRegistrationResult.Accepted -> { + registered += collected + return@manifestLoop + } + is PermissionManifestRegistrationResult.Unavailable -> { + if (attempt == MAX_ATTEMPTS - 1) { + failures += + PermissionManifestRegistrationFailure( + collected = collected, + attempts = MAX_ATTEMPTS, + reason = result.reason, + ) + return@manifestLoop + } + try { + sleep((attempt + 1) * BACKOFF_MS) + } catch (exception: InterruptedException) { + Thread.currentThread().interrupt() + failures += + PermissionManifestRegistrationFailure( + collected = collected, + attempts = attempt + 1, + reason = "interrupted", + ) + return@manifestLoop + } + } + } + } + } + + return PermissionManifestRegistrationReport(registered = registered, failures = failures) + } + + private companion object { + const val MAX_ATTEMPTS = 5 + const val BACKOFF_MS = 250L + } +} diff --git a/velocity/src/main/resources/META-INF/grounds-permissions.json b/velocity/src/main/resources/META-INF/grounds/permissions.json similarity index 97% rename from velocity/src/main/resources/META-INF/grounds-permissions.json rename to velocity/src/main/resources/META-INF/grounds/permissions.json index 16dea99..c888359 100644 --- a/velocity/src/main/resources/META-INF/grounds-permissions.json +++ b/velocity/src/main/resources/META-INF/grounds/permissions.json @@ -1,4 +1,5 @@ { + "source": "plugin-permissions", "permissions": [ { "key": "grounds.permissions.command", diff --git a/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionCommandServiceTest.kt b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionCommandServiceTest.kt index 76366cd..2c1e489 100644 --- a/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionCommandServiceTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionCommandServiceTest.kt @@ -8,6 +8,7 @@ import gg.grounds.permissions.PermissionScope import gg.grounds.permissions.PermissionSnapshot import gg.grounds.permissions.RoleMetadata import gg.grounds.permissions.SnapshotPermissions +import gg.grounds.permissions.catalog.PermissionManifest import java.time.Clock import java.time.Instant import java.time.ZoneOffset @@ -229,6 +230,7 @@ class PermissionCommandServiceTest { PermissionManifest.load( """ { + "source": "plugin-permissions", "permissions": [ { "key": "grounds.permissions.command", "label": "Permissions commands", "description": "Allows command help.", "supportedScopes": ["GLOBAL"] }, { "key": "grounds.permissions.command.status", "label": "Permissions status", "description": "Allows status.", "supportedScopes": ["GLOBAL"] }, @@ -262,7 +264,11 @@ class PermissionCommandServiceTest { @Test fun `packaged command permissions are complete`() { val permissions = - PermissionCommandPermissions.fromManifest(PermissionManifest.loadResource()) + PermissionCommandPermissions.fromManifest( + PermissionManifest.loadRequiredResource( + GroundsPermissionsPlugin::class.java.classLoader + ) + ) assertEquals("grounds.permissions.command", permissions.forArguments(emptyArray())) assertEquals( diff --git a/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscoveryTest.kt b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscoveryTest.kt new file mode 100644 index 0000000..8a97c41 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscoveryTest.kt @@ -0,0 +1,108 @@ +package gg.grounds.permissions.velocity + +import com.velocitypowered.api.plugin.PluginContainer +import com.velocitypowered.api.plugin.PluginDescription +import gg.grounds.permissions.catalog.PermissionManifest +import gg.grounds.permissions.catalog.PermissionManifestCollector +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.lang.reflect.Proxy +import java.util.Optional +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class PermissionManifestDiscoveryTest { + @Test + fun `collects manifests from active plugin instances and skips plugins without one`() { + val first = manifestInstance("first-plugin") + val second = manifestInstance("second-plugin") + val containers = + listOf( + pluginContainer("first-plugin", "1.0.0", first), + pluginContainer("without-instance", "1.0.0", null), + pluginContainer("second-plugin", "2.0.0", second), + ) + val origins = discoverPermissionManifestOrigins(containers) + val manifests = PermissionManifestCollector().collect(origins).manifests + val client = RecordingCatalogClient() + + PermissionManifestRegistrar( + client = client, + context = PermissionSnapshotContext(serverType = "proxy", serverId = "proxy-1"), + sleep = {}, + ) + .register(manifests) + + assertEquals(listOf("first-plugin", "second-plugin"), manifests.map { it.manifest.source }) + assertEquals(listOf("1.0.0", "2.0.0"), manifests.map { it.origin.version }) + assertEquals(listOf("first-plugin", "second-plugin"), client.registeredSources) + } + + private fun pluginContainer(id: String, version: String, instance: Any?): PluginContainer { + val description = + Proxy.newProxyInstance(javaClass.classLoader, arrayOf(PluginDescription::class.java)) { + _, + method, + _ -> + when (method.name) { + "getId" -> id + "getVersion" -> Optional.of(version) + else -> error("Unexpected plugin description method: ${method.name}") + } + } as PluginDescription + return Proxy.newProxyInstance( + javaClass.classLoader, + arrayOf(PluginContainer::class.java), + ) { _, method, _ -> + when (method.name) { + "getDescription" -> description + "getInstance" -> Optional.ofNullable(instance) + else -> error("Unexpected plugin container method: ${method.name}") + } + } as PluginContainer + } + + private fun manifestInstance(source: String): Any = + Proxy.newProxyInstance(ManifestClassLoader(source), arrayOf(Runnable::class.java)) { + _, + method, + _ -> + when (method.name) { + "run" -> Unit + else -> error("Unexpected plugin instance method: ${method.name}") + } + } + + private class ManifestClassLoader(private val source: String) : ClassLoader() { + override fun getResourceAsStream(name: String): InputStream? = + if (name == PermissionManifest.RESOURCE_PATH) { + ByteArrayInputStream( + """ + { + "source": "${source}", + "permissions": [ + { "key": "grounds.command.${source}", "label": "Use command", "description": "Allows using the command.", "supportedScopes": ["GLOBAL"] } + ] + } + """ + .trimIndent() + .encodeToByteArray() + ) + } else { + null + } + } + + private class RecordingCatalogClient : PermissionCatalogClient { + val registeredSources = mutableListOf() + + override fun register( + manifest: PermissionManifest, + sourceVersion: String, + context: PermissionSnapshotContext, + ): PermissionManifestRegistrationResult { + registeredSources += manifest.source + return PermissionManifestRegistrationResult.Accepted + } + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestRegistrarTest.kt b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestRegistrarTest.kt new file mode 100644 index 0000000..3c85c7c --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestRegistrarTest.kt @@ -0,0 +1,64 @@ +package gg.grounds.permissions.velocity + +import gg.grounds.permissions.catalog.CollectedPermissionManifest +import gg.grounds.permissions.catalog.ManifestOrigin +import gg.grounds.permissions.catalog.PermissionManifest +import gg.grounds.permissions.catalog.PermissionManifestEntry +import gg.grounds.permissions.catalog.PermissionManifestScope +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PermissionManifestRegistrarTest { + @Test + fun `retries unavailable registration five times before reporting the final failure`() { + val client = UnavailableCatalogClient("catalog unavailable") + val sleeps = mutableListOf() + val collected = + CollectedPermissionManifest( + origin = ManifestOrigin("test-plugin", "1.0.0", javaClass.classLoader), + manifest = + PermissionManifest( + source = "test-plugin", + permissions = + listOf( + PermissionManifestEntry( + key = "grounds.command.test", + label = "Use test command", + description = "Allows using the test command.", + supportedScopes = listOf(PermissionManifestScope.GLOBAL), + ) + ), + ), + ) + + val report = + PermissionManifestRegistrar( + client = client, + context = PermissionSnapshotContext(serverType = "proxy", serverId = "proxy-1"), + sleep = sleeps::add, + ) + .register(listOf(collected)) + + assertTrue(report.registered.isEmpty()) + assertEquals(5, client.attempts) + assertEquals(listOf(250L, 500L, 750L, 1_000L), sleeps) + assertEquals(1, report.failures.size) + assertEquals(collected, report.failures.single().collected) + assertEquals(5, report.failures.single().attempts) + assertEquals("catalog unavailable", report.failures.single().reason) + } + + private class UnavailableCatalogClient(private val reason: String) : PermissionCatalogClient { + var attempts = 0 + + override fun register( + manifest: PermissionManifest, + sourceVersion: String, + context: PermissionSnapshotContext, + ): PermissionManifestRegistrationResult { + attempts += 1 + return PermissionManifestRegistrationResult.Unavailable(reason) + } + } +}