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 build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions licensing/build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
app.softnetwork.elastic.licensing.CommunityLicenseManagerSpi
22 changes: 22 additions & 0 deletions licensing/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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()
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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
)
}
}
Loading
Loading