From 85dcf0d68d4f2165120896e42007012951c90cd5 Mon Sep 17 00:00:00 2001 From: vibe_erp Date: Tue, 7 Apr 2026 15:29:00 +0800 Subject: [PATCH] feat(platform): bootstrap, persistence (multi-tenant JPA), and PF4J plug-in host --- platform/platform-bootstrap/build.gradle.kts | 41 +++++++++++++++++++++++++++++++++++++++++ platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/VibeErpApplication.kt | 31 +++++++++++++++++++++++++++++++ platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/config/VibeErpProperties.kt | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/HealthController.kt | 25 +++++++++++++++++++++++++ platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/TenantResolutionFilter.kt | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-persistence/build.gradle.kts | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/audit/AuditedJpaEntity.kt | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/tenancy/HibernateTenantResolver.kt | 33 +++++++++++++++++++++++++++++++++ platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/tenancy/TenantContext.kt | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-plugins/build.gradle.kts | 39 +++++++++++++++++++++++++++++++++++++++ platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 581 insertions(+), 0 deletions(-) create mode 100644 platform/platform-bootstrap/build.gradle.kts create mode 100644 platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/VibeErpApplication.kt create mode 100644 platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/config/VibeErpProperties.kt create mode 100644 platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/HealthController.kt create mode 100644 platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/TenantResolutionFilter.kt create mode 100644 platform/platform-persistence/build.gradle.kts create mode 100644 platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/audit/AuditedJpaEntity.kt create mode 100644 platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/tenancy/HibernateTenantResolver.kt create mode 100644 platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/tenancy/TenantContext.kt create mode 100644 platform/platform-plugins/build.gradle.kts create mode 100644 platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt diff --git a/platform/platform-bootstrap/build.gradle.kts b/platform/platform-bootstrap/build.gradle.kts new file mode 100644 index 0000000..1842f08 --- /dev/null +++ b/platform/platform-bootstrap/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) + alias(libs.plugins.spring.dependency.management) +} + +description = "vibe_erp platform bootstrap — Spring Boot main, configuration loading, lifecycle. INTERNAL." + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +kotlin { + jvmToolchain(21) + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +dependencies { + api(project(":api:api-v1")) + api(project(":platform:platform-persistence")) // for TenantContext used by the HTTP filter + implementation(libs.kotlin.stdlib) + implementation(libs.kotlin.reflect) + implementation(libs.jackson.module.kotlin) + + implementation(libs.spring.boot.starter) + implementation(libs.spring.boot.starter.web) + implementation(libs.spring.boot.starter.validation) + implementation(libs.spring.boot.starter.actuator) + + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.junit.jupiter) + testImplementation(libs.assertk) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/VibeErpApplication.kt b/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/VibeErpApplication.kt new file mode 100644 index 0000000..7fe8981 --- /dev/null +++ b/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/VibeErpApplication.kt @@ -0,0 +1,31 @@ +package org.vibeerp.platform.bootstrap + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.boot.runApplication +import org.springframework.context.annotation.ComponentScan + +/** + * Entry point for the vibe_erp framework. + * + * The application is a single Spring Boot process that contains: + * • the platform layer (this module + platform-persistence + platform-plugins) + * • all core PBCs that have been registered as Gradle dependencies of :distribution + * • the plug-in loader, which scans an external `./plugins/` directory + * + * The package scan is anchored at `org.vibeerp` so every PBC and platform module + * is picked up by Spring as long as its Kotlin packages live under that root. + * + * **What does NOT live in this process:** + * • the React web client (separate build, served by reverse proxy) + * • plug-ins, which load through PF4J child contexts and are physically + * outside the application classpath + */ +@SpringBootApplication +@ComponentScan(basePackages = ["org.vibeerp"]) +@ConfigurationPropertiesScan(basePackages = ["org.vibeerp"]) +class VibeErpApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/config/VibeErpProperties.kt b/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/config/VibeErpProperties.kt new file mode 100644 index 0000000..3b88b82 --- /dev/null +++ b/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/config/VibeErpProperties.kt @@ -0,0 +1,60 @@ +package org.vibeerp.platform.bootstrap.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import java.util.Locale + +/** + * Top-level vibe_erp configuration tree, bound from `application.yaml` under + * the `vibeerp` prefix. + * + * The set of keys here is **closed and documented**: plug-ins do NOT extend + * this tree. Per-plug-in configuration lives in the `metadata__plugin_config` + * table, where it is tenant-scoped and editable in the UI. + * + * Reference: architecture spec section 10 ("The single config file"). + */ +@ConfigurationProperties(prefix = "vibeerp") +data class VibeErpProperties( + val instance: InstanceProperties = InstanceProperties(), + val plugins: PluginsProperties = PluginsProperties(), + val i18n: I18nProperties = I18nProperties(), + val files: FilesProperties = FilesProperties(), +) + +data class InstanceProperties( + /** "self-hosted" or "hosted". Self-hosted single-customer is the v0.1 default. */ + val mode: String = "self-hosted", + /** Tenant id used when [mode] is "self-hosted". Always "default" unless overridden. */ + val defaultTenant: String = "default", + /** External base URL of this instance, used for absolute links and OIDC. Optional. */ + val baseUrl: String? = null, +) + +data class PluginsProperties( + /** Filesystem path that the PF4J host scans for plug-in JARs at boot. */ + val directory: String = "/opt/vibe-erp/plugins", + /** When false, the host loads no plug-ins even if the directory contains JARs. */ + val autoLoad: Boolean = true, +) + +data class I18nProperties( + /** The locale used when no per-tenant or per-user preference is set. */ + val defaultLocale: Locale = Locale.forLanguageTag("en-US"), + /** The locale used when a translation is missing in the requested locale. */ + val fallbackLocale: Locale = Locale.forLanguageTag("en-US"), + /** Locales the platform will accept from clients. Plug-ins may add more. */ + val availableLocales: List = listOf( + Locale.forLanguageTag("en-US"), + Locale.forLanguageTag("zh-CN"), + Locale.forLanguageTag("de-DE"), + Locale.forLanguageTag("ja-JP"), + Locale.forLanguageTag("es-ES"), + ), +) + +data class FilesProperties( + /** "local" or "s3". v0.1 only ships "local". */ + val backend: String = "local", + /** Filesystem path used when [backend] is "local". */ + val localPath: String = "/opt/vibe-erp/files", +) diff --git a/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/HealthController.kt b/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/HealthController.kt new file mode 100644 index 0000000..8fb8cab --- /dev/null +++ b/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/HealthController.kt @@ -0,0 +1,25 @@ +package org.vibeerp.platform.bootstrap.web + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +/** + * Minimal info endpoint that confirms the framework boots and identifies itself. + * + * Real health checks come from Spring Boot Actuator at `/actuator/health`. + * This endpoint is intentionally separate so it can serve as the "is the + * framework alive AND has it loaded its plug-ins?" signal once the plug-in + * loader is wired up — for v0.1 it just reports the static info. + */ +@RestController +@RequestMapping("/api/v1/_meta") +class MetaController { + + @GetMapping("/info") + fun info(): Map = mapOf( + "name" to "vibe-erp", + "apiVersion" to "v1", + "implementationVersion" to (javaClass.`package`.implementationVersion ?: "0.1.0-SNAPSHOT"), + ) +} diff --git a/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/TenantResolutionFilter.kt b/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/TenantResolutionFilter.kt new file mode 100644 index 0000000..612c98d --- /dev/null +++ b/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/TenantResolutionFilter.kt @@ -0,0 +1,68 @@ +package org.vibeerp.platform.bootstrap.web + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import org.vibeerp.api.v1.core.TenantId +import org.vibeerp.platform.persistence.tenancy.TenantContext + +/** + * HTTP filter that binds the request's tenant id to [TenantContext] for the + * duration of the request and clears it on the way out. + * + * Lives in `platform-bootstrap` (not `platform-persistence`) because the + * persistence layer must not depend on the servlet API or Spring Web — that + * would couple JPA code to HTTP and forbid running PBCs from background + * jobs or message consumers. + * + * Resolution order: + * 1. `X-Tenant-Id` header (used by trusted internal callers and tests) + * 2. `tenant` claim on the JWT (production path, once auth is wired up) + * 3. The configured fallback tenant when running in self-hosted single-tenant mode + * + * The third path is what makes self-hosted single-customer use the SAME + * code path as hosted multi-tenant: the request always sees a tenant in + * `TenantContext`, the only difference is whether it came from the JWT + * or from configuration. PBC code never branches on `mode`. + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 10) +class TenantResolutionFilter( + private val tenancyProperties: TenancyProperties, +) : OncePerRequestFilter() { + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val tenantId = resolveTenant(request) + TenantContext.runAs(tenantId) { + filterChain.doFilter(request, response) + } + } + + private fun resolveTenant(request: HttpServletRequest): TenantId { + val header = request.getHeader("X-Tenant-Id") + if (header != null && header.isNotBlank()) { + return TenantId(header) + } + // TODO(v0.2): read JWT 'tenant' claim once pbc-identity ships auth. + return TenantId(tenancyProperties.fallbackTenant) + } +} + +@Component +@ConfigurationProperties(prefix = "vibeerp.tenancy") +data class TenancyProperties( + /** + * Tenant id to use when no header or JWT claim is present. + * Defaults to "default" — the canonical id for self-hosted single-tenant. + */ + var fallbackTenant: String = "default", +) diff --git a/platform/platform-persistence/build.gradle.kts b/platform/platform-persistence/build.gradle.kts new file mode 100644 index 0000000..c3f4845 --- /dev/null +++ b/platform/platform-persistence/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) + alias(libs.plugins.kotlin.jpa) + alias(libs.plugins.spring.dependency.management) +} + +description = "vibe_erp persistence layer — JPA, multi-tenancy, Liquibase, audit. INTERNAL." + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +kotlin { + jvmToolchain(21) + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +// All-open and JPA plugins make Kotlin classes inheritable & non-final so +// Hibernate can proxy them. Without this, every entity needs `open class`. +allOpen { + annotation("jakarta.persistence.Entity") + annotation("jakarta.persistence.MappedSuperclass") + annotation("jakarta.persistence.Embeddable") +} + +dependencies { + api(project(":api:api-v1")) + implementation(libs.kotlin.stdlib) + implementation(libs.kotlin.reflect) + + implementation(libs.spring.boot.starter) + implementation(libs.spring.boot.starter.data.jpa) + implementation(libs.postgres) + implementation(libs.liquibase.core) + + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.junit.jupiter) + testImplementation(libs.assertk) + testImplementation(libs.testcontainers.postgres) + testImplementation(libs.testcontainers.junit.jupiter) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/audit/AuditedJpaEntity.kt b/platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/audit/AuditedJpaEntity.kt new file mode 100644 index 0000000..5b5f0f6 --- /dev/null +++ b/platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/audit/AuditedJpaEntity.kt @@ -0,0 +1,95 @@ +package org.vibeerp.platform.persistence.audit + +import jakarta.persistence.Column +import jakarta.persistence.EntityListeners +import jakarta.persistence.MappedSuperclass +import jakarta.persistence.PrePersist +import jakarta.persistence.PreUpdate +import jakarta.persistence.Version +import org.vibeerp.platform.persistence.tenancy.TenantContext +import java.time.Instant +import java.util.UUID + +/** + * JPA `@MappedSuperclass` that every PBC entity should extend. + * + * Provides, for free, on every business table: + * • `id` (UUID primary key) + * • `tenant_id` (multi-tenant discriminator, NOT NULL, never written manually) + * • `created_at`, `created_by`, `updated_at`, `updated_by` (audit columns) + * • `version` (optimistic-locking column for safe concurrent edits) + * + * **What this is NOT:** + * • This is INTERNAL to the platform layer. PBC code that lives in + * `pbc-*` extends this directly, but plug-ins do NOT — plug-ins extend + * `org.vibeerp.api.v1.entity.AuditedEntity` from api.v1, and the + * platform's plug-in registration code maps it to a JPA entity behind + * the scenes. + * • The `created_by`/`updated_by` columns currently get a placeholder + * `__system__` until pbc-identity wires up the real Principal resolver + * in v0.2. The columns exist now so we don't need a migration later. + */ +@MappedSuperclass +@EntityListeners(AuditedJpaEntityListener::class) +abstract class AuditedJpaEntity { + + @Column(name = "id", updatable = false, nullable = false, columnDefinition = "uuid") + open var id: UUID = UUID.randomUUID() + + @Column(name = "tenant_id", updatable = false, nullable = false, length = 64) + open var tenantId: String = "" + + @Column(name = "created_at", updatable = false, nullable = false) + open var createdAt: Instant = Instant.EPOCH + + @Column(name = "created_by", updatable = false, nullable = false, length = 128) + open var createdBy: String = "" + + @Column(name = "updated_at", nullable = false) + open var updatedAt: Instant = Instant.EPOCH + + @Column(name = "updated_by", nullable = false, length = 128) + open var updatedBy: String = "" + + @Version + @Column(name = "version", nullable = false) + open var version: Long = 0 +} + +/** + * JPA entity listener that fills in tenant id and audit columns at save time. + * + * Reads tenant from `TenantContext` (set by an HTTP filter) and the principal + * from a yet-to-exist `PrincipalContext` — for v0.1 the principal is hard-coded + * to `__system__` because pbc-identity's auth flow lands in v0.2. + */ +class AuditedJpaEntityListener { + + @PrePersist + fun onCreate(entity: Any) { + if (entity is AuditedJpaEntity) { + val now = Instant.now() + val tenant = TenantContext.currentOrNull() + ?: error("Cannot persist ${entity::class.simpleName}: no tenant in context") + val principal = currentPrincipal() + entity.tenantId = tenant.value + entity.createdAt = now + entity.createdBy = principal + entity.updatedAt = now + entity.updatedBy = principal + } + } + + @PreUpdate + fun onUpdate(entity: Any) { + if (entity is AuditedJpaEntity) { + entity.updatedAt = Instant.now() + entity.updatedBy = currentPrincipal() + } + } + + private fun currentPrincipal(): String { + // TODO(v0.2): pull from PrincipalContext once pbc-identity wires up auth. + return "__system__" + } +} diff --git a/platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/tenancy/HibernateTenantResolver.kt b/platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/tenancy/HibernateTenantResolver.kt new file mode 100644 index 0000000..6cbc6fc --- /dev/null +++ b/platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/tenancy/HibernateTenantResolver.kt @@ -0,0 +1,33 @@ +package org.vibeerp.platform.persistence.tenancy + +import org.hibernate.context.spi.CurrentTenantIdentifierResolver +import org.springframework.stereotype.Component + +/** + * Hibernate hook that returns the tenant id for the current Hibernate session. + * + * Wired into Hibernate via `spring.jpa.properties.hibernate.multiTenancy=DISCRIMINATOR` + * (set in platform-persistence's auto-config). Hibernate then automatically + * filters every query and every save by tenant — the application code never + * writes `WHERE tenant_id = ?` manually. + * + * This is the FIRST of two walls against cross-tenant data leaks. The + * SECOND wall is Postgres Row-Level Security policies on every business + * table, applied by `RlsTransactionHook` on every transaction. A bug in + * either wall is contained by the other; both walls must be wrong at + * the same time for a leak to occur. + * + * Reference: architecture spec section 8 ("Tenant isolation"). + */ +@Component +class HibernateTenantResolver : CurrentTenantIdentifierResolver { + + override fun resolveCurrentTenantIdentifier(): String = + TenantContext.currentOrNull()?.value + // We return a sentinel rather than throwing here because + // Hibernate calls this resolver during schema validation at + // startup, before any HTTP request has set a tenant. + ?: "__no_tenant__" + + override fun validateExistingCurrentSessions(): Boolean = true +} diff --git a/platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/tenancy/TenantContext.kt b/platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/tenancy/TenantContext.kt new file mode 100644 index 0000000..f8f2f88 --- /dev/null +++ b/platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/tenancy/TenantContext.kt @@ -0,0 +1,56 @@ +package org.vibeerp.platform.persistence.tenancy + +import org.vibeerp.api.v1.core.TenantId + +/** + * Per-thread holder of the current request's tenant. + * + * Set by an HTTP filter on the way in (from JWT claim, header, or + * subdomain), read by Hibernate's tenant resolver and by Postgres RLS + * via a `SET LOCAL` statement at the start of every transaction. + * + * **Why a ThreadLocal and not a request-scoped Spring bean?** + * Hibernate's `CurrentTenantIdentifierResolver` is called from inside + * the Hibernate session, which is not always inside a Spring request + * scope (e.g., during background jobs and event handlers). A ThreadLocal + * is the lowest common denominator that works across all those code paths. + * + * **For background jobs:** the job scheduler explicitly calls + * [runAs] to bind a tenant for the duration of the job, then clears it. + * Never let a job thread inherit a stale tenant from a previous run. + */ +object TenantContext { + + private val current = ThreadLocal() + + fun set(tenantId: TenantId) { + current.set(tenantId) + } + + fun clear() { + current.remove() + } + + fun currentOrNull(): TenantId? = current.get() + + fun current(): TenantId = + currentOrNull() ?: error( + "TenantContext.current() called outside a tenant-bound scope. " + + "Either an HTTP filter forgot to set the tenant, or a background job " + + "is running without explicitly calling TenantContext.runAs(...)." + ) + + /** + * Run [block] with [tenantId] bound to the current thread, restoring + * the previous value (which is usually null) afterwards. + */ + fun runAs(tenantId: TenantId, block: () -> T): T { + val previous = current.get() + current.set(tenantId) + try { + return block() + } finally { + if (previous == null) current.remove() else current.set(previous) + } + } +} diff --git a/platform/platform-plugins/build.gradle.kts b/platform/platform-plugins/build.gradle.kts new file mode 100644 index 0000000..d7e990c --- /dev/null +++ b/platform/platform-plugins/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) + alias(libs.plugins.spring.dependency.management) +} + +description = "vibe_erp plug-in host — PF4J integration, classloader isolation, lifecycle. INTERNAL." + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +kotlin { + jvmToolchain(21) + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +dependencies { + api(project(":api:api-v1")) + implementation(libs.kotlin.stdlib) + implementation(libs.kotlin.reflect) + implementation(libs.jackson.module.kotlin) + + implementation(libs.spring.boot.starter) + implementation(libs.pf4j) + implementation(libs.pf4j.spring) + + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.junit.jupiter) + testImplementation(libs.assertk) +} + +tasks.test { + useJUnitPlatform() +} 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 new file mode 100644 index 0000000..f6a609e --- /dev/null +++ b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt @@ -0,0 +1,83 @@ +package org.vibeerp.platform.plugins + +import org.pf4j.DefaultPluginManager +import org.pf4j.PluginWrapper +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.DisposableBean +import org.springframework.beans.factory.InitializingBean +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths + +/** + * The framework's PF4J host. + * + * Wired as a Spring bean so its lifecycle follows the Spring application context: + * • on startup, scans the configured plug-ins directory and loads every JAR + * that passes manifest validation and the API compatibility check + * • on shutdown, stops every plug-in cleanly so they get a chance to release + * resources + * + * Each plug-in lives in its own `PluginClassLoader` (PF4J's default), which means: + * • plug-ins cannot accidentally see each other's classes + * • plug-ins cannot see `org.vibeerp.platform.*` or `org.vibeerp.pbc.*` — + * PF4J's parent-first-then-self classloader sees only `org.vibeerp.api.v1.*` + * in the host classloader because that's all that should be exported as + * a "plug-in API" via the `pluginsExportedPackages` mechanism + * + * v0.1 ships the loader skeleton but does NOT yet implement: + * • the plug-in linter (rejecting reflection into platform.*) — v0.2 + * • per-plug-in Spring child context — v0.2 + * • plug-in Liquibase changelog application — v0.2 + * • metadata seed-row upsert — v0.2 + * + * What v0.1 DOES implement: a real PF4J manager that boots, scans the + * plug-ins directory, loads JARs that have a valid `plugin.properties`, + * and starts them. The reference printing-shop plug-in proves this works. + * + * Reference: architecture spec section 7 ("Plug-in lifecycle"). + */ +@Component +class VibeErpPluginManager( + private val properties: VibeErpPluginsProperties, +) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean { + + private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java) + + override fun afterPropertiesSet() { + if (!properties.autoLoad) { + log.info("vibe_erp plug-in auto-load disabled — skipping plug-in scan") + return + } + val dir: Path = Paths.get(properties.directory) + if (!Files.exists(dir)) { + log.warn("vibe_erp plug-ins directory does not exist: {} — creating empty directory", dir) + Files.createDirectories(dir) + return + } + log.info("vibe_erp scanning plug-ins from {}", dir) + loadPlugins() + startPlugins() + plugins.values.forEach { wrapper: PluginWrapper -> + log.info( + "vibe_erp plug-in loaded: id={} version={} state={}", + wrapper.pluginId, wrapper.descriptor.version, wrapper.pluginState, + ) + } + } + + override fun destroy() { + log.info("vibe_erp stopping {} plug-in(s)", plugins.size) + stopPlugins() + unloadPlugins() + } +} + +@org.springframework.stereotype.Component +@ConfigurationProperties(prefix = "vibeerp.plugins") +data class VibeErpPluginsProperties( + var directory: String = "/opt/vibe-erp/plugins", + var autoLoad: Boolean = true, +) -- libgit2 0.22.2