Commit 66ad87d2d284c11ac58b2144bd450198ba94c398
1 parent
4c16574d
feat(workflow): plug-in JAR BPMN auto-deployment via PluginProcessDeployer
Completes the plug-in side of the embedded Flowable story. The P2.1
core made plug-ins able to register TaskHandlers; this chunk makes
them able to ship the BPMN processes those handlers serve.
## Why Flowable's built-in auto-deployer couldn't do it
Flowable's Spring Boot starter scans the host classpath at engine
startup for `classpath[*]:/processes/[*].bpmn20.xml` and auto-deploys
every hit (the literal glob is paraphrased because the Kotlin KDoc
comment below would otherwise treat the embedded slash-star as the
start of a nested comment — feedback memory "Kotlin KDoc nested-
comment trap"). PF4J plug-ins load through an isolated child
classloader that is NOT visible to that scan, so a `processes/*.bpmn20.xml`
resource shipped inside a plug-in JAR is never seen. This chunk adds
a dedicated host-side deployer that opens each plug-in JAR file
directly (same JarFile walk pattern as
`MetadataLoader.loadFromPluginJar`) and hand-registers the BPMNs
with the Flowable `RepositoryService`.
## Mechanism
### New PluginProcessDeployer (platform-workflow)
One Spring bean, two methods:
- `deployFromPlugin(pluginId, jarPath): String?` — walks the JAR,
collects every entry whose name starts with `processes/` and ends
with `.bpmn20.xml` or `.bpmn`, and bundles the whole set into one
Flowable `Deployment` named `plugin:<id>` with `category = pluginId`.
Returns the deployment id or null (missing JAR / no BPMN resources).
One deployment per plug-in keeps undeploy atomic and makes the
teardown query unambiguous.
- `undeployByPlugin(pluginId): Int` — runs
`createDeploymentQuery().deploymentCategory(pluginId).list()` and
calls `deleteDeployment(id, cascade=true)` on each hit. Cascading
removes process instances and history rows along with the
deployment — "uninstalling a plug-in makes it disappear". Idempotent:
a second call returns 0.
The deployer reads the JAR entries into byte arrays inside the
JarFile's `use` block and then passes the bytes to
`DeploymentBuilder.addBytes(name, bytes)` outside the block, so the
jar handle is already closed by the time Flowable sees the
deployment. No input-stream lifetime tangles.
### VibeErpPluginManager wiring
- New constructor dependency on `PluginProcessDeployer`.
- Deploy happens AFTER `start(context)` succeeds. The ordering matters
because a plug-in can only register its TaskHandlers during
`start(context)`, and a deployed BPMN whose service-task delegate
expression resolves to a key with no matching handler would still
deploy (Flowable only resolves delegates at process-start time).
Registering handlers first is the safer default: the moment the
deployment lands, every referenced handler is already in the
TaskHandlerRegistry.
- BPMN deployment failure AFTER a successful `start(context)` now
fully unwinds the plug-in state: call `instance.stop()`, remove
the plug-in from the `started` list, strip its endpoints + its
TaskHandlers + call `undeployByPlugin` (belt and suspenders — the
deploy attempt may have partially succeeded). That mirrors the
existing start-failure unwinding so the framework doesn't end up
with a plug-in that's half-installed after any step throws.
- `destroy()` calls `undeployByPlugin(pluginId)` alongside the
existing `unregisterAllByOwner(pluginId)`.
### Reference plug-in BPMN
`reference-customer/plugin-printing-shop/src/main/resources/processes/plate-approval.bpmn20.xml`
— a minimal two-task process (`start` → serviceTask → `end`) whose
serviceTask id is `printing_shop.plate.approve`, matching the
PlateApprovalTaskHandler key landed in the previous commit. Process
definition key is `plugin-printing-shop-plate-approval` (distinct
from the serviceTask id because BPMN 2.0 requires element ids to be
unique per document — same separation used for the core ping
process).
## Smoke test (fresh DB, plug-in staged)
```
$ docker compose down -v && docker compose up -d db
$ ./gradlew :distribution:bootRun &
...
registered TaskHandler 'vibeerp.workflow.ping' owner='core' ...
TaskHandlerRegistry initialised with 1 core TaskHandler bean(s): [vibeerp.workflow.ping]
...
plug-in 'printing-shop' Liquibase migrations applied successfully
[plugin:printing-shop] printing-shop plug-in started — reference acceptance test active
registered TaskHandler 'printing_shop.plate.approve' owner='printing-shop' ...
[plugin:printing-shop] registered 1 TaskHandler: printing_shop.plate.approve
PluginProcessDeployer: plug-in 'printing-shop' deployed 1 BPMN resource(s) as Flowable deploymentId='4e9f...': [processes/plate-approval.bpmn20.xml]
$ curl /api/v1/workflow/definitions (as admin)
[
{"key":"plugin-printing-shop-plate-approval",
"name":"Printing shop — plate approval",
"version":1,
"deploymentId":"4e9f85a6-33cf-11f1-acaa-1afab74ef3b4",
"resourceName":"processes/plate-approval.bpmn20.xml"},
{"key":"vibeerp-workflow-ping",
"name":"vibe_erp workflow ping",
"version":1,
"deploymentId":"4f48...",
"resourceName":"vibeerp-ping.bpmn20.xml"}
]
$ curl -X POST /api/v1/workflow/process-instances
{"processDefinitionKey":"plugin-printing-shop-plate-approval",
"variables":{"plateId":"PLATE-007"}}
→ {"processInstanceId":"5b1b...",
"ended":true,
"variables":{"plateId":"PLATE-007",
"plateApproved":true,
"approvedBy":"user:admin",
"approvedAt":"2026-04-09T04:48:30.514523Z"}}
$ kill -TERM <pid>
[ionShutdownHook] TaskHandlerRegistry.unregisterAllByOwner('printing-shop') removed 1 handler(s)
[ionShutdownHook] PluginProcessDeployer: plug-in 'printing-shop' deployment '4e9f...' removed (cascade)
```
Full end-to-end loop closed: plug-in ships a BPMN → host reads it
out of the JAR → Flowable deployment registered under the plug-in
category → HTTP caller starts a process instance via the standard
`/api/v1/workflow/process-instances` surface → dispatcher routes by
activity id to the plug-in's TaskHandler → handler writes output
variables + plug-in sees the authenticated caller as `ctx.principal()`
via the reserved `__vibeerp_*` process-variable propagation from
commit `ef9e5b42`. SIGTERM cleanly undeploys the plug-in's BPMNs.
## Tests
- 6 new unit tests on `PluginProcessDeployerTest`:
* `deployFromPlugin returns null when jarPath is not a regular file`
— guard against dev-exploded plug-in dirs
* `deployFromPlugin returns null when the plug-in jar has no BPMN resources`
* `deployFromPlugin reads every bpmn resource under processes and
deploys one bundle` — builds a real temporary JAR with two BPMN
entries + a README + a metadata YAML, verifies that both BPMNs
go through `addBytes` with the right names and the README /
metadata entries are skipped
* `deployFromPlugin rejects a blank plug-in id`
* `undeployByPlugin returns zero when there is nothing to remove`
* `undeployByPlugin cascades a deleteDeployment per matching deployment`
- Total framework unit tests: 275 (was 269), all green.
## Kotlin trap caught during authoring (feedback memory paid out)
First compile failed with `Unclosed comment` on the last line of
`PluginProcessDeployer.kt`. The culprit was a KDoc paragraph
containing the literal glob
`classpath*:/processes/*.bpmn20.xml`: the embedded `/*` inside the
backtick span was parsed as the start of a nested block comment
even though the surrounding `/* ... */` KDoc was syntactically
complete. The saved feedback-memory entry "Kotlin KDoc nested-comment
trap" covered exactly this situation — the fix is to spell out glob
characters as `[star]` / `[slash]` (or the word "slash-star") inside
documentation so the literal `/*` never appears. The KDoc now
documents the behaviour AND the workaround so the next maintainer
doesn't hit the same trap.
## Non-goals (still parking lot)
- Handler-side access to the full PluginContext — PlateApprovalTaskHandler
is still a pure function because the framework doesn't hand
TaskHandlers a context object. For REF.1 (real quote→job-card)
handlers will need to read + mutate plug-in-owned tables; the
cleanest approach is closure-capture inside the plug-in class
(handler instantiated inside `start(context)` with the context
captured in the outer scope). Decision deferred to REF.1.
- BPMN resource hot reload. The deployer runs once per plug-in
start; a plug-in whose BPMN changes under its feet at runtime
isn't supported yet.
- Plug-in-shipped DMN / CMMN resources. The deployer only looks at
`.bpmn20.xml` and `.bpmn`. Decision-table and case-management
resources are not on the v1.0 critical path.
Showing
4 changed files
with
399 additions
and
1 deletions
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt
| ... | ... | @@ -19,6 +19,7 @@ import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar |
| 19 | 19 | import org.vibeerp.platform.plugins.lint.PluginLinter |
| 20 | 20 | import org.vibeerp.platform.plugins.migration.PluginLiquibaseRunner |
| 21 | 21 | import org.vibeerp.platform.plugins.workflow.ScopedTaskHandlerRegistrar |
| 22 | +import org.vibeerp.platform.workflow.PluginProcessDeployer | |
| 22 | 23 | import org.vibeerp.platform.workflow.TaskHandlerRegistry |
| 23 | 24 | import java.nio.file.Files |
| 24 | 25 | import java.nio.file.Path |
| ... | ... | @@ -68,6 +69,7 @@ class VibeErpPluginManager( |
| 68 | 69 | private val permissionEvaluator: PermissionEvaluator, |
| 69 | 70 | private val localeProvider: LocaleProvider, |
| 70 | 71 | private val taskHandlerRegistry: TaskHandlerRegistry, |
| 72 | + private val pluginProcessDeployer: PluginProcessDeployer, | |
| 71 | 73 | ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean { |
| 72 | 74 | |
| 73 | 75 | private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java) |
| ... | ... | @@ -289,9 +291,42 @@ class VibeErpPluginManager( |
| 289 | 291 | // Strip any partial registrations the plug-in may have |
| 290 | 292 | // managed before throwing — same treatment for endpoints |
| 291 | 293 | // and TaskHandlers so a failed start does not leave either |
| 292 | - // registry in a half-populated state. | |
| 294 | + // registry in a half-populated state. No BPMN deployments | |
| 295 | + // were made yet (we deploy AFTER start succeeds), so | |
| 296 | + // undeployment is unnecessary here. | |
| 293 | 297 | endpointRegistry.unregisterAll(pluginId) |
| 294 | 298 | taskHandlerRegistry.unregisterAllByOwner(pluginId) |
| 299 | + return | |
| 300 | + } | |
| 301 | + | |
| 302 | + // Only after start(context) has successfully registered the | |
| 303 | + // plug-in's TaskHandlers do we deploy the BPMNs that reference | |
| 304 | + // them. A deployed BPMN whose delegate key has no matching | |
| 305 | + // handler would not fail deployment (Flowable only resolves | |
| 306 | + // delegate expressions at process-start time) but any process | |
| 307 | + // instance started before the handler landed would throw. On | |
| 308 | + // failure here we drop the plug-in's handlers + endpoints and | |
| 309 | + // mark the plug-in inactive, matching the start-failure path | |
| 310 | + // above. | |
| 311 | + try { | |
| 312 | + pluginProcessDeployer.deployFromPlugin(pluginId, wrapper.pluginPath) | |
| 313 | + } catch (ex: Throwable) { | |
| 314 | + log.error( | |
| 315 | + "vibe_erp plug-in '{}' BPMN deployment failed after successful start; unwinding plug-in state", | |
| 316 | + pluginId, ex, | |
| 317 | + ) | |
| 318 | + try { | |
| 319 | + vibeErpPlugin.stop() | |
| 320 | + } catch (stopEx: Throwable) { | |
| 321 | + log.warn( | |
| 322 | + "vibe_erp plug-in '{}' threw during stop after BPMN deploy failure; continuing teardown", | |
| 323 | + pluginId, stopEx, | |
| 324 | + ) | |
| 325 | + } | |
| 326 | + started.removeAll { it.first == pluginId } | |
| 327 | + endpointRegistry.unregisterAll(pluginId) | |
| 328 | + taskHandlerRegistry.unregisterAllByOwner(pluginId) | |
| 329 | + pluginProcessDeployer.undeployByPlugin(pluginId) | |
| 295 | 330 | } |
| 296 | 331 | } |
| 297 | 332 | |
| ... | ... | @@ -306,6 +341,7 @@ class VibeErpPluginManager( |
| 306 | 341 | } |
| 307 | 342 | endpointRegistry.unregisterAll(pluginId) |
| 308 | 343 | taskHandlerRegistry.unregisterAllByOwner(pluginId) |
| 344 | + pluginProcessDeployer.undeployByPlugin(pluginId) | |
| 309 | 345 | } |
| 310 | 346 | started.clear() |
| 311 | 347 | stopPlugins() | ... | ... |
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/PluginProcessDeployer.kt
0 → 100644
| 1 | +package org.vibeerp.platform.workflow | |
| 2 | + | |
| 3 | +import org.flowable.engine.RepositoryService | |
| 4 | +import org.slf4j.LoggerFactory | |
| 5 | +import org.springframework.stereotype.Component | |
| 6 | +import java.nio.file.Files | |
| 7 | +import java.nio.file.Path | |
| 8 | +import java.util.jar.JarFile | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * Reads BPMN 2.0 resources out of a plug-in JAR and deploys them to | |
| 12 | + * the Flowable process engine under a `category` tag equal to the | |
| 13 | + * plug-in id, so that [undeployByPlugin] can drop every deployment | |
| 14 | + * contributed by a plug-in in a single query at stop time. | |
| 15 | + * | |
| 16 | + * Why a dedicated deployer instead of relying on Flowable's built-in | |
| 17 | + * classpath auto-deployer: | |
| 18 | + * - Flowable's Spring Boot starter auto-deploys everything matching | |
| 19 | + * the `classpath[star]:[slash]processes[slash][star].bpmn20.xml` | |
| 20 | + * glob from the HOST classpath. PF4J plug-ins load through an | |
| 21 | + * isolated child classloader that is NOT on the system classpath, | |
| 22 | + * so plug-in BPMNs are invisible to that scan. Manually reading | |
| 23 | + * each plug-in JAR is the only way to deploy process definitions | |
| 24 | + * a plug-in ships. (The literal glob cannot appear in this KDoc | |
| 25 | + * because `[slash][star]` inside a Kotlin doc comment would start | |
| 26 | + * a nested comment block.) | |
| 27 | + * - Parent-first class loading inside `PluginClassLoader` means a | |
| 28 | + * naive `getResources("processes")` lookup against the plug-in | |
| 29 | + * classloader would ALSO see the host's core BPMN files and | |
| 30 | + * re-deploy them under the plug-in's category. Walking the JAR | |
| 31 | + * file entries directly via [JarFile] guarantees we only touch | |
| 32 | + * the plug-in's own resources — same rationale as | |
| 33 | + * [org.vibeerp.platform.metadata.MetadataLoader.loadFromPluginJar]. | |
| 34 | + * | |
| 35 | + * Resource discovery rules: | |
| 36 | + * - A resource is deployed if its name starts with `processes/` and | |
| 37 | + * ends with `.bpmn20.xml` or `.bpmn`. | |
| 38 | + * - All matching resources from one plug-in are bundled into ONE | |
| 39 | + * Flowable `Deployment` with category = pluginId, named | |
| 40 | + * `plugin:<id>`. One deployment per plug-in keeps undeploy | |
| 41 | + * atomic and makes the `deploymentCategory(pluginId)` query | |
| 42 | + * below unambiguous. | |
| 43 | + * | |
| 44 | + * Lifecycle: | |
| 45 | + * - [deployFromPlugin] is called by [org.vibeerp.platform.plugins.VibeErpPluginManager] | |
| 46 | + * AFTER the plug-in's `start(context)` lambda has registered its | |
| 47 | + * TaskHandlers. This ordering matters: a deployed BPMN that | |
| 48 | + * references a service-task key whose handler is not yet | |
| 49 | + * registered would still deploy (Flowable only checks delegate | |
| 50 | + * expressions at process start time), but any process instance | |
| 51 | + * started before the handler was available would fail. Registering | |
| 52 | + * first is the safer default. | |
| 53 | + * - [undeployByPlugin] is called during plug-in stop (orderly | |
| 54 | + * shutdown or partial-start failure). Cascading delete removes | |
| 55 | + * process instances along with the deployment; that matches the | |
| 56 | + * framework's "stopping a plug-in makes it disappear" semantics. | |
| 57 | + */ | |
| 58 | +@Component | |
| 59 | +class PluginProcessDeployer( | |
| 60 | + private val repositoryService: RepositoryService, | |
| 61 | +) { | |
| 62 | + private val log = LoggerFactory.getLogger(PluginProcessDeployer::class.java) | |
| 63 | + | |
| 64 | + /** | |
| 65 | + * Walk the plug-in JAR for BPMN resources under `processes/` and | |
| 66 | + * deploy them under a category = pluginId. Returns the Flowable | |
| 67 | + * deployment id if anything was deployed, or null if the plug-in | |
| 68 | + * ships no BPMN resources (or is loaded as an unpacked directory | |
| 69 | + * we cannot read as a JAR). The single returned id covers every | |
| 70 | + * process definition the plug-in contributes, per the one- | |
| 71 | + * deployment-per-plug-in convention. | |
| 72 | + */ | |
| 73 | + fun deployFromPlugin(pluginId: String, jarPath: Path): String? { | |
| 74 | + require(pluginId.isNotBlank()) { "pluginId must not be blank" } | |
| 75 | + | |
| 76 | + if (!Files.isRegularFile(jarPath)) { | |
| 77 | + log.debug( | |
| 78 | + "PluginProcessDeployer: plug-in '{}' jarPath {} is not a regular file; skipping " + | |
| 79 | + "(probably loaded as unpacked dir — see MetadataLoader for the same caveat)", | |
| 80 | + pluginId, jarPath, | |
| 81 | + ) | |
| 82 | + return null | |
| 83 | + } | |
| 84 | + | |
| 85 | + data class BpmnResource(val name: String, val bytes: ByteArray) | |
| 86 | + | |
| 87 | + val resources = mutableListOf<BpmnResource>() | |
| 88 | + JarFile(jarPath.toFile()).use { jar -> | |
| 89 | + val entries = jar.entries() | |
| 90 | + while (entries.hasMoreElements()) { | |
| 91 | + val entry = entries.nextElement() | |
| 92 | + if (entry.isDirectory) continue | |
| 93 | + val name = entry.name | |
| 94 | + if (!name.startsWith(PROCESSES_DIR)) continue | |
| 95 | + if (!name.endsWith(BPMN20_SUFFIX) && !name.endsWith(BPMN_SUFFIX)) continue | |
| 96 | + val bytes = jar.getInputStream(entry).use { it.readBytes() } | |
| 97 | + resources += BpmnResource(name, bytes) | |
| 98 | + } | |
| 99 | + } | |
| 100 | + | |
| 101 | + if (resources.isEmpty()) { | |
| 102 | + log.debug("PluginProcessDeployer: plug-in '{}' ships no BPMN resources under {}", pluginId, PROCESSES_DIR) | |
| 103 | + return null | |
| 104 | + } | |
| 105 | + | |
| 106 | + // Build one deployment per plug-in containing every BPMN | |
| 107 | + // resource. Category and name both carry the plug-in id so | |
| 108 | + // queries against either field find it. | |
| 109 | + val builder = repositoryService.createDeployment() | |
| 110 | + .name("plugin:$pluginId") | |
| 111 | + .category(pluginId) | |
| 112 | + for (resource in resources) { | |
| 113 | + // addInputStream closes nothing itself; we already copied | |
| 114 | + // the bytes out of the JarFile above so the JAR handle is | |
| 115 | + // already closed at this point. | |
| 116 | + builder.addBytes(resource.name, resource.bytes) | |
| 117 | + } | |
| 118 | + val deployment = builder.deploy() | |
| 119 | + | |
| 120 | + log.info( | |
| 121 | + "PluginProcessDeployer: plug-in '{}' deployed {} BPMN resource(s) as Flowable deploymentId='{}': {}", | |
| 122 | + pluginId, resources.size, deployment.id, resources.map { it.name }, | |
| 123 | + ) | |
| 124 | + return deployment.id | |
| 125 | + } | |
| 126 | + | |
| 127 | + /** | |
| 128 | + * Remove every Flowable deployment whose category equals the | |
| 129 | + * given plug-in id, cascading to running/historical instances. | |
| 130 | + * Returns the count of deployments removed. Idempotent: a second | |
| 131 | + * call after the plug-in is already undeployed returns 0. | |
| 132 | + */ | |
| 133 | + fun undeployByPlugin(pluginId: String): Int { | |
| 134 | + val deployments = repositoryService.createDeploymentQuery() | |
| 135 | + .deploymentCategory(pluginId) | |
| 136 | + .list() | |
| 137 | + if (deployments.isEmpty()) return 0 | |
| 138 | + for (deployment in deployments) { | |
| 139 | + try { | |
| 140 | + repositoryService.deleteDeployment(deployment.id, /* cascade = */ true) | |
| 141 | + log.info( | |
| 142 | + "PluginProcessDeployer: plug-in '{}' deployment '{}' removed (cascade)", | |
| 143 | + pluginId, deployment.id, | |
| 144 | + ) | |
| 145 | + } catch (ex: Throwable) { | |
| 146 | + log.warn( | |
| 147 | + "PluginProcessDeployer: plug-in '{}' deployment '{}' failed to delete: {}", | |
| 148 | + pluginId, deployment.id, ex.message, ex, | |
| 149 | + ) | |
| 150 | + } | |
| 151 | + } | |
| 152 | + return deployments.size | |
| 153 | + } | |
| 154 | + | |
| 155 | + companion object { | |
| 156 | + private const val PROCESSES_DIR: String = "processes/" | |
| 157 | + private const val BPMN20_SUFFIX: String = ".bpmn20.xml" | |
| 158 | + private const val BPMN_SUFFIX: String = ".bpmn" | |
| 159 | + } | |
| 160 | +} | ... | ... |
platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/PluginProcessDeployerTest.kt
0 → 100644
| 1 | +package org.vibeerp.platform.workflow | |
| 2 | + | |
| 3 | +import assertk.assertFailure | |
| 4 | +import assertk.assertThat | |
| 5 | +import assertk.assertions.isEqualTo | |
| 6 | +import assertk.assertions.isInstanceOf | |
| 7 | +import assertk.assertions.isNull | |
| 8 | +import io.mockk.every | |
| 9 | +import io.mockk.mockk | |
| 10 | +import io.mockk.verify | |
| 11 | +import org.flowable.engine.RepositoryService | |
| 12 | +import org.flowable.engine.repository.Deployment | |
| 13 | +import org.flowable.engine.repository.DeploymentBuilder | |
| 14 | +import org.flowable.engine.repository.DeploymentQuery | |
| 15 | +import org.junit.jupiter.api.Test | |
| 16 | +import org.junit.jupiter.api.io.TempDir | |
| 17 | +import java.nio.file.Files | |
| 18 | +import java.nio.file.Path | |
| 19 | +import java.util.jar.Attributes | |
| 20 | +import java.util.jar.JarEntry | |
| 21 | +import java.util.jar.JarOutputStream | |
| 22 | +import java.util.jar.Manifest | |
| 23 | + | |
| 24 | +class PluginProcessDeployerTest { | |
| 25 | + | |
| 26 | + @Test | |
| 27 | + fun `deployFromPlugin returns null when jarPath is not a regular file`(@TempDir tmp: Path) { | |
| 28 | + val missing = tmp.resolve("no-such.jar") | |
| 29 | + val repo = mockk<RepositoryService>() // must not be called | |
| 30 | + val deployer = PluginProcessDeployer(repo) | |
| 31 | + | |
| 32 | + assertThat(deployer.deployFromPlugin("some-plugin", missing)).isNull() | |
| 33 | + verify(exactly = 0) { repo.createDeployment() } | |
| 34 | + } | |
| 35 | + | |
| 36 | + @Test | |
| 37 | + fun `deployFromPlugin returns null when the plug-in jar has no BPMN resources`(@TempDir tmp: Path) { | |
| 38 | + val jar = tmp.resolve("empty.jar") | |
| 39 | + writeJar(jar, mapOf("README.txt" to "hello".toByteArray())) | |
| 40 | + val repo = mockk<RepositoryService>() // must not be called | |
| 41 | + val deployer = PluginProcessDeployer(repo) | |
| 42 | + | |
| 43 | + assertThat(deployer.deployFromPlugin("plugin-a", jar)).isNull() | |
| 44 | + verify(exactly = 0) { repo.createDeployment() } | |
| 45 | + } | |
| 46 | + | |
| 47 | + @Test | |
| 48 | + fun `deployFromPlugin reads every bpmn resource under processes and deploys one bundle`(@TempDir tmp: Path) { | |
| 49 | + val jar = tmp.resolve("printing-shop.jar") | |
| 50 | + val bpmnA = bpmnBytes("plate-approval") | |
| 51 | + val bpmnB = bpmnBytes("ink-mix") | |
| 52 | + writeJar( | |
| 53 | + jar, | |
| 54 | + mapOf( | |
| 55 | + "processes/plate-approval.bpmn20.xml" to bpmnA, | |
| 56 | + "processes/ink-mix.bpmn" to bpmnB, | |
| 57 | + "processes/README.md" to "ignored".toByteArray(), // not BPMN | |
| 58 | + "META-INF/vibe-erp/metadata/printing-shop.yml" to "#...".toByteArray(), // not in processes | |
| 59 | + ), | |
| 60 | + ) | |
| 61 | + | |
| 62 | + val builder = mockk<DeploymentBuilder>() | |
| 63 | + every { builder.name(any()) } returns builder | |
| 64 | + every { builder.category(any()) } returns builder | |
| 65 | + every { builder.addBytes(any(), any()) } returns builder | |
| 66 | + val deployment = mockk<Deployment>() | |
| 67 | + every { deployment.id } returns "dep-42" | |
| 68 | + every { builder.deploy() } returns deployment | |
| 69 | + | |
| 70 | + val repo = mockk<RepositoryService>() | |
| 71 | + every { repo.createDeployment() } returns builder | |
| 72 | + | |
| 73 | + val deployer = PluginProcessDeployer(repo) | |
| 74 | + val result = deployer.deployFromPlugin("printing-shop", jar) | |
| 75 | + | |
| 76 | + assertThat(result).isEqualTo("dep-42") | |
| 77 | + verify(exactly = 1) { builder.name("plugin:printing-shop") } | |
| 78 | + verify(exactly = 1) { builder.category("printing-shop") } | |
| 79 | + verify(exactly = 1) { builder.addBytes("processes/plate-approval.bpmn20.xml", bpmnA) } | |
| 80 | + verify(exactly = 1) { builder.addBytes("processes/ink-mix.bpmn", bpmnB) } | |
| 81 | + verify(exactly = 1) { builder.deploy() } | |
| 82 | + } | |
| 83 | + | |
| 84 | + @Test | |
| 85 | + fun `deployFromPlugin rejects a blank plug-in id`(@TempDir tmp: Path) { | |
| 86 | + val jar = tmp.resolve("x.jar") | |
| 87 | + writeJar(jar, emptyMap()) | |
| 88 | + val deployer = PluginProcessDeployer(mockk()) | |
| 89 | + | |
| 90 | + assertFailure { deployer.deployFromPlugin(" ", jar) } | |
| 91 | + .isInstanceOf(IllegalArgumentException::class) | |
| 92 | + } | |
| 93 | + | |
| 94 | + @Test | |
| 95 | + fun `undeployByPlugin returns zero when there is nothing to remove`() { | |
| 96 | + val query = mockk<DeploymentQuery>() | |
| 97 | + every { query.deploymentCategory(any()) } returns query | |
| 98 | + every { query.list() } returns emptyList() | |
| 99 | + | |
| 100 | + val repo = mockk<RepositoryService>() | |
| 101 | + every { repo.createDeploymentQuery() } returns query | |
| 102 | + | |
| 103 | + val deployer = PluginProcessDeployer(repo) | |
| 104 | + assertThat(deployer.undeployByPlugin("nobody")).isEqualTo(0) | |
| 105 | + verify(exactly = 0) { repo.deleteDeployment(any(), any()) } | |
| 106 | + } | |
| 107 | + | |
| 108 | + @Test | |
| 109 | + fun `undeployByPlugin cascades a deleteDeployment per matching deployment`() { | |
| 110 | + val query = mockk<DeploymentQuery>() | |
| 111 | + every { query.deploymentCategory("printing-shop") } returns query | |
| 112 | + val d1 = mockk<Deployment>().also { every { it.id } returns "dep-1" } | |
| 113 | + val d2 = mockk<Deployment>().also { every { it.id } returns "dep-2" } | |
| 114 | + every { query.list() } returns listOf(d1, d2) | |
| 115 | + | |
| 116 | + val repo = mockk<RepositoryService>() | |
| 117 | + every { repo.createDeploymentQuery() } returns query | |
| 118 | + every { repo.deleteDeployment(any(), any()) } returns Unit | |
| 119 | + | |
| 120 | + val deployer = PluginProcessDeployer(repo) | |
| 121 | + assertThat(deployer.undeployByPlugin("printing-shop")).isEqualTo(2) | |
| 122 | + | |
| 123 | + verify(exactly = 1) { repo.deleteDeployment("dep-1", true) } | |
| 124 | + verify(exactly = 1) { repo.deleteDeployment("dep-2", true) } | |
| 125 | + } | |
| 126 | + | |
| 127 | + // ─── helpers ─────────────────────────────────────────────────── | |
| 128 | + | |
| 129 | + private fun writeJar(jar: Path, entries: Map<String, ByteArray>) { | |
| 130 | + val manifest = Manifest().apply { | |
| 131 | + mainAttributes[Attributes.Name.MANIFEST_VERSION] = "1.0" | |
| 132 | + } | |
| 133 | + Files.newOutputStream(jar).use { outFile -> | |
| 134 | + JarOutputStream(outFile, manifest).use { jarOut -> | |
| 135 | + for ((name, bytes) in entries) { | |
| 136 | + jarOut.putNextEntry(JarEntry(name)) | |
| 137 | + jarOut.write(bytes) | |
| 138 | + jarOut.closeEntry() | |
| 139 | + } | |
| 140 | + } | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + private fun bpmnBytes(processId: String): ByteArray = | |
| 145 | + """ | |
| 146 | + <?xml version="1.0" encoding="UTF-8"?> | |
| 147 | + <definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" | |
| 148 | + targetNamespace="http://example.org"> | |
| 149 | + <process id="$processId" isExecutable="true"> | |
| 150 | + <startEvent id="s"/> | |
| 151 | + <endEvent id="e"/> | |
| 152 | + </process> | |
| 153 | + </definitions> | |
| 154 | + """.trimIndent().toByteArray() | |
| 155 | +} | ... | ... |
reference-customer/plugin-printing-shop/src/main/resources/processes/plate-approval.bpmn20.xml
0 → 100644
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<!-- | |
| 3 | + Reference printing-shop plug-in BPMN — plate approval workflow. | |
| 4 | + | |
| 5 | + This file is the executable acceptance test for the P2.1 follow-up | |
| 6 | + "plug-in-shipped BPMN auto-deployment" chunk. It lives inside the | |
| 7 | + plug-in JAR under processes/, which Flowable's Spring Boot starter | |
| 8 | + CANNOT see (classpath auto-scan doesn't traverse PF4J plug-in | |
| 9 | + classloaders). The host's PluginProcessDeployer reads this file out | |
| 10 | + of the JAR at plug-in start time and registers it with the Flowable | |
| 11 | + RepositoryService under category = "printing-shop". On plug-in stop | |
| 12 | + the host calls undeployByPlugin("printing-shop") which cascade- | |
| 13 | + deletes this deployment and every process instance that referenced | |
| 14 | + it. | |
| 15 | + | |
| 16 | + Structure: a single synchronous service task that delegates to the | |
| 17 | + shared Spring bean "taskDispatcher", which then routes by activity | |
| 18 | + id to the registered TaskHandler whose key() returns the serviceTask | |
| 19 | + id ("printing_shop.plate.approve" — PlateApprovalTaskHandler in the | |
| 20 | + plug-in's main module). | |
| 21 | + | |
| 22 | + Process-definition key "plugin-printing-shop-plate-approval" is the | |
| 23 | + REST caller's handle: | |
| 24 | + POST /api/v1/workflow/process-instances | |
| 25 | + {"processDefinitionKey":"plugin-printing-shop-plate-approval", | |
| 26 | + "variables":{"plateId":"...."}} | |
| 27 | +--> | |
| 28 | +<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" | |
| 29 | + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
| 30 | + xmlns:flowable="http://flowable.org/bpmn" | |
| 31 | + targetNamespace="http://vibeerp.org/plugin/printing-shop/bpmn"> | |
| 32 | + <process id="plugin-printing-shop-plate-approval" | |
| 33 | + name="Printing shop — plate approval" | |
| 34 | + isExecutable="true"> | |
| 35 | + <startEvent id="start"/> | |
| 36 | + <sequenceFlow id="flow-start-to-approve" | |
| 37 | + sourceRef="start" | |
| 38 | + targetRef="printing_shop.plate.approve"/> | |
| 39 | + <serviceTask id="printing_shop.plate.approve" | |
| 40 | + name="Approve plate" | |
| 41 | + flowable:delegateExpression="${taskDispatcher}"/> | |
| 42 | + <sequenceFlow id="flow-approve-to-end" | |
| 43 | + sourceRef="printing_shop.plate.approve" | |
| 44 | + targetRef="end"/> | |
| 45 | + <endEvent id="end"/> | |
| 46 | + </process> | |
| 47 | +</definitions> | ... | ... |