Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion core/src/main/resources/help/commands/dql/_index.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
22 changes: 22 additions & 0 deletions core/src/main/resources/help/commands/dql/refresh_license.json
Original file line number Diff line number Diff line change
@@ -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": []
}
20 changes: 20 additions & 0 deletions core/src/main/resources/help/commands/dql/show_cluster_name.json
Original file line number Diff line number Diff line change
@@ -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": []
}
22 changes: 22 additions & 0 deletions core/src/main/resources/help/commands/dql/show_license.json
Original file line number Diff line number Diff line change
@@ -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": []
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -56,15 +70,18 @@ import app.softnetwork.elastic.sql.query.{
EnrichPolicyStatement,
ExecuteEnrichPolicy,
Insert,
LicenseStatement,
MultiSearch,
PipelineStatement,
RefreshLicense,
SearchStatement,
SelectStatement,
ShowClusterName,
ShowCreatePipeline,
ShowCreateTable,
ShowEnrichPolicies,
ShowEnrichPolicy,
ShowLicense,
ShowPipeline,
ShowPipelines,
ShowTable,
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading