From 66ad87d2d284c11ac58b2144bd450198ba94c398 Mon Sep 17 00:00:00 2001 From: zichun Date: Thu, 9 Apr 2026 12:50:36 +0800 Subject: [PATCH] feat(workflow): plug-in JAR BPMN auto-deployment via PluginProcessDeployer --- platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt | 38 +++++++++++++++++++++++++++++++++++++- platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/PluginProcessDeployer.kt | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/PluginProcessDeployerTest.kt | 155 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ reference-customer/plugin-printing-shop/src/main/resources/processes/plate-approval.bpmn20.xml | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/PluginProcessDeployer.kt create mode 100644 platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/PluginProcessDeployerTest.kt create mode 100644 reference-customer/plugin-printing-shop/src/main/resources/processes/plate-approval.bpmn20.xml diff --git a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt index 35f8f37..0a448a8 100644 --- a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt +++ b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt @@ -19,6 +19,7 @@ import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar import org.vibeerp.platform.plugins.lint.PluginLinter import org.vibeerp.platform.plugins.migration.PluginLiquibaseRunner import org.vibeerp.platform.plugins.workflow.ScopedTaskHandlerRegistrar +import org.vibeerp.platform.workflow.PluginProcessDeployer import org.vibeerp.platform.workflow.TaskHandlerRegistry import java.nio.file.Files import java.nio.file.Path @@ -68,6 +69,7 @@ class VibeErpPluginManager( private val permissionEvaluator: PermissionEvaluator, private val localeProvider: LocaleProvider, private val taskHandlerRegistry: TaskHandlerRegistry, + private val pluginProcessDeployer: PluginProcessDeployer, ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean { private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java) @@ -289,9 +291,42 @@ class VibeErpPluginManager( // Strip any partial registrations the plug-in may have // managed before throwing — same treatment for endpoints // and TaskHandlers so a failed start does not leave either - // registry in a half-populated state. + // registry in a half-populated state. No BPMN deployments + // were made yet (we deploy AFTER start succeeds), so + // undeployment is unnecessary here. endpointRegistry.unregisterAll(pluginId) taskHandlerRegistry.unregisterAllByOwner(pluginId) + return + } + + // Only after start(context) has successfully registered the + // plug-in's TaskHandlers do we deploy the BPMNs that reference + // them. A deployed BPMN whose delegate key has no matching + // handler would not fail deployment (Flowable only resolves + // delegate expressions at process-start time) but any process + // instance started before the handler landed would throw. On + // failure here we drop the plug-in's handlers + endpoints and + // mark the plug-in inactive, matching the start-failure path + // above. + try { + pluginProcessDeployer.deployFromPlugin(pluginId, wrapper.pluginPath) + } catch (ex: Throwable) { + log.error( + "vibe_erp plug-in '{}' BPMN deployment failed after successful start; unwinding plug-in state", + pluginId, ex, + ) + try { + vibeErpPlugin.stop() + } catch (stopEx: Throwable) { + log.warn( + "vibe_erp plug-in '{}' threw during stop after BPMN deploy failure; continuing teardown", + pluginId, stopEx, + ) + } + started.removeAll { it.first == pluginId } + endpointRegistry.unregisterAll(pluginId) + taskHandlerRegistry.unregisterAllByOwner(pluginId) + pluginProcessDeployer.undeployByPlugin(pluginId) } } @@ -306,6 +341,7 @@ class VibeErpPluginManager( } endpointRegistry.unregisterAll(pluginId) taskHandlerRegistry.unregisterAllByOwner(pluginId) + pluginProcessDeployer.undeployByPlugin(pluginId) } started.clear() stopPlugins() diff --git a/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/PluginProcessDeployer.kt b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/PluginProcessDeployer.kt new file mode 100644 index 0000000..4ca004a --- /dev/null +++ b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/PluginProcessDeployer.kt @@ -0,0 +1,160 @@ +package org.vibeerp.platform.workflow + +import org.flowable.engine.RepositoryService +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import java.nio.file.Files +import java.nio.file.Path +import java.util.jar.JarFile + +/** + * Reads BPMN 2.0 resources out of a plug-in JAR and deploys them to + * the Flowable process engine under a `category` tag equal to the + * plug-in id, so that [undeployByPlugin] can drop every deployment + * contributed by a plug-in in a single query at stop time. + * + * Why a dedicated deployer instead of relying on Flowable's built-in + * classpath auto-deployer: + * - Flowable's Spring Boot starter auto-deploys everything matching + * the `classpath[star]:[slash]processes[slash][star].bpmn20.xml` + * glob from the HOST classpath. PF4J plug-ins load through an + * isolated child classloader that is NOT on the system classpath, + * so plug-in BPMNs are invisible to that scan. Manually reading + * each plug-in JAR is the only way to deploy process definitions + * a plug-in ships. (The literal glob cannot appear in this KDoc + * because `[slash][star]` inside a Kotlin doc comment would start + * a nested comment block.) + * - Parent-first class loading inside `PluginClassLoader` means a + * naive `getResources("processes")` lookup against the plug-in + * classloader would ALSO see the host's core BPMN files and + * re-deploy them under the plug-in's category. Walking the JAR + * file entries directly via [JarFile] guarantees we only touch + * the plug-in's own resources — same rationale as + * [org.vibeerp.platform.metadata.MetadataLoader.loadFromPluginJar]. + * + * Resource discovery rules: + * - A resource is deployed if its name starts with `processes/` and + * ends with `.bpmn20.xml` or `.bpmn`. + * - All matching resources from one plug-in are bundled into ONE + * Flowable `Deployment` with category = pluginId, named + * `plugin:`. One deployment per plug-in keeps undeploy + * atomic and makes the `deploymentCategory(pluginId)` query + * below unambiguous. + * + * Lifecycle: + * - [deployFromPlugin] is called by [org.vibeerp.platform.plugins.VibeErpPluginManager] + * AFTER the plug-in's `start(context)` lambda has registered its + * TaskHandlers. This ordering matters: a deployed BPMN that + * references a service-task key whose handler is not yet + * registered would still deploy (Flowable only checks delegate + * expressions at process start time), but any process instance + * started before the handler was available would fail. Registering + * first is the safer default. + * - [undeployByPlugin] is called during plug-in stop (orderly + * shutdown or partial-start failure). Cascading delete removes + * process instances along with the deployment; that matches the + * framework's "stopping a plug-in makes it disappear" semantics. + */ +@Component +class PluginProcessDeployer( + private val repositoryService: RepositoryService, +) { + private val log = LoggerFactory.getLogger(PluginProcessDeployer::class.java) + + /** + * Walk the plug-in JAR for BPMN resources under `processes/` and + * deploy them under a category = pluginId. Returns the Flowable + * deployment id if anything was deployed, or null if the plug-in + * ships no BPMN resources (or is loaded as an unpacked directory + * we cannot read as a JAR). The single returned id covers every + * process definition the plug-in contributes, per the one- + * deployment-per-plug-in convention. + */ + fun deployFromPlugin(pluginId: String, jarPath: Path): String? { + require(pluginId.isNotBlank()) { "pluginId must not be blank" } + + if (!Files.isRegularFile(jarPath)) { + log.debug( + "PluginProcessDeployer: plug-in '{}' jarPath {} is not a regular file; skipping " + + "(probably loaded as unpacked dir — see MetadataLoader for the same caveat)", + pluginId, jarPath, + ) + return null + } + + data class BpmnResource(val name: String, val bytes: ByteArray) + + val resources = mutableListOf() + JarFile(jarPath.toFile()).use { jar -> + val entries = jar.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (entry.isDirectory) continue + val name = entry.name + if (!name.startsWith(PROCESSES_DIR)) continue + if (!name.endsWith(BPMN20_SUFFIX) && !name.endsWith(BPMN_SUFFIX)) continue + val bytes = jar.getInputStream(entry).use { it.readBytes() } + resources += BpmnResource(name, bytes) + } + } + + if (resources.isEmpty()) { + log.debug("PluginProcessDeployer: plug-in '{}' ships no BPMN resources under {}", pluginId, PROCESSES_DIR) + return null + } + + // Build one deployment per plug-in containing every BPMN + // resource. Category and name both carry the plug-in id so + // queries against either field find it. + val builder = repositoryService.createDeployment() + .name("plugin:$pluginId") + .category(pluginId) + for (resource in resources) { + // addInputStream closes nothing itself; we already copied + // the bytes out of the JarFile above so the JAR handle is + // already closed at this point. + builder.addBytes(resource.name, resource.bytes) + } + val deployment = builder.deploy() + + log.info( + "PluginProcessDeployer: plug-in '{}' deployed {} BPMN resource(s) as Flowable deploymentId='{}': {}", + pluginId, resources.size, deployment.id, resources.map { it.name }, + ) + return deployment.id + } + + /** + * Remove every Flowable deployment whose category equals the + * given plug-in id, cascading to running/historical instances. + * Returns the count of deployments removed. Idempotent: a second + * call after the plug-in is already undeployed returns 0. + */ + fun undeployByPlugin(pluginId: String): Int { + val deployments = repositoryService.createDeploymentQuery() + .deploymentCategory(pluginId) + .list() + if (deployments.isEmpty()) return 0 + for (deployment in deployments) { + try { + repositoryService.deleteDeployment(deployment.id, /* cascade = */ true) + log.info( + "PluginProcessDeployer: plug-in '{}' deployment '{}' removed (cascade)", + pluginId, deployment.id, + ) + } catch (ex: Throwable) { + log.warn( + "PluginProcessDeployer: plug-in '{}' deployment '{}' failed to delete: {}", + pluginId, deployment.id, ex.message, ex, + ) + } + } + return deployments.size + } + + companion object { + private const val PROCESSES_DIR: String = "processes/" + private const val BPMN20_SUFFIX: String = ".bpmn20.xml" + private const val BPMN_SUFFIX: String = ".bpmn" + } +} diff --git a/platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/PluginProcessDeployerTest.kt b/platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/PluginProcessDeployerTest.kt new file mode 100644 index 0000000..33a554a --- /dev/null +++ b/platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/PluginProcessDeployerTest.kt @@ -0,0 +1,155 @@ +package org.vibeerp.platform.workflow + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNull +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.flowable.engine.RepositoryService +import org.flowable.engine.repository.Deployment +import org.flowable.engine.repository.DeploymentBuilder +import org.flowable.engine.repository.DeploymentQuery +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import java.util.jar.Attributes +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream +import java.util.jar.Manifest + +class PluginProcessDeployerTest { + + @Test + fun `deployFromPlugin returns null when jarPath is not a regular file`(@TempDir tmp: Path) { + val missing = tmp.resolve("no-such.jar") + val repo = mockk() // must not be called + val deployer = PluginProcessDeployer(repo) + + assertThat(deployer.deployFromPlugin("some-plugin", missing)).isNull() + verify(exactly = 0) { repo.createDeployment() } + } + + @Test + fun `deployFromPlugin returns null when the plug-in jar has no BPMN resources`(@TempDir tmp: Path) { + val jar = tmp.resolve("empty.jar") + writeJar(jar, mapOf("README.txt" to "hello".toByteArray())) + val repo = mockk() // must not be called + val deployer = PluginProcessDeployer(repo) + + assertThat(deployer.deployFromPlugin("plugin-a", jar)).isNull() + verify(exactly = 0) { repo.createDeployment() } + } + + @Test + fun `deployFromPlugin reads every bpmn resource under processes and deploys one bundle`(@TempDir tmp: Path) { + val jar = tmp.resolve("printing-shop.jar") + val bpmnA = bpmnBytes("plate-approval") + val bpmnB = bpmnBytes("ink-mix") + writeJar( + jar, + mapOf( + "processes/plate-approval.bpmn20.xml" to bpmnA, + "processes/ink-mix.bpmn" to bpmnB, + "processes/README.md" to "ignored".toByteArray(), // not BPMN + "META-INF/vibe-erp/metadata/printing-shop.yml" to "#...".toByteArray(), // not in processes + ), + ) + + val builder = mockk() + every { builder.name(any()) } returns builder + every { builder.category(any()) } returns builder + every { builder.addBytes(any(), any()) } returns builder + val deployment = mockk() + every { deployment.id } returns "dep-42" + every { builder.deploy() } returns deployment + + val repo = mockk() + every { repo.createDeployment() } returns builder + + val deployer = PluginProcessDeployer(repo) + val result = deployer.deployFromPlugin("printing-shop", jar) + + assertThat(result).isEqualTo("dep-42") + verify(exactly = 1) { builder.name("plugin:printing-shop") } + verify(exactly = 1) { builder.category("printing-shop") } + verify(exactly = 1) { builder.addBytes("processes/plate-approval.bpmn20.xml", bpmnA) } + verify(exactly = 1) { builder.addBytes("processes/ink-mix.bpmn", bpmnB) } + verify(exactly = 1) { builder.deploy() } + } + + @Test + fun `deployFromPlugin rejects a blank plug-in id`(@TempDir tmp: Path) { + val jar = tmp.resolve("x.jar") + writeJar(jar, emptyMap()) + val deployer = PluginProcessDeployer(mockk()) + + assertFailure { deployer.deployFromPlugin(" ", jar) } + .isInstanceOf(IllegalArgumentException::class) + } + + @Test + fun `undeployByPlugin returns zero when there is nothing to remove`() { + val query = mockk() + every { query.deploymentCategory(any()) } returns query + every { query.list() } returns emptyList() + + val repo = mockk() + every { repo.createDeploymentQuery() } returns query + + val deployer = PluginProcessDeployer(repo) + assertThat(deployer.undeployByPlugin("nobody")).isEqualTo(0) + verify(exactly = 0) { repo.deleteDeployment(any(), any()) } + } + + @Test + fun `undeployByPlugin cascades a deleteDeployment per matching deployment`() { + val query = mockk() + every { query.deploymentCategory("printing-shop") } returns query + val d1 = mockk().also { every { it.id } returns "dep-1" } + val d2 = mockk().also { every { it.id } returns "dep-2" } + every { query.list() } returns listOf(d1, d2) + + val repo = mockk() + every { repo.createDeploymentQuery() } returns query + every { repo.deleteDeployment(any(), any()) } returns Unit + + val deployer = PluginProcessDeployer(repo) + assertThat(deployer.undeployByPlugin("printing-shop")).isEqualTo(2) + + verify(exactly = 1) { repo.deleteDeployment("dep-1", true) } + verify(exactly = 1) { repo.deleteDeployment("dep-2", true) } + } + + // ─── helpers ─────────────────────────────────────────────────── + + private fun writeJar(jar: Path, entries: Map) { + val manifest = Manifest().apply { + mainAttributes[Attributes.Name.MANIFEST_VERSION] = "1.0" + } + Files.newOutputStream(jar).use { outFile -> + JarOutputStream(outFile, manifest).use { jarOut -> + for ((name, bytes) in entries) { + jarOut.putNextEntry(JarEntry(name)) + jarOut.write(bytes) + jarOut.closeEntry() + } + } + } + } + + private fun bpmnBytes(processId: String): ByteArray = + """ + + + + + + + + """.trimIndent().toByteArray() +} diff --git a/reference-customer/plugin-printing-shop/src/main/resources/processes/plate-approval.bpmn20.xml b/reference-customer/plugin-printing-shop/src/main/resources/processes/plate-approval.bpmn20.xml new file mode 100644 index 0000000..1a7fcaf --- /dev/null +++ b/reference-customer/plugin-printing-shop/src/main/resources/processes/plate-approval.bpmn20.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + -- libgit2 0.22.2