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