diff --git a/core/build.sbt b/core/build.sbt index 058a2a5d6..1dcfdec1e 100644 --- a/core/build.sbt +++ b/core/build.sbt @@ -65,7 +65,13 @@ libraryDependencies ++= akka ++ typesafeConfig ++ http ++ json4s ++ mockito ++ avro ++ cloudConnectors ++ repl :+ "com.google.code.gson" % "gson" % Versions.gson :+ "com.typesafe.scala-logging" %% "scala-logging" % Versions.scalaLogging :+ "io.delta" %% "delta-standalone" % Versions.delta :+ -"org.scalatest" %% "scalatest" % Versions.scalatest % Test +"org.scalatest" %% "scalatest" % Versions.scalatest % Test :+ +// #258: the isolation specs capture the empty-provider-list WARN through logback's ListAppender +// (house pattern: SlicedScrollCompletenessSpec). Test scope only - the published module carries no +// logging backend. Declared here rather than inherited from `licensing % "test->test"`; test logging +// (root WARN) comes from licensing's TestLoggingConfigurator, a logback Configurator SPI that applies +// only where no logback XML config exists - see its scaladoc for why it is not a logback-test.xml. +"ch.qos.logback" % "logback-classic" % Versions.logback % Test // Issue #183: run the very same test suite on an arbitrary JDK without changing the compile JDK. // sbt -Dtest.jdk.home=/Library/Java/JavaVirtualMachines/zulu-25.jdk/Contents/Home \ diff --git a/core/src/main/scala-2.12/app/softnetwork/elastic/client/ElasticConfig.scala b/core/src/main/scala-2.12/app/softnetwork/elastic/client/ElasticConfig.scala index 7c2e8f3d9..11b347c41 100644 --- a/core/src/main/scala-2.12/app/softnetwork/elastic/client/ElasticConfig.scala +++ b/core/src/main/scala-2.12/app/softnetwork/elastic/client/ElasticConfig.scala @@ -37,14 +37,14 @@ import java.time.Duration * Socket operation timeout * @param metrics * Metrics and monitoring configuration - * @param watcher - * Credentials for the watcher component (if applicable) - * @param includeDocumentId - * When enabled, result rows surface the Elasticsearch document id as an `_id` column (disabled - * by default) - * @param scroll - * Paged row extraction settings (`elastic.scroll`: page size and the ceiling on concurrent PIT - * slices, #238) + * @param watcher + * Credentials for the watcher component (if applicable) + * @param includeDocumentId + * When enabled, result rows surface the Elasticsearch document id as an `_id` column (disabled + * by default) + * @param scroll + * Paged row extraction settings (`elastic.scroll`: page size and the ceiling on concurrent PIT + * slices, #238) */ case class ElasticConfig( credentials: ElasticCredentials = ElasticCredentials(), @@ -55,12 +55,22 @@ case class ElasticConfig( metrics: MetricsConfig, watcher: ElasticCredentials, includeDocumentId: Boolean = false, - scroll: ScrollSettings = ScrollSettings()) + scroll: ScrollSettings = ScrollSettings() +) object ElasticConfig extends StrictLogging { + + /** The `elastic.*` defaults shipped in this jar (`softnetwork-elastic.conf`), resolved against + * the classloader that loaded this class rather than the thread context classloader: under a + * host-owned blind TCCL `ConfigFactory.load(name)` finds nothing and every client creation fails + * on configuration before it can even look for a provider (#258). + */ + private def defaults: Config = + ConfigFactory.load(classOf[ElasticConfig].getClassLoader, "softnetwork-elastic.conf") + def apply(config: Config): ElasticConfig = { Configs[ElasticConfig] - .get(config.withFallback(ConfigFactory.load("softnetwork-elastic.conf")), "elastic") + .get(config.withFallback(defaults), "elastic") .toEither match { case Left(configError) => logger.error(s"Something went wrong with the provided arguments $configError") diff --git a/core/src/main/scala-2.13/app/softnetwork/elastic/client/ElasticConfig.scala b/core/src/main/scala-2.13/app/softnetwork/elastic/client/ElasticConfig.scala index 590fc3eb7..8144b13a2 100644 --- a/core/src/main/scala-2.13/app/softnetwork/elastic/client/ElasticConfig.scala +++ b/core/src/main/scala-2.13/app/softnetwork/elastic/client/ElasticConfig.scala @@ -59,9 +59,18 @@ case class ElasticConfig( ) object ElasticConfig extends StrictLogging { + + /** The `elastic.*` defaults shipped in this jar (`softnetwork-elastic.conf`), resolved against + * the classloader that loaded this class rather than the thread context classloader: under a + * host-owned blind TCCL `ConfigFactory.load(name)` finds nothing and every client creation fails + * on configuration before it can even look for a provider (#258). + */ + private def defaults: Config = + ConfigFactory.load(classOf[ElasticConfig].getClassLoader, "softnetwork-elastic.conf") + def apply(config: Config): ElasticConfig = { ConfigReader[ElasticConfig] - .read(config.withFallback(ConfigFactory.load("softnetwork-elastic.conf")), "elastic") + .read(config.withFallback(defaults), "elastic") .toEither match { case Left(configError) => logger.error(s"Something went wrong with the provided arguments $configError") diff --git a/core/src/main/scala/app/softnetwork/elastic/client/ExtensionRegistry.scala b/core/src/main/scala/app/softnetwork/elastic/client/ExtensionRegistry.scala index 84d0e0297..2b83dbe68 100644 --- a/core/src/main/scala/app/softnetwork/elastic/client/ExtensionRegistry.scala +++ b/core/src/main/scala/app/softnetwork/elastic/client/ExtensionRegistry.scala @@ -36,9 +36,22 @@ class ExtensionRegistry( */ lazy val extensions: Seq[ExtensionSpi] = { val scanStart = System.nanoTime() - val loader = ServiceLoader.load(classOf[ExtensionSpi]) + // #258: resolve providers against the classloader that loaded this library, never the thread + // context classloader - under a host-owned blind loader a single-arg load found nothing and the + // registry silently ran with no extension at all (#157's silent-wrong-answer mode). + val spiClass = classOf[ExtensionSpi] + val discovered = ServiceLoader.load(spiClass, spiClass.getClassLoader).iterator().asScala.toList + if (discovered.isEmpty) { + logger.warn( + s"No ${spiClass.getName} provider found through ${spiClass.getClassLoader}: no SQL " + + "extension will be available - not even the core DDL/DQL extensions shipped in " + + "softclient4es-core, let alone cross-index JOIN or materialized views. Providers are " + + "resolved against the classloader that loaded softclient4es-core, never the thread context " + + "classloader: the extension jars must be on that same classpath (#258)." + ) + } - val loaded = loader.iterator().asScala.toSeq.flatMap { ext => + val loaded = discovered.flatMap { ext => logger.info( s"🔌 Discovered extension: ${ext.extensionName} v${ext.version} (priority: ${ext.priority})" ) diff --git a/core/src/main/scala/app/softnetwork/elastic/client/spi/ElasticClientFactory.scala b/core/src/main/scala/app/softnetwork/elastic/client/spi/ElasticClientFactory.scala index 4f5838a5e..d6a9db2ef 100644 --- a/core/src/main/scala/app/softnetwork/elastic/client/spi/ElasticClientFactory.scala +++ b/core/src/main/scala/app/softnetwork/elastic/client/spi/ElasticClientFactory.scala @@ -25,6 +25,7 @@ import org.slf4j.{Logger, LoggerFactory} import java.util.ServiceLoader import java.util.concurrent.ConcurrentHashMap import scala.jdk.CollectionConverters._ +import scala.sys.ShutdownHookThread import scala.util.control.NonFatal /** Factory for creating Elasticsearch clients with optional metrics and monitoring. @@ -39,16 +40,22 @@ object ElasticClientFactory { private val logger: Logger = LoggerFactory.getLogger(getClass) + // #258: resolve providers against the classloader that loaded this library, never the thread + // context classloader. This val is latched at class initialisation, so a single-arg load froze + // whichever TCCL the first caller happened to carry - under a host-owned blind loader (Tableau, + // plugin containers, app servers) that meant a permanent and misleading "No ElasticClientSpi + // implementation found" although the provider sat right beside this class on the classpath. private[this] val factories: ServiceLoader[ElasticClientSpi] = - ServiceLoader.load(classOf[ElasticClientSpi]) + ServiceLoader.load(classOf[ElasticClientSpi], classOf[ElasticClientSpi].getClassLoader) // Use String key (URL) instead of Config for reliable caching private[this] val clientsByUrl = new ConcurrentHashMap[String, ElasticClientApi]() private[this] val metricsClientsByUrl = new ConcurrentHashMap[String, MetricsElasticClient]() private[this] val monitoredClientsByUrl = new ConcurrentHashMap[String, MonitoredElasticClient]() - // Shutdown hook to close all clients - sys.addShutdownHook { + // Shutdown hook to close all clients. Named so that a test which defines a fresh copy of this + // object (ElasticClientFactoryIsolationSpec, #258) can remove the hook that copy registered. + private[spi] val shutdownHook: ShutdownHookThread = sys.addShutdownHook { logger.info("JVM shutdown detected, closing all Elasticsearch clients") shutdown() } @@ -74,7 +81,15 @@ object ElasticClientFactory { .map(_.client(config)) .toSeq .headOption - .getOrElse(throw new IllegalStateException("No ElasticClientSpi implementation found")) + .getOrElse( + // The leading substring is pinned by the jdbc/arrow isolation specs - append, never replace. + throw new IllegalStateException( + "No ElasticClientSpi implementation found through " + + s"${classOf[ElasticClientSpi].getClassLoader}: the client jar must be on the same " + + "classpath as softclient4es-core - providers are resolved against the classloader that " + + "loaded softclient4es-core, never the thread context classloader (#258)" + ) + ) } ) } diff --git a/core/src/test/scala/app/softnetwork/elastic/client/ElasticConfigIsolationSpec.scala b/core/src/test/scala/app/softnetwork/elastic/client/ElasticConfigIsolationSpec.scala new file mode 100644 index 000000000..18e747c80 --- /dev/null +++ b/core/src/test/scala/app/softnetwork/elastic/client/ElasticConfigIsolationSpec.scala @@ -0,0 +1,53 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.client + +import app.softnetwork.elastic.licensing.ClassLoaderIsolation +import com.typesafe.config.{Config, ConfigFactory} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +/** #258 (found by the ElasticClientFactory isolation spec): `ElasticConfig.apply` supplies the + * `elastic.*` defaults by loading `softnetwork-elastic.conf` itself, and + * `ConfigFactory.load(name)` resolves that resource through the thread context classloader. Under + * a host-owned blind TCCL the factory therefore failed on configuration BEFORE its `ServiceLoader` + * ever ran. The resource must resolve against the classloader that loaded softclient4es-core, + * whatever the TCCL. + */ +class ElasticConfigIsolationSpec extends AnyFlatSpec with Matchers { + + private val defaultsResource = "softnetwork-elastic.conf" + + behavior of "ElasticConfig defaults resolution (#258)" + + it should "resolve the softnetwork-elastic.conf defaults under a blind context classloader" in { + // Built BEFORE the swap (ConfigFactory.load() is itself TCCL-bound). Precondition, asserted not + // assumed: the application config carries NO `elastic.*` key of its own, so every value below + // has to come from the defaults resource. + val application: Config = ConfigFactory.load() + application.hasPath("elastic") shouldBe false + + val blind = ClassLoaderIsolation.blindLoader() + blind.getResource(defaultsResource) shouldBe null + + val expected = ElasticConfig(application) + val underBlindTccl = + ClassLoaderIsolation.withContextClassLoader(blind)(ElasticConfig(application)) + + underBlindTccl shouldBe expected + } +} diff --git a/core/src/test/scala/app/softnetwork/elastic/client/ExtensionRegistryIsolationSpec.scala b/core/src/test/scala/app/softnetwork/elastic/client/ExtensionRegistryIsolationSpec.scala new file mode 100644 index 000000000..edf53b733 --- /dev/null +++ b/core/src/test/scala/app/softnetwork/elastic/client/ExtensionRegistryIsolationSpec.scala @@ -0,0 +1,108 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.client + +import app.softnetwork.elastic.client.extensions.{CoreDdlExtension, CoreDqlExtension} +import app.softnetwork.elastic.licensing.ClassLoaderIsolation.RedefiningClassLoader +import app.softnetwork.elastic.licensing.{ + ClassLoaderIsolation, + LicenseRefreshStrategy, + LogbackCapture, + NopRefreshStrategy +} +import ch.qos.logback.classic.Level +import com.typesafe.config.{Config, ConfigFactory} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.net.URL + +/** #258 - `ExtensionRegistry` resolves its `ExtensionSpi` providers against the interface's own + * classloader, never the thread context classloader, and WARNs when it finds none (the silent "no + * JOIN / MV extension" mode of #157). + * + * Each spec instantiates a FRESH copy of the registry class defined by a + * [[RedefiningClassLoader]], so the `lazy val extensions` it forces is that class's first touch + * whatever ran before. + */ +class ExtensionRegistryIsolationSpec extends AnyFlatSpec with Matchers { + + private val registryClass = classOf[ExtensionRegistry].getName + private val spiName = classOf[ExtensionSpi].getName + private val servicesResource = "META-INF/services/" + spiName + + // ExtensionRegistry logs through LoggerFactory.getLogger(getClass): the logger is its class name + private val registryLogger = registryClass + + // Built BEFORE any blind context classloader is installed: ConfigFactory.load() is TCCL-bound. + private val config: Config = ConfigFactory.load() + private val strategy: LicenseRefreshStrategy = new NopRefreshStrategy() + + private def extensionsOfFreshRegistry(loader: ClassLoader): Seq[_] = { + val cls = loader.loadClass(registryClass) + // a distinct Class => a distinct, unforced lazy val: this IS the registry's first touch + cls should not be theSameInstanceAs(classOf[ExtensionRegistry]) + val registry = cls + .getConstructor(classOf[Config], classOf[LicenseRefreshStrategy]) + .newInstance(config, strategy) + .asInstanceOf[AnyRef] + cls.getMethod("extensions").invoke(registry).asInstanceOf[Seq[_]] + } + + behavior of "ExtensionRegistry provider discovery (#258)" + + it should "discover the extensions shipped in softclient4es-core on first touch under a blind context classloader" in { + val parent = getClass.getClassLoader + // precondition, asserted not assumed: core's own jar registers CoreDdlExtension/CoreDqlExtension + parent.getResources(servicesResource).hasMoreElements shouldBe true + val blind = ClassLoaderIsolation.blindLoader() + blind.getResources(servicesResource).hasMoreElements shouldBe false + + val isolating = + new RedefiningClassLoader(parent, Seq(registryClass), Set.empty, Array.empty[URL]) + val (extensions, events) = LogbackCapture.capture(registryLogger, Level.INFO) { + ClassLoaderIsolation.withContextClassLoader(blind)(extensionsOfFreshRegistry(isolating)) + } + val ids = extensions.collect { case e: ExtensionSpi => e.extensionId } + ids should contain allOf (new CoreDdlExtension().extensionId, new CoreDqlExtension().extensionId) + events.filter(_.level == "WARN") shouldBe empty + } + + it should "WARN naming ExtensionSpi and load nothing when no provider is visible to the interface's own classloader" in { + val parent = getClass.getClassLoader + // The context classloader is deliberately left as it is - one that CAN see core's registration. + // Post-fix that must not matter (AD-S5-1: a provider visible only through the TCCL is no longer + // honoured), and this WARN is what makes that narrowing visible. Pre-fix the single-arg load + // consults the TCCL and dies on `CoreDdlExtension not a subtype` of the redefined interface. + val isolating = new RedefiningClassLoader( + parent, + Seq(registryClass, spiName), + Set(servicesResource), + Array.empty[URL] + ) + // the interface now belongs to the isolating loader, which hides its registration + isolating.loadClass(spiName).getClassLoader shouldBe theSameInstanceAs(isolating) + isolating.getResources(servicesResource).hasMoreElements shouldBe false + + val (extensions, events) = + LogbackCapture.capture(registryLogger, Level.INFO)(extensionsOfFreshRegistry(isolating)) + extensions shouldBe empty + val warnings = events.filter(_.level == "WARN") + warnings should have size 1 + warnings.head.message should include(spiName) + } +} diff --git a/core/src/test/scala/app/softnetwork/elastic/client/spi/ElasticClientFactoryIsolationSpec.scala b/core/src/test/scala/app/softnetwork/elastic/client/spi/ElasticClientFactoryIsolationSpec.scala new file mode 100644 index 000000000..80efad886 --- /dev/null +++ b/core/src/test/scala/app/softnetwork/elastic/client/spi/ElasticClientFactoryIsolationSpec.scala @@ -0,0 +1,169 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.client.spi + +import app.softnetwork.elastic.client.ElasticClientApi +import app.softnetwork.elastic.licensing.ClassLoaderIsolation +import app.softnetwork.elastic.licensing.ClassLoaderIsolation.RedefiningClassLoader +import com.typesafe.config.{Config, ConfigFactory} +import org.scalatest.BeforeAndAfterAll +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.lang.reflect.InvocationTargetException +import java.net.URL +import java.nio.charset.StandardCharsets +import java.nio.file.{Files, Path} +import scala.jdk.CollectionConverters._ +import scala.util.{Failure, Try} + +/** Thrown by [[IsolationStubClientSpi]]: proof that the provider was discovered AND invoked. */ +final class IsolationStubReached + extends RuntimeException("IsolationStubClientSpi.client was invoked") + +/** Test-only `ElasticClientSpi` provider. It is registered NOWHERE on the real test classpath: the + * spec serves its `META-INF/services` entry from a synthetic directory that only the isolating + * loader can see. Every es{N} client module inherits `core % "test->test"` and + * `ElasticClientFactory` takes `headOption` of ALL providers, so a registration under + * `core/src/test/resources` would race the real client providers in the integration suites. + */ +final class IsolationStubClientSpi extends ElasticClientSpi { + override def client(conf: Config): ElasticClientApi = throw new IsolationStubReached +} + +/** #258 - `ElasticClientFactory` resolves its `ElasticClientSpi` providers against the interface's + * own classloader, never the thread context classloader. + * + * The factory's `ServiceLoader` is a `val` on an `object`, latched at class initialisation. The + * spec defines a FRESH copy of the object (and of the SPI interface, so that the interface's + * loader is the isolating one) with a [[RedefiningClassLoader]] and forces its initialiser while a + * blind context classloader is installed. Two-sided oracle: before the fix the blind loader yields + * `IllegalStateException("No ElasticClientSpi implementation found")`; after it the ONLY + * registration anywhere - the synthetic one - is found and its provider is invoked. + */ +class ElasticClientFactoryIsolationSpec extends AnyFlatSpec with Matchers with BeforeAndAfterAll { + + private val factoryObject = "app.softnetwork.elastic.client.spi.ElasticClientFactory" + private val spiName = classOf[ElasticClientSpi].getName + private val stubName = classOf[IsolationStubClientSpi].getName + private val servicesResource = "META-INF/services/" + spiName + + // Built BEFORE any blind context classloader is installed (ConfigFactory.load is TCCL-bound), and + // from the resource that carries every `elastic.*` default itself, so that this spec isolates + // DISCOVERY: ElasticConfig(config) runs before the ServiceLoader and must not be what fails here. + // ElasticConfigIsolationSpec covers ElasticConfig's own fallback under a blind TCCL. + private val config: Config = ConfigFactory.load("softnetwork-elastic.conf") + + private var servicesDir: Path = _ + private def servicesFile: Path = servicesDir.resolve(servicesResource) + + override def beforeAll(): Unit = { + servicesDir = Files.createTempDirectory("bidc5-elastic-client-spi") + Files.createDirectories(servicesFile.getParent) + Files.write(servicesFile, (stubName + "\n").getBytes(StandardCharsets.UTF_8)) + } + + override def afterAll(): Unit = + if (servicesDir != null) { + Files.deleteIfExists(servicesFile) + Files.deleteIfExists(servicesFile.getParent) // META-INF/services + Files.deleteIfExists(servicesFile.getParent.getParent) // META-INF + Files.deleteIfExists(servicesDir) + } + + behavior of "ElasticClientFactory provider discovery (#258)" + + it should "find a provider registered beside ElasticClientSpi on first touch under a blind context classloader" in { + val parent = getClass.getClassLoader + // Precondition, asserted not assumed: the REAL test classpath carries no ElasticClientSpi + // provider (the four registrations live in es6/7/8/9). That is what makes the oracle + // two-sided - the only registration anywhere is the one the isolating loader serves. + parent.getResources(servicesResource).hasMoreElements shouldBe false + + val blind = ClassLoaderIsolation.blindLoader() + blind.getResources(servicesResource).hasMoreElements shouldBe false + a[ClassNotFoundException] should be thrownBy Class.forName(spiName, false, blind) + + val isolating = new RedefiningClassLoader( + parent, + Seq(factoryObject, spiName, stubName), + Set.empty, + Array[URL](servicesDir.toUri.toURL) + ) + val spiInIsolation = isolating.loadClass(spiName) + spiInIsolation.getClassLoader shouldBe theSameInstanceAs(isolating) + spiInIsolation should not be theSameInstanceAs(classOf[ElasticClientSpi]) + isolating.getResources(servicesResource).asScala.toList should have size 1 + + val outcome = ClassLoaderIsolation.withContextClassLoader(blind) { + // the object's initialiser - and its ServiceLoader.load - runs HERE, under the blind TCCL + val factory = ClassLoaderIsolation.moduleOf(isolating, factoryObject) + try Try(factory.getClass.getMethod("create", classOf[Config]).invoke(factory, config)) + finally removeShutdownHook(factory) + } + + outcome match { + case Failure(e: InvocationTargetException) if e.getCause.isInstanceOf[IsolationStubReached] => + succeed + case Failure(e: InvocationTargetException) => + fail(s"provider NOT discovered: create() failed with ${e.getCause}", e.getCause) + case other => + fail(s"expected create() to reach the stub provider, got $other") + } + } + + it should "name the interface's classloader and the remedy when no provider is visible to it" in { + val parent = getClass.getClassLoader + // The interface belongs to the isolating loader, which hides its registration; the context + // classloader is deliberately left alone - post-fix it must not matter (AD-S5-1). + val isolating = new RedefiningClassLoader( + parent, + Seq(factoryObject, spiName), + Set(servicesResource), + Array.empty[URL] + ) + isolating.loadClass(spiName).getClassLoader shouldBe theSameInstanceAs(isolating) + isolating.getResources(servicesResource).hasMoreElements shouldBe false + + val factory = ClassLoaderIsolation.moduleOf(isolating, factoryObject) + val outcome = + try Try(factory.getClass.getMethod("create", classOf[Config]).invoke(factory, config)) + finally removeShutdownHook(factory) + + outcome match { + case Failure(e: InvocationTargetException) => + val cause = e.getCause + cause shouldBe an[IllegalStateException] + // the substring the jdbc/arrow ContextClassLoaderIsolationSpecs pin - kept, appended to + cause.getMessage should include("No ElasticClientSpi implementation found") + cause.getMessage should include(isolating.toString) + cause.getMessage should include("#258") + case other => + fail(s"expected create() to fail on an empty provider list, got $other") + } + } + + /** Every fresh copy of the factory registers its own JVM shutdown hook in its initialiser; remove + * it so a test run does not accumulate hooks in the long-lived sbt JVM. + */ + private def removeShutdownHook(factory: AnyRef): Unit = + factory.getClass + .getMethod("shutdownHook") + .invoke(factory) + .asInstanceOf[scala.sys.ShutdownHookThread] + .remove() +} diff --git a/documentation/client/common_principles.md b/documentation/client/common_principles.md index 86c393226..4027a2547 100644 --- a/documentation/client/common_principles.md +++ b/documentation/client/common_principles.md @@ -132,6 +132,28 @@ class RestHighLevelClientSpi extends ElasticClientSpi { - app.softnetwork.elastic.client.spi.RestHighLevelClientSpi (`softclient4es6-rest-client`, `softclient4es7-rest-client`) - app.softnetwork.elastic.client.spi.JavaClientSpi (`softclient4es8-java-client`, `softclient4es9-java-client`) +### Classloader resolution + +Since 0.23.0, every SPI lookup in the library (`ElasticClientSpi`, `ExtensionSpi` and the licensing +`LicenseManagerSpi`) resolves its providers against the **classloader that loaded the SPI +interface** (the one holding `softclient4es-core` and `softclient4es-licensing`), never the thread +context classloader. The client factory's built-in `elastic.*` defaults (`ElasticConfig`, read from +`softnetwork-elastic.conf`) are resolved the same way. This is what lets the library run inside +hosts that own the context classloader (Tableau, plugin containers, application servers) without a +provider silently going missing. + +Consequences: + +- Ship the client, extension and licensing jars on the **same classpath** as the core jar - a flat + classpath (`-cp core.jar:lib/*`) or a single shaded jar both qualify. +- A provider visible **only** through the thread context classloader (for example an extension jar + placed on a child classloader with the context classloader pointing at it) is no longer discovered. +- An empty provider list is reported with a **WARN naming the SPI interface** at the two sites that + used to degrade silently (`ExtensionRegistry`, `LicenseRefreshStrategyFactory`); + `ElasticClientFactory` fails loudly instead, with + `No ElasticClientSpi implementation found through : the client jar must be on the + same classpath as softclient4es-core ...`. + --- ## Client Factory diff --git a/licensing/build.sbt b/licensing/build.sbt index 6cfd4f95f..e10b08e6e 100644 --- a/licensing/build.sbt +++ b/licensing/build.sbt @@ -5,5 +5,11 @@ name := "softclient4es-licensing" libraryDependencies ++= Seq( "com.typesafe" % "config" % Versions.typesafeConfig, "com.typesafe.scala-logging" %% "scala-logging" % Versions.scalaLogging, - "org.scalatest" %% "scalatest" % Versions.scalatest % Test + "org.scalatest" %% "scalatest" % Versions.scalatest % Test, + // #258: the isolation specs capture the empty-provider-list WARN through logback's ListAppender + // (house pattern: SlicedScrollCompletenessSpec). Test scope only - the published module carries + // no logging backend. Test logging is configured by TestLoggingConfigurator (a logback + // Configurator SPI, root WARN) - deliberately NOT a logback-test.xml, which would override the + // persistence-core-testkit logback.xml on every es{N} test classpath via test->test. + "ch.qos.logback" % "logback-classic" % Versions.logback % Test ) diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseRefreshStrategyFactory.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseRefreshStrategyFactory.scala index 9c4e13415..34d34c574 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseRefreshStrategyFactory.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseRefreshStrategyFactory.scala @@ -109,7 +109,11 @@ object LicenseRefreshStrategyFactory extends LazyLogging { case Some(s) => s case None => val mode = resolveMode(config) - val loader = ServiceLoader.load(classOf[LicenseManagerSpi]) + // #258: resolve providers against the classloader that loaded this library, never the + // thread context classloader - under a host-owned blind loader a single-arg load found + // nothing and the JVM silently ran with license refresh disabled. + val spiClass = classOf[LicenseManagerSpi] + val loader = ServiceLoader.load(spiClass, spiClass.getClassLoader) val spis = loader.iterator().asScala.toSeq.sortBy(_.priority) val strategy = spis.headOption .map { spi => @@ -122,6 +126,13 @@ object LicenseRefreshStrategyFactory extends LazyLogging { s } .getOrElse { + logger.warn( + s"No ${spiClass.getName} provider found through ${spiClass.getClassLoader}: falling " + + s"back to ${classOf[NopRefreshStrategy].getSimpleName} (Community license, refresh " + + "disabled for this JVM). Providers are resolved against the classloader that loaded " + + "softclient4es-licensing, never the thread context classloader: the licensing jars " + + "must be on that same classpath (#258)." + ) val fallback = new NopRefreshStrategy() fallback.initialize() fallback diff --git a/licensing/src/test/resources/META-INF/services/ch.qos.logback.classic.spi.Configurator b/licensing/src/test/resources/META-INF/services/ch.qos.logback.classic.spi.Configurator new file mode 100644 index 000000000..0a7b74b84 --- /dev/null +++ b/licensing/src/test/resources/META-INF/services/ch.qos.logback.classic.spi.Configurator @@ -0,0 +1 @@ +app.softnetwork.elastic.licensing.TestLoggingConfigurator diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/ClassLoaderIsolation.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/ClassLoaderIsolation.scala new file mode 100644 index 000000000..1cedeb108 --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/ClassLoaderIsolation.scala @@ -0,0 +1,131 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import java.io.{ByteArrayOutputStream, InputStream} +import java.net.{URL, URLClassLoader} +import java.util.Enumeration + +/** Test-only classloader tooling for the #258 isolation specs. + * + * The three SPI lookups fixed by #258 are once-per-JVM latches: an object's static initialiser + * (`ElasticClientFactory`), a `lazy val` (`ExtensionRegistry`) and a CAS-cached strategy + * (`LicenseRefreshStrategyFactory`). In sbt's shared test JVM dozens of earlier suites fire them + * under a healthy classloader, so a spec that merely installs an isolating context classloader + * passes with or without the fix. [[RedefiningClassLoader]] restores FIRST TOUCH by construction: + * it defines the latch-holding classes afresh from their own bytecode, so the object under test + * runs its initialiser inside the spec's classloader window, whatever ran before. + * + * Lives in the `licensing` test tree because that is the lowest module with a latch; `core`'s + * specs see it through `core.dependsOn(licensing % "test->test")`. + */ +object ClassLoaderIsolation { + + /** A NON-null loader that sees only the bootstrap classes: no application class and no + * `META-INF/services` resource. This is the shape of a host-owned context classloader that + * cannot see our jars (Tableau, plugin containers, app servers). A `null` context classloader is + * deliberately never used: `ServiceLoader` and Akka both fall back to a healthy loader on + * `null`, so it does not reproduce the defect. + */ + def blindLoader(): ClassLoader = new URLClassLoader(Array.empty[URL], null) + + /** Run `body` with `loader` installed as the current thread's context classloader; the previous + * loader is restored on every exit path. + */ + def withContextClassLoader[A](loader: ClassLoader)(body: => A): A = { + val thread = Thread.currentThread() + val saved = thread.getContextClassLoader + thread.setContextClassLoader(loader) + try body + finally thread.setContextClassLoader(saved) + } + + /** The Scala `object` named `objectName`, as defined by `loader`, forcing its static initialiser + * NOW - which is where an object-level `ServiceLoader.load` runs. + */ + def moduleOf(loader: ClassLoader, objectName: String): AnyRef = + Class.forName(objectName + "$", true, loader).getField("MODULE$").get(null) + + /** The bytecode of `className` as the loader `from` sees it (its own or its parents'). */ + def classBytes(from: ClassLoader, className: String): Array[Byte] = { + val path = className.replace('.', '/') + ".class" + val in = from.getResourceAsStream(path) + if (in == null) { + throw new ClassNotFoundException(s"$className: no resource $path is visible from $from") + } + try readFully(in) + finally in.close() + } + + // InputStream.readAllBytes is JDK 9+; these modules emit JDK 8 bytecode (-target:jvm-1.8). + private def readFully(in: InputStream): Array[Byte] = { + val out = new ByteArrayOutputStream() + val buffer = new Array[Byte](8192) + var read = in.read(buffer) + while (read >= 0) { + out.write(buffer, 0, read) + read = in.read(buffer) + } + out.toByteArray + } + + /** Parent-first loader with a CHILD-DEFINED name set: the classes named in `redefine` are defined + * afresh here from the parent's bytecode, every other class is delegated to the parent. A class + * defined here is a distinct `Class` object with its own static state, so an `object` in the set + * runs its initialiser again on first use. + * + * @param parent + * the loader whose classpath supplies every class, including the bytes of the redefined ones + * @param redefine + * fully-qualified class names to define afresh; a name `X` also covers `X$` (the module class + * of an `object X`) and every `X$...` inner or synthetic class + * @param hiddenResources + * resource names this loader answers with its OWN roots only, never the parent's - how a spec + * makes an SPI interface's loader see no provider registration at all + * @param extraUrls + * additional roots searched by this loader - how a spec serves a synthetic `META-INF/services` + * registration that must never reach the real test classpath + */ + final class RedefiningClassLoader( + parent: ClassLoader, + redefine: Seq[String], + hiddenResources: Set[String], + extraUrls: Array[URL] + ) extends URLClassLoader(extraUrls, parent) { + + def redefines(name: String): Boolean = + redefine.exists(c => name == c || name.startsWith(c + "$")) + + override protected def loadClass(name: String, resolve: Boolean): Class[_] = + getClassLoadingLock(name).synchronized { + val loaded = findLoadedClass(name) + if (loaded != null) loaded + else if (redefines(name)) { + val bytes = classBytes(parent, name) + val defined = defineClass(name, bytes, 0, bytes.length) + if (resolve) resolveClass(defined) + defined + } else super.loadClass(name, resolve) + } + + override def getResources(name: String): Enumeration[URL] = + if (hiddenResources.contains(name)) findResources(name) else super.getResources(name) + + override def getResource(name: String): URL = + if (hiddenResources.contains(name)) findResource(name) else super.getResource(name) + } +} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseRefreshStrategyFactoryIsolationSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseRefreshStrategyFactoryIsolationSpec.scala new file mode 100644 index 000000000..f6443686e --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseRefreshStrategyFactoryIsolationSpec.scala @@ -0,0 +1,104 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import app.softnetwork.elastic.licensing.ClassLoaderIsolation.RedefiningClassLoader +import app.softnetwork.elastic.licensing.metrics.MetricsApi +import ch.qos.logback.classic.Level +import com.typesafe.config.{Config, ConfigFactory} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.net.URL + +/** #258 - `LicenseRefreshStrategyFactory` resolves its `LicenseManagerSpi` providers against the + * interface's own classloader, never the thread context classloader, and WARNs when it finds none. + * + * Both specs drive a FRESH copy of the factory object (its CAS cache included) defined by a + * [[RedefiningClassLoader]], so the discovery under test is the object's first touch whatever + * other suites in this JVM did before. + */ +class LicenseRefreshStrategyFactoryIsolationSpec extends AnyFlatSpec with Matchers { + + private val factoryObject = "app.softnetwork.elastic.licensing.LicenseRefreshStrategyFactory" + + // LazyLogging names the logger after the module class: "...LicenseRefreshStrategyFactory$" + private val factoryLogger = LicenseRefreshStrategyFactory.getClass.getName + private val spiName = classOf[LicenseManagerSpi].getName + private val servicesResource = "META-INF/services/" + spiName + private val initializedLine = "License strategy initialized" + + // Built BEFORE any blind context classloader is installed: ConfigFactory.load() is TCCL-bound. + private val config: Config = ConfigFactory.load() + + private def createVia(loader: ClassLoader): LicenseRefreshStrategy = { + val factory = ClassLoaderIsolation.moduleOf(loader, factoryObject) + // a distinct Class => a distinct, empty CAS cache: this IS the object's first touch + factory.getClass should not be theSameInstanceAs(LicenseRefreshStrategyFactory.getClass) + factory.getClass + .getMethod("create", classOf[Config], classOf[MetricsApi]) + .invoke(factory, config, MetricsApi.Noop) + .asInstanceOf[LicenseRefreshStrategy] + } + + behavior of "LicenseRefreshStrategyFactory provider discovery (#258)" + + it should "discover the shipped CommunityLicenseManagerSpi on first touch under a blind context classloader" in { + val parent = getClass.getClassLoader + // precondition, asserted not assumed: the licensing jar registers CommunityLicenseManagerSpi + parent.getResources(servicesResource).hasMoreElements shouldBe true + val blind = ClassLoaderIsolation.blindLoader() + blind.getResources(servicesResource).hasMoreElements shouldBe false + + val isolating = + new RedefiningClassLoader(parent, Seq(factoryObject), Set.empty, Array.empty[URL]) + val (strategy, events) = LogbackCapture.capture(factoryLogger, Level.INFO) { + ClassLoaderIsolation.withContextClassLoader(blind)(createVia(isolating)) + } + try { + // CommunityLicenseManagerSpi builds a plain NopRefreshStrategy - the SAME class as the empty + // list fallback - so the strategy class is no oracle. The SPI path is the one that logs the + // initialisation line; the fallback path is the one that WARNs. + events.map(_.message).filter(_.startsWith(initializedLine)) should have size 1 + events.filter(_.level == "WARN") shouldBe empty + strategy.licenseManager.licenseType shouldBe LicenseType.Community + } finally strategy.shutdown() + } + + it should "WARN naming LicenseManagerSpi and fall back to NopRefreshStrategy when no provider is visible to the interface's own classloader" in { + val parent = getClass.getClassLoader + val isolating = new RedefiningClassLoader( + parent, + Seq(factoryObject, spiName), + Set(servicesResource), + Array.empty[URL] + ) + // the interface now belongs to the isolating loader, which hides its registration + isolating.loadClass(spiName).getClassLoader shouldBe theSameInstanceAs(isolating) + isolating.getResources(servicesResource).hasMoreElements shouldBe false + + val (strategy, events) = LogbackCapture.capture(factoryLogger, Level.INFO)(createVia(isolating)) + try { + strategy shouldBe a[NopRefreshStrategy] + val warnings = events.filter(_.level == "WARN") + warnings should have size 1 + warnings.head.message should include(spiName) + warnings.head.message should include(classOf[NopRefreshStrategy].getSimpleName) + events.map(_.message).exists(_.startsWith(initializedLine)) shouldBe false + } finally strategy.shutdown() + } +} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LogbackCapture.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LogbackCapture.scala new file mode 100644 index 000000000..96db2c0fe --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LogbackCapture.scala @@ -0,0 +1,109 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import ch.qos.logback.classic.{Level, Logger => LogbackLogger, LoggerContext} +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import org.slf4j.LoggerFactory + +import scala.jdk.CollectionConverters._ + +/** Test-only logback capture (house pattern: `SlicedScrollCompletenessSpec.captureClientLog`). + * + * `logback-classic` is a `Test`-scoped dependency of `licensing` and `core` for exactly this: the + * #258 specs assert the WARN a factory emits when an SPI interface has no provider. Reading the + * captured list only after the appender is detached, and under the appender's lock, is what keeps + * a late log line from another thread from throwing `ConcurrentModificationException`. + */ +object LogbackCapture { + + final case class Captured(level: String, message: String) + + /** Run `body` while capturing every event logged through `loggerName` at `level` or above BY THE + * CALLING THREAD; the logger's level is raised for the window and restored afterwards. Returns + * the body's result and the captured events in order. + * + * The thread filter is what keeps an exact-count assertion honest in a module whose suites run + * in parallel (`licensing` does): the redefined objects under test log on the spec's own thread, + * while the REAL object of the same name - hence the same logger - may log from another suite's + * thread at any moment. Events from other threads are dropped. A `body` that throws propagates + * its exception and the events are discarded with the appender - wrap a throwing body in `Try` + * INSIDE `body` if its log matters. + */ + def capture[A](loggerName: String, level: Level)(body: => A): (A, Seq[Captured]) = { + val logger = logbackLogger(loggerName) + val capturingThread = Thread.currentThread().getName + // logback 1.5 stamps an event's thread name LAZILY (on the first getThreadName()), and a plain + // ListAppender only stores the event - so the thread filter below must not depend on some + // other appender (a root console appender) having forced the stamp. Prepare the event on the + // logging thread ourselves, whatever the root configuration is. + val appender = new ListAppender[ILoggingEvent]() { + override protected def append(e: ILoggingEvent): Unit = { + e.prepareForDeferredProcessing() + super.append(e) + } + } + appender.start() + val previousLevel = logger.getLevel + logger.setLevel(level) + logger.addAppender(appender) + try { + val result = body + logger.detachAppender(appender) + val events = appender.synchronized( + appender.list.asScala + .filter(_.getThreadName == capturingThread) + .map(e => Captured(e.getLevel.toString, e.getFormattedMessage)) + .toVector + ) + (result, events) + } finally { + logger.detachAppender(appender) // idempotent + appender.stop() + logger.setLevel(previousLevel) + } + } + + private val bindingWaitNanos = 10000000000L // 10 s + + /** The logback logger named `loggerName`, taken from the REAL `LoggerContext`. + * + * slf4j 2 performs its one-time binding on the first thread that asks and, while that runs, + * hands every OTHER thread a `SubstituteLogger` (measured: the licensing suites run in parallel, + * and a plain `LoggerFactory.getLogger` here returned `org.slf4j.helpers.SubstituteLogger` under + * `+ licensing/test` although it returned a logback logger when the spec ran alone). Waiting, + * bounded, for `getILoggerFactory` to become the `LoggerContext` makes the capture + * deterministic. + */ + private def logbackLogger(loggerName: String): LogbackLogger = { + val deadline = System.nanoTime() + bindingWaitNanos + var factory = LoggerFactory.getILoggerFactory + while (!factory.isInstanceOf[LoggerContext] && System.nanoTime() < deadline) { + Thread.sleep(20) + factory = LoggerFactory.getILoggerFactory + } + factory match { + case context: LoggerContext => context.getLogger(loggerName) + case other => + throw new IllegalStateException( + s"slf4j is not bound to logback (${other.getClass.getName}): " + + "logback-classic must be on the test classpath" + ) + } + } +} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/TestLoggingConfigurator.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/TestLoggingConfigurator.scala new file mode 100644 index 000000000..5fe039d82 --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/TestLoggingConfigurator.scala @@ -0,0 +1,86 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import ch.qos.logback.classic.{ClassicConstants, Level, LoggerContext} +import ch.qos.logback.classic.encoder.PatternLayoutEncoder +import ch.qos.logback.classic.spi.{Configurator, ILoggingEvent} +import ch.qos.logback.core.ConsoleAppender +import ch.qos.logback.core.spi.ContextAwareBase +import ch.qos.logback.core.util.{Loader, OptionHelper} +import org.slf4j.Logger + +/** Test-only logback fallback for the `licensing` and `core` unit-test classpaths: root at WARN, + * one console appender - applied ONLY where no logback XML configuration exists. + * + * Why a `ch.qos.logback.classic.spi.Configurator` service and not a `logback-test.xml`: this test + * tree reaches every es{N} test classpath through `core % "test->test"`, where + * `persistence-core-testkit` ships the `logback.xml` the integration suites are read by (INFO to + * STDOUT). A `logback-test.xml` here would OVERRIDE it. Measured on logback 1.5.32 + * (`ContextInitializer.autoConfig`): ServiceLoader configurators are invoked BEFORE the internal + * `DefaultJoranConfigurator` (the XML search) and `BasicConfigurator`, so a service configurator + * would override the XML just the same - unless it defers. Hence [[xmlConfigurationPresent]]: this + * configurator repeats `DefaultJoranConfigurator`'s own search and steps aside + * (`INVOKE_NEXT_IF_ANY`) whenever an XML configuration is visible, so the ES suites keep their + * `logback.xml` and the two unit-test modules (which had no logging backend before #258) get a + * quiet root instead of `BasicConfigurator`'s DEBUG console. + */ +class TestLoggingConfigurator extends ContextAwareBase with Configurator { + + override def configure(context: LoggerContext): Configurator.ExecutionStatus = + if (xmlConfigurationPresent) { + addInfo( + "TestLoggingConfigurator: a logback XML configuration is present, deferring to DefaultJoranConfigurator" + ) + Configurator.ExecutionStatus.INVOKE_NEXT_IF_ANY + } else { + val encoder = new PatternLayoutEncoder() + encoder.setContext(context) + encoder.setPattern("%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n") + encoder.start() + + val console = new ConsoleAppender[ILoggingEvent]() + console.setContext(context) + console.setName(TestLoggingConfigurator.ConsoleAppenderName) + console.setEncoder(encoder) + console.start() + + val root = context.getLogger(Logger.ROOT_LOGGER_NAME) + root.setLevel(Level.WARN) + root.addAppender(console) + + addInfo( + "TestLoggingConfigurator: no logback XML configuration found, root logger at WARN with a console appender" + ) + Configurator.ExecutionStatus.DO_NOT_INVOKE_NEXT_IF_ANY + } + + /** Mirrors `DefaultJoranConfigurator.performMultiStepConfigurationFileSearch` (logback 1.5.32): + * the `logback.configurationFile` system property, then `logback-test.xml`, then `logback.xml`, + * resolved through this class's own classloader. + */ + private[licensing] def xmlConfigurationPresent: Boolean = { + val loader = Loader.getClassLoaderOfObject(this) + OptionHelper.getSystemProperty(ClassicConstants.CONFIG_FILE_PROPERTY) != null || + Loader.getResource(ClassicConstants.TEST_AUTOCONFIG_FILE, loader) != null || + Loader.getResource(ClassicConstants.AUTOCONFIG_FILE, loader) != null + } +} + +object TestLoggingConfigurator { + val ConsoleAppenderName = "console" +} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/TestLoggingConfiguratorSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/TestLoggingConfiguratorSpec.scala new file mode 100644 index 000000000..15ff7e2cc --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/TestLoggingConfiguratorSpec.scala @@ -0,0 +1,122 @@ +/* + * Copyright 2025 SOFTNETWORK + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package app.softnetwork.elastic.licensing + +import app.softnetwork.elastic.licensing.ClassLoaderIsolation.RedefiningClassLoader +import ch.qos.logback.classic.{ClassicConstants, Level, LoggerContext} +import ch.qos.logback.classic.spi.Configurator +import ch.qos.logback.core.Context +import org.scalatest.BeforeAndAfterAll +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.slf4j.Logger + +import java.net.URL +import java.nio.charset.StandardCharsets +import java.nio.file.{Files, Path} +import java.util.ServiceLoader +import scala.jdk.CollectionConverters._ + +/** Pins the ONE property that makes [[TestLoggingConfigurator]] safe to ship in a test tree that + * every es{N} test classpath inherits: it must step aside whenever a logback XML configuration is + * visible to its own classloader (the integration suites' `logback.xml`), and configure only when + * none is. Logback 1.5 invokes service configurators BEFORE its XML search, so a configurator that + * forgot to defer would silently re-configure every integration suite's logging. + */ +class TestLoggingConfiguratorSpec extends AnyFlatSpec with Matchers with BeforeAndAfterAll { + + private val configuratorClass = classOf[TestLoggingConfigurator].getName + + private var xmlDir: Path = _ + private def xmlFile: Path = xmlDir.resolve(ClassicConstants.AUTOCONFIG_FILE) + + override def beforeAll(): Unit = { + xmlDir = Files.createTempDirectory("bidc5-logback-xml") + Files.write( + xmlFile, + "\n".getBytes(StandardCharsets.UTF_8) + ) + } + + override def afterAll(): Unit = + if (xmlDir != null) { + Files.deleteIfExists(xmlFile) + Files.deleteIfExists(xmlDir) + } + + private def assertNoXmlConfigurationOnThisClasspath(): Unit = { + // preconditions, asserted not assumed: nothing on the licensing test classpath configures logback + System.getProperty(ClassicConstants.CONFIG_FILE_PROPERTY) shouldBe null + getClass.getClassLoader.getResource(ClassicConstants.TEST_AUTOCONFIG_FILE) shouldBe null + getClass.getClassLoader.getResource(ClassicConstants.AUTOCONFIG_FILE) shouldBe null + } + + behavior of "TestLoggingConfigurator" + + it should "be discovered by logback as a Configurator service on this test classpath" in { + // Pins the META-INF/services registration itself: a typo there would degrade silently to + // BasicConfigurator (root DEBUG, console flood) with every other test still green. + ServiceLoader + .load(classOf[Configurator], getClass.getClassLoader) + .iterator() + .asScala + .exists(_.isInstanceOf[TestLoggingConfigurator]) shouldBe true + } + + it should "configure the root logger at WARN with one console appender when no XML configuration is visible" in { + assertNoXmlConfigurationOnThisClasspath() + val context = new LoggerContext() + val configurator = new TestLoggingConfigurator() + configurator.setContext(context) + + configurator.xmlConfigurationPresent shouldBe false + configurator.configure(context) shouldBe Configurator.ExecutionStatus.DO_NOT_INVOKE_NEXT_IF_ANY + + val root = context.getLogger(Logger.ROOT_LOGGER_NAME) + root.getLevel shouldBe Level.WARN + root.getAppender(TestLoggingConfigurator.ConsoleAppenderName) should not be null + root.iteratorForAppenders().next().getName shouldBe TestLoggingConfigurator.ConsoleAppenderName + } + + it should "defer to DefaultJoranConfigurator when a logback.xml is visible to its own classloader" in { + assertNoXmlConfigurationOnThisClasspath() + // A fresh copy of the configurator whose OWN classloader sees a logback.xml - the shape of every + // es{N} test classpath, where persistence-core-testkit's logback.xml is on the classpath. + val withXml = new RedefiningClassLoader( + getClass.getClassLoader, + Seq(configuratorClass), + Set.empty, + Array[URL](xmlDir.toUri.toURL) + ) + val cls = withXml.loadClass(configuratorClass) + cls.getClassLoader shouldBe theSameInstanceAs(withXml) + withXml.getResource(ClassicConstants.AUTOCONFIG_FILE) should not be null + + val context = new LoggerContext() + val configurator = cls.getConstructor().newInstance().asInstanceOf[AnyRef] + cls.getMethod("setContext", classOf[Context]).invoke(configurator, context) + cls.getMethod("xmlConfigurationPresent").invoke(configurator) shouldBe true + + val status = cls.getMethod("configure", classOf[LoggerContext]).invoke(configurator, context) + status shouldBe Configurator.ExecutionStatus.INVOKE_NEXT_IF_ANY + + // and it touched nothing: a fresh context's root keeps logback's default level and no appender + val root = context.getLogger(Logger.ROOT_LOGGER_NAME) + root.getLevel shouldBe Level.DEBUG + root.iteratorForAppenders().hasNext shouldBe false + } +}