diff --git a/core/src/main/resources/help/commands/dql/_index.json b/core/src/main/resources/help/commands/dql/_index.json index 8f11f7ed..b950d9e0 100644 --- a/core/src/main/resources/help/commands/dql/_index.json +++ b/core/src/main/resources/help/commands/dql/_index.json @@ -11,5 +11,8 @@ "show_watchers.json", "show_watcher_status.json", "show_enrich_policies.json", - "show_enrich_policy.json" + "show_enrich_policy.json", + "show_cluster_name.json", + "show_license.json", + "refresh_license.json" ] diff --git a/core/src/main/resources/help/commands/dql/refresh_license.json b/core/src/main/resources/help/commands/dql/refresh_license.json new file mode 100644 index 00000000..af9f7b31 --- /dev/null +++ b/core/src/main/resources/help/commands/dql/refresh_license.json @@ -0,0 +1,22 @@ +{ + "name": "REFRESH LICENSE", + "category": "DQL", + "shortDescription": "Force an immediate license refresh from the backend", + "syntax": ["REFRESH LICENSE"], + "description": "Forces an immediate license refresh from the backend (API key fetch). Returns previous and new tier information.", + "clauses": [], + "examples": [ + { + "title": "Refresh license", + "description": "Fetches a new JWT from the license server", + "sql": "REFRESH LICENSE" + } + ], + "notes": [ + "Requires API key configuration. Without an API key, returns an informational message." + ], + "limitations": [], + "seeAlso": ["SHOW LICENSE"], + "minVersion": null, + "aliases": [] +} diff --git a/core/src/main/resources/help/commands/dql/show_cluster_name.json b/core/src/main/resources/help/commands/dql/show_cluster_name.json new file mode 100644 index 00000000..cceee8f3 --- /dev/null +++ b/core/src/main/resources/help/commands/dql/show_cluster_name.json @@ -0,0 +1,20 @@ +{ + "name": "SHOW CLUSTER NAME", + "category": "DQL", + "shortDescription": "Display the Elasticsearch cluster name", + "syntax": ["SHOW CLUSTER NAME"], + "description": "Returns the name of the Elasticsearch cluster. Used by the federation server for schema resolution.", + "clauses": [], + "examples": [ + { + "title": "Show cluster name", + "description": "Returns a single row with the cluster name", + "sql": "SHOW CLUSTER NAME" + } + ], + "notes": ["The cluster name is cached after the first call"], + "limitations": [], + "seeAlso": ["SHOW TABLES"], + "minVersion": null, + "aliases": [] +} diff --git a/core/src/main/resources/help/commands/dql/show_license.json b/core/src/main/resources/help/commands/dql/show_license.json new file mode 100644 index 00000000..c0b639cd --- /dev/null +++ b/core/src/main/resources/help/commands/dql/show_license.json @@ -0,0 +1,22 @@ +{ + "name": "SHOW LICENSE", + "category": "DQL", + "shortDescription": "Display current license information", + "syntax": ["SHOW LICENSE"], + "description": "Returns the current license type, quota values, expiration date, and grace status.", + "clauses": [], + "examples": [ + { + "title": "Show license details", + "description": "Returns a single row with license information", + "sql": "SHOW LICENSE" + } + ], + "notes": [ + "Returns license type, quota values, expiration date, and grace status" + ], + "limitations": [], + "seeAlso": ["REFRESH LICENSE"], + "minVersion": null, + "aliases": [] +} 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 27432ecf..d69bc9fb 100644 --- a/core/src/main/scala/app/softnetwork/elastic/client/ExtensionApi.scala +++ b/core/src/main/scala/app/softnetwork/elastic/client/ExtensionApi.scala @@ -16,51 +16,14 @@ package app.softnetwork.elastic.client -import java.util.ServiceLoader - -import scala.jdk.CollectionConverters._ - -import app.softnetwork.elastic.licensing.{ - CommunityLicenseManager, - LicenseManager, - LicenseManagerSpi, - LicenseMode -} +import app.softnetwork.elastic.licensing.{LicenseManager, LicenseManagerFactory} trait ExtensionApi { self: ElasticClientApi => - /** 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. + /** License manager resolved via LicenseManagerFactory. The factory uses SPI discovery and derives + * LicenseMode from config (refreshEnabled=true -> LongRunning, else -> Driver). */ - 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()) - } + lazy val licenseManager: LicenseManager = LicenseManagerFactory.create(config) /** Extension registry (lazy loaded) */ lazy val extensionRegistry: ExtensionRegistry = diff --git a/core/src/main/scala/app/softnetwork/elastic/client/GatewayApi.scala b/core/src/main/scala/app/softnetwork/elastic/client/GatewayApi.scala index 965ae9db..ce52b5cb 100644 --- a/core/src/main/scala/app/softnetwork/elastic/client/GatewayApi.scala +++ b/core/src/main/scala/app/softnetwork/elastic/client/GatewayApi.scala @@ -17,6 +17,20 @@ package app.softnetwork.elastic.client import akka.actor.ActorSystem +import app.softnetwork.elastic.licensing.{ + CommunityLicenseManager, + Feature, + GraceStatus, + InvalidLicense, + LicenseError, + LicenseKey, + LicenseManager, + LicenseManagerFactory, + LicenseRefreshStrategy, + LicenseType, + NopRefreshStrategy, + Quota +} import app.softnetwork.elastic.client.result.{ DdlResult, DmlResult, @@ -56,8 +70,10 @@ import app.softnetwork.elastic.sql.query.{ EnrichPolicyStatement, ExecuteEnrichPolicy, Insert, + LicenseStatement, MultiSearch, PipelineStatement, + RefreshLicense, SearchStatement, SelectStatement, ShowClusterName, @@ -65,6 +81,7 @@ import app.softnetwork.elastic.sql.query.{ ShowCreateTable, ShowEnrichPolicies, ShowEnrichPolicy, + ShowLicense, ShowPipeline, ShowPipelines, ShowTable, @@ -1597,13 +1614,79 @@ class ClusterExecutor( } } +class LicenseExecutor( + strategy: LicenseRefreshStrategy +) extends Executor[LicenseStatement] { + + override def execute( + statement: LicenseStatement + )(implicit system: ActorSystem): Future[ElasticResult[QueryResult]] = + statement match { + case ShowLicense => Future.successful(executeShowLicense()) + case RefreshLicense => Future.successful(executeRefreshLicense()) + } + + private def executeShowLicense(): ElasticResult[QueryResult] = { + val mgr = strategy.licenseManager + val key = mgr.currentLicenseKey + val graceStatus = mgr.graceStatus match { + case GraceStatus.NotInGrace => "Active" + case GraceStatus.EarlyGrace(days) => + s"Expired ($days days ago, in grace period)" + case GraceStatus.MidGrace(days, remaining) => + s"Expired ($days days ago, $remaining days until Community fallback)" + } + val degradedNote = if (mgr.wasDegraded) " (degraded)" else "" + val row = ListMap[String, Any]( + "license_type" -> s"${mgr.licenseType}$degradedNote", + "max_materialized_views" -> formatQuota(mgr.quotas.maxMaterializedViews), + "max_clusters" -> formatQuota(mgr.quotas.maxClusters), + "max_result_rows" -> formatQuota(mgr.quotas.maxQueryResults), + "max_concurrent_queries" -> formatQuota(mgr.quotas.maxConcurrentQueries), + "expires_at" -> formatExpiry(key.expiresAt), + "status" -> graceStatus + ) + ElasticSuccess(QueryRows(Seq(row))) + } + + private def executeRefreshLicense(): ElasticResult[QueryResult] = { + val previousTier = strategy.licenseManager.licenseType + strategy.refresh() match { + case Right(key) => + val row = ListMap[String, Any]( + "previous_tier" -> previousTier.toString, + "new_tier" -> key.licenseType.toString, + "expires_at" -> formatExpiry(key.expiresAt), + "status" -> "Refreshed", + "message" -> "" + ) + ElasticSuccess(QueryRows(Seq(row))) + case Left(err) => + val row = ListMap[String, Any]( + "previous_tier" -> previousTier.toString, + "new_tier" -> previousTier.toString, + "expires_at" -> formatExpiry(strategy.licenseManager.currentLicenseKey.expiresAt), + "status" -> "Failed", + "message" -> err.message + ) + ElasticSuccess(QueryRows(Seq(row))) + } + } + + private def formatQuota(q: Option[Int]): String = + q.map(_.toString).getOrElse("unlimited") + private def formatExpiry(exp: Option[java.time.Instant]): String = + exp.map(_.toString).getOrElse("never") +} + class DqlRouterExecutor( searchExec: SearchExecutor, pipelineExec: PipelineExecutor, tableExec: TableExecutor, watcherExec: WatcherExecutor, policyExec: EnrichPolicyExecutor, - clusterExec: ClusterExecutor + clusterExec: ClusterExecutor, + licenseExec: LicenseExecutor ) extends Executor[DqlStatement] { override def execute( @@ -1616,6 +1699,7 @@ class DqlRouterExecutor( case w: WatcherStatement => watcherExec.execute(w) case e: EnrichPolicyStatement => policyExec.execute(e) case c: ClusterStatement => clusterExec.execute(c) + case l: LicenseStatement => licenseExec.execute(l) case _ => Future.successful( @@ -1654,6 +1738,11 @@ class DdlRouterExecutor( trait GatewayApi extends IndicesApi with ElasticClientHelpers { self: ElasticClientApi => + /** License refresh strategy. Resolved via LicenseManagerFactory (SPI + config-driven mode). + * Override in concrete implementations if custom strategy wiring is needed. + */ + def licenseRefreshStrategy: LicenseRefreshStrategy = LicenseManagerFactory.currentStrategy + lazy val searchExecutor = new SearchExecutor( api = this, logger = logger @@ -1689,13 +1778,18 @@ trait GatewayApi extends IndicesApi with ElasticClientHelpers { logger = logger ) + lazy val licenseExecutor = new LicenseExecutor( + strategy = licenseRefreshStrategy // No longer Option — NopRefreshStrategy for Community + ) + lazy val dqlExecutor = new DqlRouterExecutor( searchExec = searchExecutor, pipelineExec = pipelineExecutor, tableExec = tableExecutor, watcherExec = watcherExecutor, policyExec = policyExecutor, - clusterExec = clusterExecutor + clusterExec = clusterExecutor, + licenseExec = licenseExecutor ) lazy val ddlExecutor = new DdlRouterExecutor( diff --git a/core/src/test/scala/app/softnetwork/elastic/client/LicenseExecutorSpec.scala b/core/src/test/scala/app/softnetwork/elastic/client/LicenseExecutorSpec.scala new file mode 100644 index 00000000..4ff1c88c --- /dev/null +++ b/core/src/test/scala/app/softnetwork/elastic/client/LicenseExecutorSpec.scala @@ -0,0 +1,260 @@ +/* + * 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 akka.actor.ActorSystem +import app.softnetwork.elastic.client.result.{ElasticSuccess, QueryRows} +import app.softnetwork.elastic.licensing._ +import app.softnetwork.elastic.sql.query.{RefreshLicense, ShowLicense} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.time.Instant +import scala.collection.immutable.ListMap +import scala.concurrent.Await +import scala.concurrent.duration._ + +class LicenseExecutorSpec extends AnyFlatSpec with Matchers { + + implicit val system: ActorSystem = ActorSystem("license-executor-test") + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private def mkStrategy( + mgr: LicenseManager, + refreshResult: Either[LicenseError, LicenseKey] = Left(RefreshNotSupported) + ): LicenseRefreshStrategy = new LicenseRefreshStrategy { + override def initialize(): LicenseKey = LicenseKey.Community + override def refresh(): Either[LicenseError, LicenseKey] = refreshResult + override def licenseManager: LicenseManager = mgr + } + + private val nopStrategy = new NopRefreshStrategy() + + private def execute(executor: LicenseExecutor, stmt: ShowLicense.type): ListMap[String, Any] = { + val result = Await.result(executor.execute(stmt), 5.seconds) + result shouldBe a[ElasticSuccess[_]] + val rows = result.asInstanceOf[ElasticSuccess[QueryRows]].value.rows + rows should have size 1 + rows.head.asInstanceOf[ListMap[String, Any]] + } + + private def executeRefresh(executor: LicenseExecutor): ListMap[String, Any] = { + val result = Await.result(executor.execute(RefreshLicense), 5.seconds) + result shouldBe a[ElasticSuccess[_]] + val rows = result.asInstanceOf[ElasticSuccess[QueryRows]].value.rows + rows should have size 1 + rows.head.asInstanceOf[ListMap[String, Any]] + } + + // ------------------------------------------------------------------------- + // SHOW LICENSE + // ------------------------------------------------------------------------- + + behavior of "LicenseExecutor - SHOW LICENSE" + + it should "return Community defaults with NopRefreshStrategy" in { + val executor = new LicenseExecutor(strategy = nopStrategy) + val row = execute(executor, ShowLicense) + + row should contain key "license_type" + row("license_type") shouldBe "Community" + row should contain key "max_materialized_views" + row("max_materialized_views") shouldBe "3" + row should contain key "max_clusters" + row("max_clusters") shouldBe "2" + row should contain key "max_result_rows" + row("max_result_rows") shouldBe "10000" + row should contain key "max_concurrent_queries" + row("max_concurrent_queries") shouldBe "5" + row should contain key "expires_at" + row("expires_at") shouldBe "never" + row should contain key "status" + row("status") shouldBe "Active" + } + + it should "return strategy license info when strategy is configured" in { + val proManager = new LicenseManager { + override def validate(key: String): Either[LicenseError, LicenseKey] = + Left(InvalidLicense("test")) + override def hasFeature(feature: Feature): Boolean = true + override def quotas: Quota = Quota.Pro + override def licenseType: LicenseType = LicenseType.Pro + override def currentLicenseKey: LicenseKey = LicenseKey( + id = "test-pro", + licenseType = LicenseType.Pro, + features = Feature.values.toSet, + expiresAt = Some(Instant.parse("2026-12-31T23:59:59Z")) + ) + } + val executor = new LicenseExecutor(strategy = mkStrategy(proManager)) + val row = execute(executor, ShowLicense) + + row("license_type") shouldBe "Pro" + row("max_materialized_views") shouldBe "50" + row("max_clusters") shouldBe "5" + row("max_result_rows") shouldBe "1000000" + row("max_concurrent_queries") shouldBe "50" + row("expires_at") shouldBe "2026-12-31T23:59:59Z" + row("status") shouldBe "Active" + } + + it should "show degraded suffix when license was degraded" in { + val degradedManager = new LicenseManager { + override def validate(key: String): Either[LicenseError, LicenseKey] = + Left(InvalidLicense("test")) + 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 wasDegraded: Boolean = true + } + val executor = new LicenseExecutor(strategy = mkStrategy(degradedManager)) + val row = execute(executor, ShowLicense) + + row("license_type") shouldBe "Community (degraded)" + } + + it should "show grace status when in early grace" in { + val graceManager = new LicenseManager { + override def validate(key: String): Either[LicenseError, LicenseKey] = + Left(InvalidLicense("test")) + override def hasFeature(feature: Feature): Boolean = true + override def quotas: Quota = Quota.Pro + override def licenseType: LicenseType = LicenseType.Pro + override def graceStatus: GraceStatus = GraceStatus.EarlyGrace(3) + } + val executor = new LicenseExecutor(strategy = mkStrategy(graceManager)) + val row = execute(executor, ShowLicense) + + row("status") shouldBe "Expired (3 days ago, in grace period)" + } + + it should "show grace status when in mid grace" in { + val graceManager = new LicenseManager { + override def validate(key: String): Either[LicenseError, LicenseKey] = + Left(InvalidLicense("test")) + override def hasFeature(feature: Feature): Boolean = true + override def quotas: Quota = Quota.Pro + override def licenseType: LicenseType = LicenseType.Pro + override def graceStatus: GraceStatus = GraceStatus.MidGrace(10, 4) + } + val executor = new LicenseExecutor(strategy = mkStrategy(graceManager)) + val row = execute(executor, ShowLicense) + + row("status") shouldBe "Expired (10 days ago, 4 days until Community fallback)" + } + + // ------------------------------------------------------------------------- + // REFRESH LICENSE + // ------------------------------------------------------------------------- + + behavior of "LicenseExecutor - REFRESH LICENSE" + + it should "return failure with NopRefreshStrategy" in { + val executor = new LicenseExecutor(strategy = nopStrategy) + val row = executeRefresh(executor) + + row should contain key "previous_tier" + row("previous_tier") shouldBe "Community" + row should contain key "new_tier" + row("new_tier") shouldBe "Community" + row should contain key "expires_at" + row("expires_at") shouldBe "never" + row should contain key "status" + row("status") shouldBe "Failed" + row should contain key "message" + row("message") shouldBe "License refresh is not supported in Community mode" + } + + it should "return success result on successful refresh" in { + val proManager = new LicenseManager { + override def validate(key: String): Either[LicenseError, LicenseKey] = + Left(InvalidLicense("test")) + override def hasFeature(feature: Feature): Boolean = true + override def quotas: Quota = Quota.Pro + override def licenseType: LicenseType = LicenseType.Pro + } + val newKey = LicenseKey( + id = "refreshed-pro", + licenseType = LicenseType.Pro, + features = Feature.values.toSet, + expiresAt = Some(Instant.parse("2027-06-30T23:59:59Z")) + ) + val executor = new LicenseExecutor( + strategy = mkStrategy(proManager, refreshResult = Right(newKey)) + ) + val row = executeRefresh(executor) + + row("previous_tier") shouldBe "Pro" + row("new_tier") shouldBe "Pro" + row("expires_at") shouldBe "2027-06-30T23:59:59Z" + row("status") shouldBe "Refreshed" + row("message") shouldBe "" + } + + it should "return failure result with descriptive message when no API key" in { + val communityManager = new CommunityLicenseManager() + val executor = new LicenseExecutor( + strategy = mkStrategy( + communityManager, + refreshResult = Left( + InvalidLicense( + "No API key configured — license loaded from static JWT or Community default" + ) + ) + ) + ) + val row = executeRefresh(executor) + + row("status") shouldBe "Failed" + row("message").toString should include("No API key configured") + row("previous_tier") shouldBe "Community" + row("new_tier") shouldBe "Community" + } + + it should "return failure result on refresh error" in { + val proManager = new LicenseManager { + override def validate(key: String): Either[LicenseError, LicenseKey] = + Left(InvalidLicense("test")) + override def hasFeature(feature: Feature): Boolean = true + override def quotas: Quota = Quota.Pro + override def licenseType: LicenseType = LicenseType.Pro + override def currentLicenseKey: LicenseKey = LicenseKey( + id = "test-pro", + licenseType = LicenseType.Pro, + features = Feature.values.toSet, + expiresAt = Some(Instant.parse("2026-12-31T23:59:59Z")) + ) + } + val executor = new LicenseExecutor( + strategy = mkStrategy( + proManager, + refreshResult = Left(InvalidLicense("Network error")) + ) + ) + val row = executeRefresh(executor) + + row("status") shouldBe "Failed" + row("message").toString should include("Network error") + row("previous_tier") shouldBe "Pro" + row("new_tier") shouldBe "Pro" + row("expires_at") shouldBe "2026-12-31T23:59:59Z" + } +} diff --git a/documentation/sql/dql_statements.md b/documentation/sql/dql_statements.md index a502e463..ac92eca6 100644 --- a/documentation/sql/dql_statements.md +++ b/documentation/sql/dql_statements.md @@ -47,6 +47,8 @@ DQL supports: - [SHOW ENRICH POLICIES](#show-enrich-policies) - [SHOW ENRICH POLICY](#show-enrich-policy) - [SHOW CLUSTER NAME](#show-cluster-name) +- [SHOW LICENSE](#show-license) +- [REFRESH LICENSE](#refresh-license) --- @@ -1304,4 +1306,70 @@ SHOW CLUSTER NAME; --- +## SHOW LICENSE + +```sql +SHOW LICENSE; +``` + +Returns the current license type, quota values, expiration date, and grace status. + +**Columns returned:** + +| Column | Description | +|--------|-------------| +| `license_type` | Current license tier (Community, Pro, Enterprise). Shows "(degraded)" suffix if the license was degraded from a higher tier. | +| `max_materialized_views` | Maximum number of materialized views allowed, or "unlimited" | +| `max_clusters` | Maximum number of federated clusters allowed, or "unlimited" | +| `max_result_rows` | Maximum rows returned per query, or "unlimited" | +| `max_concurrent_queries` | Maximum concurrent queries allowed, or "unlimited" | +| `expires_at` | License expiration timestamp, or "never" for Community | +| `status` | "Active", or grace period details if expired | + +**Example:** + +```sql +SHOW LICENSE; +``` + +| license_type | max_materialized_views | max_clusters | max_result_rows | max_concurrent_queries | expires_at | status | +|---|---|---|---|---|---|---| +| Community | 3 | 2 | 10000 | 5 | never | Active | +📊 1 row(s) (1ms) + +--- + +## REFRESH LICENSE + +```sql +REFRESH LICENSE; +``` + +Forces an immediate license refresh from the backend (API key fetch). Returns the previous and new tier information. + +**Columns returned:** + +| Column | Description | +|--------|-------------| +| `previous_tier` | License tier before refresh | +| `new_tier` | License tier after refresh | +| `expires_at` | New expiration timestamp | +| `status` | "Refreshed" on success, "Failed" on error | +| `message` | Error details (empty on success) | + +**Example (no API key configured):** + +```sql +REFRESH LICENSE; +``` + +| previous_tier | new_tier | expires_at | status | message | +|---|---|---|---|---| +| Community | Community | never | Failed | License refresh is not supported in Community mode | +📊 1 row(s) (1ms) + +> **Note:** Requires API key configuration. Without an API key, returns an informational failure message. + +--- + [Back to index](README.md) diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/CommunityLicenseManagerSpi.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/CommunityLicenseManagerSpi.scala index a824c683..a9062304 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/CommunityLicenseManagerSpi.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/CommunityLicenseManagerSpi.scala @@ -24,8 +24,8 @@ import com.typesafe.config.Config */ class CommunityLicenseManagerSpi extends LicenseManagerSpi { override def priority: Int = Int.MaxValue - override def create( + override protected def buildStrategy( config: Config, - mode: Option[LicenseMode] = None - ): LicenseManager = new CommunityLicenseManager() + mode: Option[LicenseMode] + ): LicenseRefreshStrategy = new NopRefreshStrategy() } diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseManagerFactory.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseManagerFactory.scala new file mode 100644 index 00000000..de7c296a --- /dev/null +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseManagerFactory.scala @@ -0,0 +1,115 @@ +/* + * 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 +import com.typesafe.scalalogging.LazyLogging + +import java.util.ServiceLoader +import java.util.concurrent.atomic.AtomicReference +import scala.jdk.CollectionConverters._ + +/** Factory for creating and caching LicenseRefreshStrategy instances. + * + * Owns the full strategy lifecycle: SPI discovery -> build -> initialize -> cache. The LicenseMode + * is derived from configuration (`refreshEnabled=true` -> LongRunning, else -> Driver). + * + * Thread-safe: all AtomicReference mutations use compareAndSet. + */ +object LicenseManagerFactory extends LazyLogging { + + private val _strategy: AtomicReference[Option[LicenseRefreshStrategy]] = + new AtomicReference(None) + + /** Create a LicenseManager from config. Derives LicenseMode from config. Resolves the best SPI, + * builds the strategy, initializes it, and caches it. + */ + def create(config: Config): LicenseManager = + resolveStrategy(config).licenseManager + + /** Get the cached strategy (resolved during create()). Falls back to NopRefreshStrategy. */ + def currentStrategy: LicenseRefreshStrategy = + _strategy.get().getOrElse(new NopRefreshStrategy()) + + /** Replace the cached strategy. Initializes the new strategy before caching. Use for license + * upgrade/downgrade at runtime. Uses CAS loop to ensure atomic replacement. + */ + def setStrategy(strategy: LicenseRefreshStrategy): Unit = { + strategy.initialize() + var updated = false + while (!updated) { + val current = _strategy.get() + updated = _strategy.compareAndSet(current, Some(strategy)) + } + } + + /** Reset cached strategy (for testing). Uses CAS to ensure atomic clear. */ + def reset(): Unit = { + var updated = false + while (!updated) { + val current = _strategy.get() + updated = _strategy.compareAndSet(current, None) + } + } + + /** Resolve LicenseMode from config. refreshEnabled=true -> LongRunning, else -> Driver. */ + private def resolveMode(config: Config): Option[LicenseMode] = { + val licenseConfig = LicenseConfig.load(config) + if (licenseConfig.refreshEnabled) Some(LicenseMode.LongRunning) + else Some(LicenseMode.Driver) + } + + /** Resolve strategy via SPI, initialize it, and cache it. Uses CAS to ensure only one strategy is + * created in concurrent scenarios. + */ + private def resolveStrategy(config: Config): LicenseRefreshStrategy = + _strategy.get() match { + case Some(s) => s + case None => + val mode = resolveMode(config) + val loader = ServiceLoader.load(classOf[LicenseManagerSpi]) + val spis = loader.iterator().asScala.toSeq.sortBy(_.priority) + val strategy = spis.headOption + .map { spi => + try { + val s = spi.createStrategy(config, mode) + s.initialize() + logger.info( + s"License strategy initialized: ${s.getClass.getSimpleName} " + + s"(mode=${mode.getOrElse("default")}, type=${s.licenseManager.licenseType})" + ) + s + } catch { + case e: Exception => + logger.error( + s"Failed to create license strategy from ${spi.getClass.getName}: ${e.getMessage}", + e + ) + val fallback = new NopRefreshStrategy() + fallback.initialize() + fallback + } + } + .getOrElse { + val fallback = new NopRefreshStrategy() + fallback.initialize() + fallback + } + _strategy.compareAndSet(None, Some(strategy)) + _strategy.get().getOrElse(strategy) + } +} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseManagerSpi.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseManagerSpi.scala index 0156ef65..eea1e4f1 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseManagerSpi.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseManagerSpi.scala @@ -44,6 +44,10 @@ object LicenseMode { * Discovered via `java.util.ServiceLoader`. The implementation with the lowest priority value * wins. * + * Subclasses implement `buildStrategy()` (pure construction, no initialization). The default + * `create()` and `createStrategy()` delegate to `buildStrategy()`. Strategy initialization and + * caching are handled by `LicenseManagerFactory`, not by the SPI. + * * Register implementations in: * `META-INF/services/app.softnetwork.elastic.licensing.LicenseManagerSpi` */ @@ -54,7 +58,10 @@ trait LicenseManagerSpi { */ def priority: Int = 100 - /** Create a LicenseManager from application configuration. + /** Build a LicenseRefreshStrategy for this SPI. Subclasses must implement. + * + * MUST NOT call `strategy.initialize()` — lifecycle is managed by `LicenseManagerFactory`. Each + * call builds a fresh strategy (no caching). * * @param config * The application configuration (typically from `ConfigFactory.load()`) @@ -63,7 +70,26 @@ trait LicenseManagerSpi { * `Some(LongRunning)` = wire AutoRefreshStrategy. `Some(Driver)` = wire * OnDemandRefreshStrategy with checkExpiry(). * @return - * A configured LicenseManager instance with appropriate refresh behavior + * A freshly constructed (not initialized) LicenseRefreshStrategy + */ + protected def buildStrategy(config: Config, mode: Option[LicenseMode]): LicenseRefreshStrategy + + /** Create a LicenseManager by building a strategy and returning its licenseManager. + * + * Note: the strategy is NOT initialized here — callers who need full lifecycle management should + * use `LicenseManagerFactory.create()` instead. + */ + def create(config: Config, mode: Option[LicenseMode] = None): LicenseManager = + buildStrategy(config, mode).licenseManager + + /** Create a LicenseRefreshStrategy directly (not initialized, not cached). + * + * Returns the full strategy object, enabling `LicenseManagerFactory` to manage its lifecycle + * (initialize, cache, replace). */ - def create(config: Config, mode: Option[LicenseMode] = None): LicenseManager + def createStrategy( + config: Config, + mode: Option[LicenseMode] = None + ): LicenseRefreshStrategy = + buildStrategy(config, mode) } diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseRefreshStrategy.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseRefreshStrategy.scala new file mode 100644 index 00000000..47b43acc --- /dev/null +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseRefreshStrategy.scala @@ -0,0 +1,39 @@ +/* + * 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 + +/** Strategy for license lifecycle management (initialization and refresh). + * + * Concrete implementations: + * - `OnDemandRefreshStrategy` (Driver mode): single fetch at startup, checkExpiry on access, no + * implicit network calls + * - `AutoRefreshStrategy` (LongRunning mode): periodic background refresh via scheduler (Story + * 5.8) + */ +trait LicenseRefreshStrategy { + + /** Called once at startup to resolve the initial license. Always succeeds (worst case: Community + * default). + */ + def initialize(): LicenseKey + + /** Called by REFRESH LICENSE command to force an explicit re-fetch. */ + def refresh(): Either[LicenseError, LicenseKey] + + /** Access the current LicenseManager for SHOW LICENSE and feature/quota checks. */ + def licenseManager: LicenseManager +} diff --git a/licensing/src/main/scala/app/softnetwork/elastic/licensing/NopRefreshStrategy.scala b/licensing/src/main/scala/app/softnetwork/elastic/licensing/NopRefreshStrategy.scala new file mode 100644 index 00000000..d15d0b6b --- /dev/null +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/NopRefreshStrategy.scala @@ -0,0 +1,33 @@ +/* + * 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 + +/** No-op refresh strategy for Community mode. + * + * Always returns Community defaults. Used as the fallback when no extensions JAR is on the + * classpath or when no LicenseManagerSpi provides a real strategy. + */ +class NopRefreshStrategy extends LicenseRefreshStrategy { + + private val manager: LicenseManager = new CommunityLicenseManager() + + override def initialize(): LicenseKey = LicenseKey.Community + + override def refresh(): Either[LicenseError, LicenseKey] = Left(RefreshNotSupported) + + override def licenseManager: LicenseManager = manager +} 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 cf029102..d79c977e 100644 --- a/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala +++ b/licensing/src/main/scala/app/softnetwork/elastic/licensing/package.scala @@ -170,6 +170,11 @@ package object licensing { * for stub implementations). */ def refresh(): Either[LicenseError, LicenseKey] = Left(RefreshNotSupported) + + /** Get the current LicenseKey (needed by SHOW LICENSE for expiresAt). Default returns + * Community. + */ + def currentLicenseKey: LicenseKey = LicenseKey.Community } sealed trait LicenseError { diff --git a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpiSpec.scala b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpiSpec.scala index ef231dea..0371c846 100644 --- a/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpiSpec.scala +++ b/licensing/src/test/scala/app/softnetwork/elastic/licensing/LicenseManagerSpiSpec.scala @@ -44,7 +44,6 @@ class LicenseManagerSpiSpec extends AnyFlatSpec with Matchers { // 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)) @@ -74,11 +73,13 @@ class LicenseManagerSpiSpec extends AnyFlatSpec with Matchers { "SPI resolution" should "pick the lowest-priority SPI" in { val lowPriority = new LicenseManagerSpi { override def priority: Int = 10 - override def create( + override protected def buildStrategy( config: Config, mode: Option[LicenseMode] - ): LicenseManager = new CommunityLicenseManager() { - override def licenseType: LicenseType = LicenseType.Pro + ): LicenseRefreshStrategy = new NopRefreshStrategy() { + override def licenseManager: LicenseManager = new CommunityLicenseManager() { + override def licenseType: LicenseType = LicenseType.Pro + } } } val community = new CommunityLicenseManagerSpi() @@ -96,10 +97,10 @@ class LicenseManagerSpiSpec extends AnyFlatSpec with Matchers { it should "fall back to CommunityLicenseManager when winning SPI throws" in { val broken = new LicenseManagerSpi { override def priority: Int = 1 - override def create( + override protected def buildStrategy( config: Config, mode: Option[LicenseMode] - ): LicenseManager = throw new RuntimeException("boom") + ): LicenseRefreshStrategy = throw new RuntimeException("boom") } val mgr = resolveManager(Seq(broken)) @@ -111,12 +112,12 @@ class LicenseManagerSpiSpec extends AnyFlatSpec with Matchers { var receivedMode: Option[LicenseMode] = None val spy = new LicenseManagerSpi { override def priority: Int = 1 - override def create( + override protected def buildStrategy( config: Config, mode: Option[LicenseMode] - ): LicenseManager = { + ): LicenseRefreshStrategy = { receivedMode = mode - new CommunityLicenseManager() + new NopRefreshStrategy() } } diff --git a/sql/src/main/scala/app/softnetwork/elastic/sql/parser/Parser.scala b/sql/src/main/scala/app/softnetwork/elastic/sql/parser/Parser.scala index f7e72d90..c684e445 100644 --- a/sql/src/main/scala/app/softnetwork/elastic/sql/parser/Parser.scala +++ b/sql/src/main/scala/app/softnetwork/elastic/sql/parser/Parser.scala @@ -954,6 +954,16 @@ object Parser ShowClusterName } + def showLicense: PackratParser[ShowLicense.type] = + (keyword("SHOW") ~ keyword("LICENSE")) ^^ { _ => + ShowLicense + } + + def refreshLicense: PackratParser[RefreshLicense.type] = + (keyword("REFRESH") ~ keyword("LICENSE")) ^^ { _ => + RefreshLicense + } + def dqlStatement: PackratParser[DqlStatement] = { searchStatement | showTables | @@ -973,7 +983,9 @@ object Parser showWatcherStatus | showEnrichPolicy | showEnrichPolicies | - showClusterName + showLicense | + showClusterName | + refreshLicense } def ddlStatement: PackratParser[DdlStatement] = diff --git a/sql/src/main/scala/app/softnetwork/elastic/sql/query/package.scala b/sql/src/main/scala/app/softnetwork/elastic/sql/query/package.scala index 9b6b0248..72519b59 100644 --- a/sql/src/main/scala/app/softnetwork/elastic/sql/query/package.scala +++ b/sql/src/main/scala/app/softnetwork/elastic/sql/query/package.scala @@ -1294,4 +1294,18 @@ package object query { case object ShowClusterName extends ClusterStatement with DqlStatement { override def sql: String = "SHOW CLUSTER NAME" } + + // ======================================================================== + // License statements + // ======================================================================== + + sealed trait LicenseStatement extends Statement with DqlStatement + + case object ShowLicense extends LicenseStatement { + override def sql: String = "SHOW LICENSE" + } + + case object RefreshLicense extends LicenseStatement { + override def sql: String = "REFRESH LICENSE" + } } diff --git a/sql/src/test/scala/app/softnetwork/elastic/sql/parser/ParserSpec.scala b/sql/src/test/scala/app/softnetwork/elastic/sql/parser/ParserSpec.scala index b49f872b..10d9efc0 100644 --- a/sql/src/test/scala/app/softnetwork/elastic/sql/parser/ParserSpec.scala +++ b/sql/src/test/scala/app/softnetwork/elastic/sql/parser/ParserSpec.scala @@ -3079,4 +3079,44 @@ class ParserSpec extends AnyFlatSpec with Matchers { result.isLeft shouldBe true } + behavior of "Parser License" + + it should "parse SHOW LICENSE" in { + val result = Parser("SHOW LICENSE") + result.isRight shouldBe true + result.toOption.get shouldBe ShowLicense + result.toOption.get.sql shouldBe "SHOW LICENSE" + } + + it should "parse show license (lowercase)" in { + val result = Parser("show license") + result.isRight shouldBe true + result.toOption.get shouldBe ShowLicense + } + + it should "parse Show License (mixed case)" in { + val result = Parser("Show License") + result.isRight shouldBe true + result.toOption.get shouldBe ShowLicense + } + + it should "parse REFRESH LICENSE" in { + val result = Parser("REFRESH LICENSE") + result.isRight shouldBe true + result.toOption.get shouldBe RefreshLicense + result.toOption.get.sql shouldBe "REFRESH LICENSE" + } + + it should "parse refresh license (lowercase)" in { + val result = Parser("refresh license") + result.isRight shouldBe true + result.toOption.get shouldBe RefreshLicense + } + + it should "parse Refresh License (mixed case)" in { + val result = Parser("Refresh License") + result.isRight shouldBe true + result.toOption.get shouldBe RefreshLicense + } + } diff --git a/testkit/src/main/scala/app/softnetwork/elastic/client/repl/ReplGatewayIntegrationSpec.scala b/testkit/src/main/scala/app/softnetwork/elastic/client/repl/ReplGatewayIntegrationSpec.scala index 24a0d879..008794fc 100644 --- a/testkit/src/main/scala/app/softnetwork/elastic/client/repl/ReplGatewayIntegrationSpec.scala +++ b/testkit/src/main/scala/app/softnetwork/elastic/client/repl/ReplGatewayIntegrationSpec.scala @@ -1425,4 +1425,38 @@ trait ReplGatewayIntegrationSpec extends ReplIntegrationTestKit { rows.head should contain key "name" rows.head("name") shouldBe "docker-cluster" } + + // ========================================================================= + // 11. SHOW LICENSE / REFRESH LICENSE tests + // ========================================================================= + + behavior of "REPL - SHOW LICENSE / REFRESH LICENSE" + + it should "return Community license info using SHOW LICENSE" in { + val rows = assertQueryRows(System.nanoTime(), executeSync("SHOW LICENSE")) + rows should have size 1 + val row = rows.head + row should contain key "license_type" + row("license_type") shouldBe "Community" + row should contain key "max_materialized_views" + row should contain key "max_result_rows" + row should contain key "max_concurrent_queries" + row should contain key "max_clusters" + row should contain key "expires_at" + row("expires_at") shouldBe "never" + row should contain key "status" + } + + it should "return failure result using REFRESH LICENSE without strategy" in { + val rows = assertQueryRows(System.nanoTime(), executeSync("REFRESH LICENSE")) + rows should have size 1 + val row = rows.head + row should contain key "status" + row("status") shouldBe "Failed" + row should contain key "message" + row should contain key "previous_tier" + row("previous_tier") shouldBe "Community" + row should contain key "new_tier" + row should contain key "expires_at" + } }