diff --git a/build.sbt b/build.sbt index dab63ba6..e88ae69e 100644 --- a/build.sbt +++ b/build.sbt @@ -103,9 +103,12 @@ Test / parallelExecution := false lazy val licensing = project .in(file("licensing")) .configs(IntegrationTest) + .enablePlugins(BuildInfoPlugin) .settings( Defaults.itSettings, - moduleSettings + app.softnetwork.Info.infoSettings, + moduleSettings, + buildInfoObject := "LicensingBuildInfo" ) lazy val sql = project diff --git a/core/src/main/scala/app/softnetwork/elastic/client/ExtensionApi.scala b/core/src/main/scala/app/softnetwork/elastic/client/ExtensionApi.scala index 56b857e9..27432ecf 100644 --- a/core/src/main/scala/app/softnetwork/elastic/client/ExtensionApi.scala +++ b/core/src/main/scala/app/softnetwork/elastic/client/ExtensionApi.scala @@ -16,13 +16,53 @@ package app.softnetwork.elastic.client -import app.softnetwork.elastic.licensing.{DefaultLicenseManager, LicenseManager} +import java.util.ServiceLoader + +import scala.jdk.CollectionConverters._ + +import app.softnetwork.elastic.licensing.{ + CommunityLicenseManager, + LicenseManager, + LicenseManagerSpi, + LicenseMode +} trait ExtensionApi { self: ElasticClientApi => - // ✅ Inject license manager (overridable) - def licenseManager: LicenseManager = new DefaultLicenseManager() + /** Runtime context for licensing behavior. Override in concrete implementations: + * - REPL/CLI: `Some(LicenseMode.LongRunning)` — auto-refresh + * - JDBC/ADBC: `Some(LicenseMode.Driver)` — on-demand, no implicit calls + * - Default: `None` — safe default (Driver semantics) + */ + def licenseMode: Option[LicenseMode] = None + + /** License manager resolved via SPI (highest-priority LicenseManagerSpi wins). The licenseMode is + * passed to the SPI to wire the appropriate refresh strategy. Falls back to + * CommunityLicenseManager if no SPI implementation is found or if the winning SPI fails to + * create a manager. + * + * CONSTRAINT: SPI create() must NOT reference this ElasticClientApi instance. + */ + lazy val licenseManager: LicenseManager = { + val loader = ServiceLoader.load(classOf[LicenseManagerSpi]) + val spis = loader.iterator().asScala.toSeq.sortBy(_.priority) + spis.headOption + .flatMap { spi => + try { + Some(spi.create(config, licenseMode)) + } catch { + case e: Exception => + logger.error( + s"Failed to create LicenseManager from ${spi.getClass.getName}: ${e.getMessage}", + e + ) + None + } + } + .getOrElse(new CommunityLicenseManager()) + } /** Extension registry (lazy loaded) */ - lazy val extensionRegistry: ExtensionRegistry = new ExtensionRegistry(config, licenseManager) + lazy val extensionRegistry: ExtensionRegistry = + new ExtensionRegistry(config, licenseManager) } diff --git a/core/src/main/scala/app/softnetwork/elastic/client/extensions/CoreDdlExtension.scala b/core/src/main/scala/app/softnetwork/elastic/client/extensions/CoreDdlExtension.scala index 33a59092..2241f999 100644 --- a/core/src/main/scala/app/softnetwork/elastic/client/extensions/CoreDdlExtension.scala +++ b/core/src/main/scala/app/softnetwork/elastic/client/extensions/CoreDdlExtension.scala @@ -31,7 +31,7 @@ import scala.concurrent.{ExecutionContext, Future} * {{{ * ✅ OSS (always loaded) * ✅ Handles simple DDL (CREATE TABLE, DROP TABLE, etc.) - * ✅ Does NOT handle materialized views (premium extension) + * ✅ Does NOT handle materialized views (community extension) * }}} */ //format:on diff --git a/licensing/build.sbt b/licensing/build.sbt index 59ed8497..6cfd4f95 100644 --- a/licensing/build.sbt +++ b/licensing/build.sbt @@ -2,3 +2,8 @@ organization := "app.softnetwork.elastic" name := "softclient4es-licensing" +libraryDependencies ++= Seq( + "com.typesafe" % "config" % Versions.typesafeConfig, + "com.typesafe.scala-logging" %% "scala-logging" % Versions.scalaLogging, + "org.scalatest" %% "scalatest" % Versions.scalatest % Test +) diff --git a/licensing/src/main/resources/META-INF/services/app.softnetwork.elastic.licensing.LicenseManagerSpi b/licensing/src/main/resources/META-INF/services/app.softnetwork.elastic.licensing.LicenseManagerSpi new file mode 100644 index 00000000..5f701f17 --- /dev/null +++ b/licensing/src/main/resources/META-INF/services/app.softnetwork.elastic.licensing.LicenseManagerSpi @@ -0,0 +1 @@ +app.softnetwork.elastic.licensing.CommunityLicenseManagerSpi diff --git a/licensing/src/main/resources/reference.conf b/licensing/src/main/resources/reference.conf new file mode 100644 index 00000000..bef6d662 --- /dev/null +++ b/licensing/src/main/resources/reference.conf @@ -0,0 +1,22 @@ +softclient4es { + license { + key = "" + key = ${?SOFTCLIENT4ES_LICENSE_KEY} + api-key = "" + api-key = ${?SOFTCLIENT4ES_API_KEY} + api-url = "https://license.softclient4es.com" + + refresh { + enabled = true + interval = 24h + } + + telemetry { + enabled = true + } + + grace-period = 14d + + cache-dir = ${user.home}/.softclient4es + } +} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/CommunityLicenseManager.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/CommunityLicenseManager.scala new file mode 100644 index 00000000..c4a2c359 --- /dev/null +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/CommunityLicenseManager.scala @@ -0,0 +1,40 @@ +/* + * 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 com.typesafe.scalalogging.LazyLogging + +/** Community-tier license manager. No crypto dependencies, no key validation. Always returns + * Community features and quotas. Used as the fallback when no extensions JAR is on the classpath. + */ +class CommunityLicenseManager extends LicenseManager with LazyLogging { + + override def validate(key: String): Either[LicenseError, LicenseKey] = + Left(InvalidLicense("Community mode — license key validation requires the extensions JAR")) + + override def hasFeature(feature: Feature): Boolean = + LicenseKey.Community.features.contains(feature) + + override def quotas: Quota = Quota.Community + + override def licenseType: LicenseType = LicenseType.Community + + override def refresh(): Either[LicenseError, LicenseKey] = { + logger.debug("Community mode — refresh not supported") + Left(RefreshNotSupported) + } +} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/CommunityLicenseManagerSpi.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/CommunityLicenseManagerSpi.scala new file mode 100644 index 00000000..a824c683 --- /dev/null +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/CommunityLicenseManagerSpi.scala @@ -0,0 +1,31 @@ +/* + * 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 com.typesafe.config.Config + +/** Fallback SPI that provides Community-tier licensing with no external dependencies. Priority + * Int.MaxValue ensures any other SPI implementation takes precedence. Ignores licenseMode — + * Community is always Community regardless of runtime context. + */ +class CommunityLicenseManagerSpi extends LicenseManagerSpi { + override def priority: Int = Int.MaxValue + override def create( + config: Config, + mode: Option[LicenseMode] = None + ): LicenseManager = new CommunityLicenseManager() +} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/DefaultLicenseManager.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/DefaultLicenseManager.scala deleted file mode 100644 index 348086ab..00000000 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/DefaultLicenseManager.scala +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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 - -class DefaultLicenseManager extends LicenseManager { - - private var currentLicense: LicenseKey = LicenseKey( - id = "community", - licenseType = LicenseType.Community, - features = Set( - Feature.MaterializedViews, - Feature.JdbcDriver - ), - expiresAt = None - ) - - override def validate(key: String): Either[LicenseError, LicenseKey] = { - key match { - case k if k.startsWith("PRO-") => - val license = LicenseKey( - id = k, - licenseType = LicenseType.Pro, - features = Set( - Feature.MaterializedViews, - Feature.JdbcDriver, - Feature.UnlimitedResults - ), - expiresAt = None - ) - currentLicense = license - Right(license) - - case k if k.startsWith("ENT-") => - val license = LicenseKey( - id = k, - licenseType = LicenseType.Enterprise, - features = Set( - Feature.MaterializedViews, - Feature.JdbcDriver, - Feature.OdbcDriver, - Feature.UnlimitedResults - ), - expiresAt = None - ) - currentLicense = license - Right(license) - - case _ => - Left(InvalidLicense("Invalid license key format")) - } - } - - override def hasFeature(feature: Feature): Boolean = { - currentLicense.features.contains(feature) - } - - override def quotas: Quota = currentLicense.licenseType match { - case LicenseType.Community => Quota.Community - case LicenseType.Pro => Quota.Pro - case LicenseType.Enterprise => Quota.Enterprise - } - - override def licenseType: LicenseType = currentLicense.licenseType -} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseConfig.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseConfig.scala new file mode 100644 index 00000000..b45dda42 --- /dev/null +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseConfig.scala @@ -0,0 +1,69 @@ +/* + * 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 com.typesafe.config.{Config, ConfigFactory} + +import scala.concurrent.duration._ + +case class LicenseConfig( + key: Option[String], + apiKey: Option[String], + apiUrl: String, + refreshEnabled: Boolean, + refreshInterval: FiniteDuration, + telemetryEnabled: Boolean, + gracePeriod: FiniteDuration, + cacheDir: String +) + +object LicenseConfig { + + private def nonBlank(s: String): Option[String] = + Option(s).map(_.trim).filter(_.nonEmpty) + + def load(): LicenseConfig = load(ConfigFactory.load()) + + def load(config: Config): LicenseConfig = { + val license = config.getConfig("softclient4es.license") + + val key = nonBlank(license.getString("key")) + val apiKey = nonBlank(license.getString("api-key")) + + val refreshEnabled = license.getBoolean("refresh.enabled") + val refreshInterval = license.getDuration("refresh.interval").toMillis.millis + + val telemetryEnabled = license.getBoolean("telemetry.enabled") + + val gracePeriod = license.getDuration("grace-period").toMillis.millis + + val apiUrl = license.getString("api-url") + + val cacheDir = license.getString("cache-dir") + + LicenseConfig( + key = key, + apiKey = apiKey, + apiUrl = apiUrl, + refreshEnabled = refreshEnabled, + refreshInterval = refreshInterval, + telemetryEnabled = telemetryEnabled, + gracePeriod = gracePeriod, + cacheDir = cacheDir + ) + } +} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseManagerSpi.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseManagerSpi.scala new file mode 100644 index 00000000..0156ef65 --- /dev/null +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseManagerSpi.scala @@ -0,0 +1,69 @@ +/* + * 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 com.typesafe.config.Config + +/** Runtime context that determines licensing behavior. + * + * The LicenseManagerSpi uses this to wire the appropriate refresh strategy: + * - LongRunning: AutoRefreshStrategy (periodic background refresh) + * - Driver: OnDemandRefreshStrategy (single fetch, checkExpiry on access, no implicit calls) + */ +sealed trait LicenseMode + +object LicenseMode { + + /** Long-running process: REPL, Arrow Flight SQL server, Federation server. License refreshes + * automatically in the background. + */ + case object LongRunning extends LicenseMode + + /** Short-lived driver session: JDBC, ADBC. License fetched once at connect time, never refreshed + * implicitly. Expired JWTs silently fall back to Community tier via checkExpiry(). + */ + case object Driver extends LicenseMode +} + +/** Service Provider Interface for pluggable license manager implementations. + * + * Discovered via `java.util.ServiceLoader`. The implementation with the lowest priority value + * wins. + * + * Register implementations in: + * `META-INF/services/app.softnetwork.elastic.licensing.LicenseManagerSpi` + */ +trait LicenseManagerSpi { + + /** Priority for SPI resolution (lower = higher priority). Default: 100. + * CommunityLicenseManagerSpi uses Int.MaxValue (lowest priority). + */ + def priority: Int = 100 + + /** Create a LicenseManager from application configuration. + * + * @param config + * The application configuration (typically from `ConfigFactory.load()`) + * @param mode + * Runtime context hint. `None` = unknown context (safe default: Driver semantics). + * `Some(LongRunning)` = wire AutoRefreshStrategy. `Some(Driver)` = wire + * OnDemandRefreshStrategy with checkExpiry(). + * @return + * A configured LicenseManager instance with appropriate refresh behavior + */ + def create(config: Config, mode: Option[LicenseMode] = None): LicenseManager +} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala index e67c2caa..cf029102 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala @@ -22,6 +22,11 @@ package object licensing { def isPaid: Boolean = this != LicenseType.Community def isEnterprise: Boolean = this == LicenseType.Enterprise def isPro: Boolean = this == LicenseType.Pro + def ordinal: Int = this match { + case LicenseType.Community => 0 + case LicenseType.Pro => 1 + case LicenseType.Enterprise => 2 + } } object LicenseType { @@ -33,6 +38,12 @@ package object licensing { case Pro => Enterprise case Enterprise => Enterprise } + + def fromString(s: String): LicenseType = s.trim.toLowerCase match { + case "pro" => Pro + case "enterprise" => Enterprise + case _ => Community + } } sealed trait Feature @@ -43,13 +54,38 @@ package object licensing { case object OdbcDriver extends Feature case object UnlimitedResults extends Feature case object AdvancedAggregations extends Feature + case object FlightSql extends Feature + case object Federation extends Feature def values: Seq[Feature] = Seq( MaterializedViews, JdbcDriver, OdbcDriver, UnlimitedResults, - AdvancedAggregations + AdvancedAggregations, + FlightSql, + Federation ) + + def fromString(s: String): Option[Feature] = s.trim.toLowerCase match { + case "materialized_views" => Some(MaterializedViews) + case "jdbc_driver" => Some(JdbcDriver) + case "odbc_driver" => Some(OdbcDriver) + case "unlimited_results" => Some(UnlimitedResults) + case "advanced_aggregations" => Some(AdvancedAggregations) + case "flight_sql" => Some(FlightSql) + case "federation" => Some(Federation) + case _ => None + } + + def toSnakeCase(f: Feature): String = f match { + case MaterializedViews => "materialized_views" + case JdbcDriver => "jdbc_driver" + case OdbcDriver => "odbc_driver" + case UnlimitedResults => "unlimited_results" + case AdvancedAggregations => "advanced_aggregations" + case FlightSql => "flight_sql" + case Federation => "federation" + } } case class LicenseKey( @@ -60,32 +96,52 @@ package object licensing { metadata: Map[String, String] = Map.empty ) + object LicenseKey { + val Community: LicenseKey = LicenseKey( + id = "community", + licenseType = LicenseType.Community, + features = Set(Feature.MaterializedViews, Feature.JdbcDriver), + expiresAt = None + ) + } + case class Quota( maxMaterializedViews: Option[Int], // None = unlimited maxQueryResults: Option[Int], // None = unlimited - maxConcurrentQueries: Option[Int] + maxConcurrentQueries: Option[Int], + maxClusters: Option[Int] = Some(2) // None = unlimited ) object Quota { val Community: Quota = Quota( maxMaterializedViews = Some(3), maxQueryResults = Some(10000), - maxConcurrentQueries = Some(5) + maxConcurrentQueries = Some(5), + maxClusters = Some(2) ) val Pro: Quota = Quota( maxMaterializedViews = Some(50), maxQueryResults = Some(1000000), - maxConcurrentQueries = Some(50) + maxConcurrentQueries = Some(50), + maxClusters = Some(5) ) val Enterprise: Quota = Quota( maxMaterializedViews = None, // Unlimited maxQueryResults = None, - maxConcurrentQueries = None + maxConcurrentQueries = None, + maxClusters = None ) } + sealed trait GraceStatus + object GraceStatus { + case object NotInGrace extends GraceStatus + case class EarlyGrace(daysExpired: Long) extends GraceStatus + case class MidGrace(daysExpired: Long, daysRemaining: Long) extends GraceStatus + } + trait LicenseManager { /** Validate license key */ @@ -99,6 +155,21 @@ package object licensing { /** Get license type */ def licenseType: LicenseType + + /** Get current grace status */ + def graceStatus: GraceStatus = GraceStatus.NotInGrace + + /** Whether the license was degraded to Community due to expiry/failure */ + def wasDegraded: Boolean = false + + /** Log a warning if the license is in mid-grace period (per-request use) */ + def warnIfInGrace(): Unit = () + + /** Refresh the license (re-resolve from backend/cache/config). Returns Right(LicenseKey) on + * success, Left(LicenseError) on failure. Default: returns Right(LicenseKey.Community) (no-op + * for stub implementations). + */ + def refresh(): Either[LicenseError, LicenseKey] = Left(RefreshNotSupported) } sealed trait LicenseError { @@ -122,4 +193,12 @@ package object licensing { def message: String = s"Quota exceeded: $quota ($current/$max)" } + case object RefreshNotSupported extends LicenseError { + def message: String = "License refresh is not supported in Community mode" + override def statusCode: Int = 501 + } + + @deprecated("Use CommunityLicenseManager", "0.20.0") + type DefaultLicenseManager = CommunityLicenseManager + } diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/FeatureFromStringSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/FeatureFromStringSpec.scala new file mode 100644 index 00000000..ff61d516 --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/FeatureFromStringSpec.scala @@ -0,0 +1,70 @@ +/* + * 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 org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class FeatureFromStringSpec extends AnyFlatSpec with Matchers { + + "Feature.fromString" should "map materialized_views" in { + Feature.fromString("materialized_views") shouldBe Some(Feature.MaterializedViews) + } + + it should "map jdbc_driver" in { + Feature.fromString("jdbc_driver") shouldBe Some(Feature.JdbcDriver) + } + + it should "map odbc_driver" in { + Feature.fromString("odbc_driver") shouldBe Some(Feature.OdbcDriver) + } + + it should "map unlimited_results" in { + Feature.fromString("unlimited_results") shouldBe Some(Feature.UnlimitedResults) + } + + it should "map advanced_aggregations" in { + Feature.fromString("advanced_aggregations") shouldBe Some(Feature.AdvancedAggregations) + } + + it should "map flight_sql" in { + Feature.fromString("flight_sql") shouldBe Some(Feature.FlightSql) + } + + it should "map federation" in { + Feature.fromString("federation") shouldBe Some(Feature.Federation) + } + + it should "return None for unknown string" in { + Feature.fromString("warp_drive") shouldBe None + } + + it should "be case-insensitive" in { + Feature.fromString("MATERIALIZED_VIEWS") shouldBe Some(Feature.MaterializedViews) + Feature.fromString("Flight_Sql") shouldBe Some(Feature.FlightSql) + } + + it should "handle whitespace" in { + Feature.fromString(" federation ") shouldBe Some(Feature.Federation) + } + + "Feature.toSnakeCase round-trip" should "work for all features" in { + Feature.values.foreach { f => + Feature.fromString(Feature.toSnakeCase(f)) shouldBe Some(f) + } + } +} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/FeatureSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/FeatureSpec.scala new file mode 100644 index 00000000..c5104296 --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/FeatureSpec.scala @@ -0,0 +1,52 @@ +/* + * 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 org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class FeatureSpec extends AnyFlatSpec with Matchers { + + "Feature.FlightSql" should "exist as a case object" in { + Feature.FlightSql shouldBe a[Feature] + } + + "Feature.Federation" should "exist as a case object" in { + Feature.Federation shouldBe a[Feature] + } + + "Feature.values" should "contain all 7 features" in { + Feature.values should have size 7 + } + + it should "contain FlightSql and Federation" in { + Feature.values should contain(Feature.FlightSql) + Feature.values should contain(Feature.Federation) + } + + it should "preserve insertion order" in { + Feature.values shouldBe Seq( + Feature.MaterializedViews, + Feature.JdbcDriver, + Feature.OdbcDriver, + Feature.UnlimitedResults, + Feature.AdvancedAggregations, + Feature.FlightSql, + Feature.Federation + ) + } +} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseConfigSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseConfigSpec.scala new file mode 100644 index 00000000..bf44e11a --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseConfigSpec.scala @@ -0,0 +1,127 @@ +/* + * 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 com.typesafe.config.ConfigFactory +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import scala.concurrent.duration._ + +class LicenseConfigSpec extends AnyFlatSpec with Matchers { + + private def configFrom(hocon: String): LicenseConfig = + LicenseConfig.load( + ConfigFactory + .parseString(hocon) + .withFallback(ConfigFactory.load()) + ) + + "reference.conf defaults" should "load without explicit HOCON override" in { + val cfg = LicenseConfig.load(ConfigFactory.load()) + cfg.key shouldBe None + cfg.apiKey shouldBe None + cfg.refreshEnabled shouldBe true + cfg.refreshInterval shouldBe 24.hours + cfg.telemetryEnabled shouldBe true + cfg.gracePeriod shouldBe 14.days + cfg.apiUrl shouldBe "https://license.softclient4es.com" + cfg.cacheDir should include(".softclient4es") + } + + "HOCON with key" should "extract static JWT" in { + val cfg = configFrom("""softclient4es.license.key = "eyJhbGciOiJFZERTQSJ9.test" """) + cfg.key shouldBe Some("eyJhbGciOiJFZERTQSJ9.test") + } + + "HOCON with api-key" should "extract API key" in { + val cfg = configFrom("""softclient4es.license.api-key = "sk-abc123" """) + cfg.apiKey shouldBe Some("sk-abc123") + } + + "HOCON with neither key nor api-key" should "return None for both" in { + val cfg = configFrom("") // just reference.conf defaults + cfg.key shouldBe None + cfg.apiKey shouldBe None + } + + "HOCON with empty string key" should "treat as absent (blank-as-absent)" in { + val cfg = configFrom("""softclient4es.license.key = "" """) + cfg.key shouldBe None + } + + "HOCON with whitespace-only api-key" should "treat as absent (blank-as-absent)" in { + val cfg = configFrom("""softclient4es.license.api-key = " " """) + cfg.apiKey shouldBe None + } + + "custom refresh settings" should "be parsed correctly" in { + val cfg = configFrom(""" + softclient4es.license.refresh { + enabled = false + interval = 12h + } + """) + cfg.refreshEnabled shouldBe false + cfg.refreshInterval shouldBe 12.hours + } + + "custom telemetry setting" should "be parsed correctly" in { + val cfg = configFrom("""softclient4es.license.telemetry.enabled = false""") + cfg.telemetryEnabled shouldBe false + } + + "custom grace-period" should "be parsed correctly" in { + val cfg = configFrom("""softclient4es.license.grace-period = 7d""") + cfg.gracePeriod shouldBe 7.days + } + + "custom api-url" should "be parsed correctly" in { + val cfg = configFrom("""softclient4es.license.api-url = "https://custom.example.com" """) + cfg.apiUrl shouldBe "https://custom.example.com" + } + + "custom cache-dir" should "be parsed correctly" in { + val cfg = configFrom("""softclient4es.license.cache-dir = "/tmp/test-cache" """) + cfg.cacheDir shouldBe "/tmp/test-cache" + } + + "all custom settings" should "be parsed together" in { + val cfg = configFrom(""" + softclient4es.license { + key = "eyJ.test.jwt" + api-key = "sk-custom" + refresh { + enabled = false + interval = 6h + } + telemetry.enabled = false + grace-period = 30d + api-url = "https://staging.license.softclient4es.com" + cache-dir = "/opt/licenses" + } + """) + cfg.key shouldBe Some("eyJ.test.jwt") + cfg.apiKey shouldBe Some("sk-custom") + cfg.apiUrl shouldBe "https://staging.license.softclient4es.com" + cfg.refreshEnabled shouldBe false + cfg.refreshInterval shouldBe 6.hours + cfg.telemetryEnabled shouldBe false + cfg.gracePeriod shouldBe 30.days + cfg.cacheDir shouldBe "/opt/licenses" + } +} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeySpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeySpec.scala new file mode 100644 index 00000000..e5527319 --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseKeySpec.scala @@ -0,0 +1,87 @@ +/* + * 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 org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class LicenseKeySpec extends AnyFlatSpec with Matchers { + + "LicenseKey" should "support FlightSql in features set" in { + val key = LicenseKey( + id = "test-key", + licenseType = LicenseType.Pro, + features = Set(Feature.FlightSql), + expiresAt = None + ) + key.features should contain(Feature.FlightSql) + } + + it should "support Federation in features set" in { + val key = LicenseKey( + id = "test-key", + licenseType = LicenseType.Enterprise, + features = Set(Feature.Federation), + expiresAt = None + ) + key.features should contain(Feature.Federation) + } + + it should "support all features combined" in { + val key = LicenseKey( + id = "test-key", + licenseType = LicenseType.Enterprise, + features = Feature.values.toSet, + expiresAt = None + ) + key.features should have size 7 + key.features should contain(Feature.MaterializedViews) + key.features should contain(Feature.JdbcDriver) + key.features should contain(Feature.OdbcDriver) + key.features should contain(Feature.UnlimitedResults) + key.features should contain(Feature.AdvancedAggregations) + key.features should contain(Feature.FlightSql) + key.features should contain(Feature.Federation) + } + + it should "store JWT metadata claims" in { + val key = LicenseKey( + id = "jwt-key", + licenseType = LicenseType.Pro, + features = Set(Feature.FlightSql), + expiresAt = None, + metadata = Map( + "org_name" -> "Acme Corp", + "jti" -> "abc-123", + "trial" -> "true" + ) + ) + key.metadata("org_name") shouldBe "Acme Corp" + key.metadata("jti") shouldBe "abc-123" + key.metadata("trial") shouldBe "true" + } + + it should "default to empty metadata" in { + val key = LicenseKey( + id = "test-key", + licenseType = LicenseType.Community, + features = Set.empty, + expiresAt = None + ) + key.metadata shouldBe empty + } +} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpec.scala new file mode 100644 index 00000000..8cb34b66 --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpec.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 org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class LicenseManagerSpec extends AnyFlatSpec with Matchers { + + "CommunityLicenseManager" should "include MaterializedViews" in { + val manager = new CommunityLicenseManager + manager.hasFeature(Feature.MaterializedViews) shouldBe true + } + + it should "include JdbcDriver" in { + val manager = new CommunityLicenseManager + manager.hasFeature(Feature.JdbcDriver) shouldBe true + } + + it should "not include FlightSql" in { + val manager = new CommunityLicenseManager + manager.hasFeature(Feature.FlightSql) shouldBe false + } + + it should "not include Federation" in { + val manager = new CommunityLicenseManager + manager.hasFeature(Feature.Federation) shouldBe false + } + + it should "not include OdbcDriver" in { + val manager = new CommunityLicenseManager + manager.hasFeature(Feature.OdbcDriver) shouldBe false + } + + it should "not include UnlimitedResults" in { + val manager = new CommunityLicenseManager + manager.hasFeature(Feature.UnlimitedResults) shouldBe false + } + + it should "not include AdvancedAggregations" in { + val manager = new CommunityLicenseManager + manager.hasFeature(Feature.AdvancedAggregations) shouldBe false + } + + it should "return Community quotas" in { + val manager = new CommunityLicenseManager + manager.quotas shouldBe Quota.Community + } + + it should "always be Community type" in { + val manager = new CommunityLicenseManager + manager.licenseType shouldBe LicenseType.Community + } + + it should "reject any key validation" in { + val manager = new CommunityLicenseManager + manager.validate("PRO-test-key") shouldBe a[Left[_, _]] + manager.validate("ENT-test-key") shouldBe a[Left[_, _]] + manager.validate("anything") shouldBe a[Left[_, _]] + // State remains Community after rejected validation + manager.licenseType shouldBe LicenseType.Community + manager.quotas shouldBe Quota.Community + } + + it should "return Left(RefreshNotSupported) on refresh" in { + val manager = new CommunityLicenseManager + manager.refresh() shouldBe Left(RefreshNotSupported) + } + + "LicenseManager trait" should "be source-compatible" in { + val manager: LicenseManager = new CommunityLicenseManager + manager.licenseType shouldBe LicenseType.Community + manager.quotas shouldBe Quota.Community + } + + it should "default refresh to Left(RefreshNotSupported)" in { + val stub = new LicenseManager { + def validate(key: String): Either[LicenseError, LicenseKey] = Left(InvalidLicense("stub")) + def hasFeature(feature: Feature): Boolean = false + def quotas: Quota = Quota.Community + def licenseType: LicenseType = LicenseType.Community + } + stub.refresh() shouldBe Left(RefreshNotSupported) + } + + "DefaultLicenseManager" should "be a deprecated alias for CommunityLicenseManager" in { + val manager: DefaultLicenseManager = new CommunityLicenseManager + manager shouldBe a[CommunityLicenseManager] + } +} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpiSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpiSpec.scala new file mode 100644 index 00000000..ef231dea --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpiSpec.scala @@ -0,0 +1,132 @@ +/* + * 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.util.ServiceLoader + +import scala.jdk.CollectionConverters._ + +import com.typesafe.config.{Config, ConfigFactory} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class LicenseManagerSpiSpec extends AnyFlatSpec with Matchers { + + "ServiceLoader" should "discover CommunityLicenseManagerSpi" in { + val loader = ServiceLoader.load(classOf[LicenseManagerSpi]) + val spis = loader.iterator().asScala.toSeq + spis should not be empty + spis.exists(_.isInstanceOf[CommunityLicenseManagerSpi]) shouldBe true + } + + "CommunityLicenseManagerSpi" should "have Int.MaxValue priority" in { + new CommunityLicenseManagerSpi().priority shouldBe Int.MaxValue + } + + it should "create a CommunityLicenseManager regardless of mode" in { + val config = ConfigFactory.load() + val spi = new CommunityLicenseManagerSpi() + + // No mode + val mgr1 = spi.create(config) + mgr1.licenseType shouldBe LicenseType.Community + mgr1.refresh() shouldBe Left(RefreshNotSupported) + + // LongRunning mode — still Community + val mgr2 = spi.create(config, Some(LicenseMode.LongRunning)) + mgr2.licenseType shouldBe LicenseType.Community + + // Driver mode — still Community + val mgr3 = spi.create(config, Some(LicenseMode.Driver)) + mgr3.licenseType shouldBe LicenseType.Community + } + + /** Mirrors the SPI resolution logic in ExtensionApi.licenseManager */ + private def resolveManager( + spis: Seq[LicenseManagerSpi], + mode: Option[LicenseMode] = None + ): LicenseManager = { + val config = ConfigFactory.load() + spis + .sortBy(_.priority) + .headOption + .flatMap { spi => + try { Some(spi.create(config, mode)) } + catch { case _: Exception => None } + } + .getOrElse(new CommunityLicenseManager()) + } + + "SPI resolution" should "pick the lowest-priority SPI" in { + val lowPriority = new LicenseManagerSpi { + override def priority: Int = 10 + override def create( + config: Config, + mode: Option[LicenseMode] + ): LicenseManager = new CommunityLicenseManager() { + override def licenseType: LicenseType = LicenseType.Pro + } + } + val community = new CommunityLicenseManagerSpi() + + val mgr = resolveManager(Seq(community, lowPriority)) + mgr.licenseType shouldBe LicenseType.Pro + } + + it should "fall back to CommunityLicenseManager when SPI list is empty" in { + val mgr = resolveManager(Seq.empty) + mgr shouldBe a[CommunityLicenseManager] + mgr.licenseType shouldBe LicenseType.Community + } + + it should "fall back to CommunityLicenseManager when winning SPI throws" in { + val broken = new LicenseManagerSpi { + override def priority: Int = 1 + override def create( + config: Config, + mode: Option[LicenseMode] + ): LicenseManager = throw new RuntimeException("boom") + } + + val mgr = resolveManager(Seq(broken)) + mgr shouldBe a[CommunityLicenseManager] + mgr.licenseType shouldBe LicenseType.Community + } + + it should "pass licenseMode to the winning SPI" in { + var receivedMode: Option[LicenseMode] = None + val spy = new LicenseManagerSpi { + override def priority: Int = 1 + override def create( + config: Config, + mode: Option[LicenseMode] + ): LicenseManager = { + receivedMode = mode + new CommunityLicenseManager() + } + } + + resolveManager(Seq(spy), Some(LicenseMode.LongRunning)) + receivedMode shouldBe Some(LicenseMode.LongRunning) + + resolveManager(Seq(spy), Some(LicenseMode.Driver)) + receivedMode shouldBe Some(LicenseMode.Driver) + + resolveManager(Seq(spy), None) + receivedMode shouldBe None + } +} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseTypeFromStringSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseTypeFromStringSpec.scala new file mode 100644 index 00000000..b381b29a --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseTypeFromStringSpec.scala @@ -0,0 +1,48 @@ +/* + * 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 org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class LicenseTypeFromStringSpec extends AnyFlatSpec with Matchers { + + "LicenseType.fromString" should "map community" in { + LicenseType.fromString("community") shouldBe LicenseType.Community + } + + it should "map pro" in { + LicenseType.fromString("pro") shouldBe LicenseType.Pro + } + + it should "map enterprise" in { + LicenseType.fromString("enterprise") shouldBe LicenseType.Enterprise + } + + it should "default to Community for unknown string" in { + LicenseType.fromString("platinum") shouldBe LicenseType.Community + } + + it should "be case-insensitive" in { + LicenseType.fromString("PRO") shouldBe LicenseType.Pro + LicenseType.fromString("Enterprise") shouldBe LicenseType.Enterprise + } + + it should "handle whitespace" in { + LicenseType.fromString(" pro ") shouldBe LicenseType.Pro + } +} diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/QuotaSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/QuotaSpec.scala new file mode 100644 index 00000000..2b2a5bd3 --- /dev/null +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/QuotaSpec.scala @@ -0,0 +1,80 @@ +/* + * 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 org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class QuotaSpec extends AnyFlatSpec with Matchers { + + "Quota.Community" should "have maxClusters = Some(2)" in { + Quota.Community.maxClusters shouldBe Some(2) + } + + it should "have maxMaterializedViews = Some(3)" in { + Quota.Community.maxMaterializedViews shouldBe Some(3) + } + + it should "have maxQueryResults = Some(10000)" in { + Quota.Community.maxQueryResults shouldBe Some(10000) + } + + it should "have maxConcurrentQueries = Some(5)" in { + Quota.Community.maxConcurrentQueries shouldBe Some(5) + } + + "Quota.Pro" should "have maxClusters = Some(5)" in { + Quota.Pro.maxClusters shouldBe Some(5) + } + + it should "have maxMaterializedViews = Some(50)" in { + Quota.Pro.maxMaterializedViews shouldBe Some(50) + } + + it should "have maxQueryResults = Some(1000000)" in { + Quota.Pro.maxQueryResults shouldBe Some(1000000) + } + + it should "have maxConcurrentQueries = Some(50)" in { + Quota.Pro.maxConcurrentQueries shouldBe Some(50) + } + + "Quota.Enterprise" should "have maxClusters = None (unlimited)" in { + Quota.Enterprise.maxClusters shouldBe None + } + + it should "have maxMaterializedViews = None (unlimited)" in { + Quota.Enterprise.maxMaterializedViews shouldBe None + } + + it should "have maxQueryResults = None (unlimited)" in { + Quota.Enterprise.maxQueryResults shouldBe None + } + + it should "have maxConcurrentQueries = None (unlimited)" in { + Quota.Enterprise.maxConcurrentQueries shouldBe None + } + + "Quota default constructor" should "use maxClusters = Some(2)" in { + val quota = Quota( + maxMaterializedViews = Some(10), + maxQueryResults = Some(100), + maxConcurrentQueries = Some(1) + ) + quota.maxClusters shouldBe Some(2) + } +}