diff --git a/experiments/common-wasm/.gitignore b/experiments/common-wasm/.gitignore new file mode 100644 index 0000000000..bddd1888af --- /dev/null +++ b/experiments/common-wasm/.gitignore @@ -0,0 +1,2 @@ +/.bsp/ +target/ diff --git a/experiments/common-wasm/build.sbt b/experiments/common-wasm/build.sbt new file mode 100644 index 0000000000..531229b433 --- /dev/null +++ b/experiments/common-wasm/build.sbt @@ -0,0 +1,45 @@ +import Dependencies.munit + +ThisBuild / scalaVersion := "2.12.16" +ThisBuild / version := "1.0.0-SNAPSHOT" +ThisBuild / organization := "io.otoroshi.common" +ThisBuild / organizationName := "wasm" + +lazy val playJsonVersion = "2.9.3" +lazy val playWsVersion = "2.8.19" +lazy val akkaVersion = "2.6.20" +lazy val akkaHttpVersion = "10.2.10" +lazy val metricsVersion = "4.2.12" +lazy val excludesJackson = Seq( + ExclusionRule(organization = "com.fasterxml.jackson.core"), + ExclusionRule(organization = "com.fasterxml.jackson.datatype"), + ExclusionRule(organization = "com.fasterxml.jackson.dataformat") +) + +scalacOptions ++= Seq( + "-feature", + "-language:higherKinds", + "-language:implicitConversions", + "-language:existentials", + "-language:postfixOps" +) + +lazy val root = (project in file(".")) + .settings( + name := "common-wasm", + libraryDependencies ++= Seq( + munit % Test, + "com.typesafe.play" %% "play-ws" % playWsVersion, + "com.typesafe.play" %% "play-json" % playJsonVersion, + "com.typesafe.akka" %% "akka-stream" % akkaVersion, + "com.typesafe.akka" %% "akka-http" % akkaHttpVersion, + "com.typesafe.play" %% "play-json-joda" % playJsonVersion, + "com.github.blemale" %% "scaffeine" % "4.0.2", + "com.jayway.jsonpath" % "json-path" % "2.7.0", + "commons-lang" % "commons-lang" % "2.6", + "commons-codec" % "commons-codec" % "1.16.0", + "net.java.dev.jna" % "jna" % "5.13.0", + "com.google.code.gson" % "gson" % "2.10", + "io.dropwizard.metrics" % "metrics-json" % metricsVersion excludeAll (excludesJackson: _*), // Apache 2.0 + ) + ) diff --git a/experiments/common-wasm/lib/extism-v0.4.0.jar b/experiments/common-wasm/lib/extism-v0.4.0.jar new file mode 100644 index 0000000000..997bcce9ee Binary files /dev/null and b/experiments/common-wasm/lib/extism-v0.4.0.jar differ diff --git a/experiments/common-wasm/project/Dependencies.scala b/experiments/common-wasm/project/Dependencies.scala new file mode 100644 index 0000000000..1edb07a723 --- /dev/null +++ b/experiments/common-wasm/project/Dependencies.scala @@ -0,0 +1,5 @@ +import sbt._ + +object Dependencies { + lazy val munit = "org.scalameta" %% "munit" % "0.7.29" +} diff --git a/experiments/common-wasm/project/build.properties b/experiments/common-wasm/project/build.properties new file mode 100644 index 0000000000..563a014da4 --- /dev/null +++ b/experiments/common-wasm/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.7.2 diff --git a/experiments/common-wasm/src/main/resources/.gitignore b/experiments/common-wasm/src/main/resources/.gitignore new file mode 100644 index 0000000000..5ddda1cc8b --- /dev/null +++ b/experiments/common-wasm/src/main/resources/.gitignore @@ -0,0 +1,3 @@ +/darwin-* +/linux-* +/native \ No newline at end of file diff --git a/experiments/common-wasm/src/main/scala/io/otoroshi/common/utils/syntax.scala b/experiments/common-wasm/src/main/scala/io/otoroshi/common/utils/syntax.scala new file mode 100644 index 0000000000..a198a3e2ac --- /dev/null +++ b/experiments/common-wasm/src/main/scala/io/otoroshi/common/utils/syntax.scala @@ -0,0 +1,385 @@ +package io.otoroshi.common.utils + +import akka.NotUsed +import akka.http.scaladsl.util.FastFuture +import akka.stream.scaladsl.Source +import akka.util.ByteString +import org.apache.commons.codec.binary.{Base64, Hex} +import play.api.Logger +import play.api.libs.json._ + +import java.io.ByteArrayInputStream +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.cert.{CertificateFactory, X509Certificate} +import scala.collection.TraversableOnce +import scala.collection.concurrent.TrieMap +import scala.concurrent.{ExecutionContext, Future, Promise} +import scala.util.{Failure, Success, Try} + +private[common] object implicits { + + implicit class BetterSyntax[A](private val obj: A) extends AnyVal { + def seq: Seq[A] = Seq(obj) + + def set: Set[A] = Set(obj) + + def list: List[A] = List(obj) + + def some: Option[A] = Some(obj) + + def none: Option[A] = None + + def option: Option[A] = Some(obj) + + def left[B]: Either[A, B] = Left(obj) + + def right[B]: Either[B, A] = Right(obj) + + @inline + def vfuture: Future[A] = { + // Future.successful(obj) + FastFuture.successful(obj) + } + + @inline + def stdFuture: Future[A] = Future.successful(obj) + + @inline + def future: Future[A] = FastFuture.successful(obj) + + def asFuture: Future[A] = FastFuture.successful(obj) + + def toFuture: Future[A] = FastFuture.successful(obj) + + def somef: Future[Option[A]] = FastFuture.successful(Some(obj)) + + def leftf[B]: Future[Either[A, B]] = FastFuture.successful(Left(obj)) + + def rightf[B]: Future[Either[B, A]] = FastFuture.successful(Right(obj)) + + def debug(f: A => Any): A = { + f(obj) + obj + } + + def debugPrintln: A = { + println(obj) + obj + } + + def debugPrintlnWithPrefix(prefix: String): A = { + println(prefix + " - " + obj) + obj + } + + def debugLogger(logger: Logger): A = { + if (logger.isDebugEnabled) logger.debug(s"$obj") + obj + } + + def applyOn[B](f: A => B): B = f(obj) + + def applyOnIf(predicate: => Boolean)(f: A => A): A = if (predicate) f(obj) else obj + + def applyOnWithOpt[B](opt: => Option[B])(f: (A, B) => A): A = if (opt.isDefined) f(obj, opt.get) else obj + + def applyOnWithPredicate(predicate: A => Boolean)(f: A => A): A = if (predicate(obj)) f(obj) else obj + + def seffectOn(f: A => Unit): A = { + f(obj) + obj + } + + def seffectOnIf(predicate: => Boolean)(f: A => Unit): A = { + if (predicate) { + f(obj) + obj + } else obj + } + + def seffectOnWithPredicate(predicate: A => Boolean)(f: A => Unit): A = { + if (predicate(obj)) { + f(obj) + obj + } else obj + } + + def singleSource: Source[A, NotUsed] = Source.single(obj) + } + + implicit class BetterString(private val obj: String) extends AnyVal { + + def byteString: ByteString = ByteString(obj) + + def bytes: Array[Byte] = obj.getBytes(StandardCharsets.UTF_8) + + def json: JsValue = JsString(obj) + + def parseJson: JsValue = Json.parse(obj) + + def encodeBase64: String = Base64.encodeBase64String(obj.getBytes(StandardCharsets.UTF_8)) + + def base64: String = Base64.encodeBase64String(obj.getBytes(StandardCharsets.UTF_8)) + + def base64UrlSafe: String = Base64.encodeBase64URLSafeString(obj.getBytes(StandardCharsets.UTF_8)) + + def fromBase64: String = new String(Base64.decodeBase64(obj), StandardCharsets.UTF_8) + + def decodeBase64: String = new String(Base64.decodeBase64(obj), StandardCharsets.UTF_8) + + def sha256: String = + Hex.encodeHexString(MessageDigest.getInstance("SHA-256").digest(obj.getBytes(StandardCharsets.UTF_8))) + + def sha512: String = + Hex.encodeHexString(MessageDigest.getInstance("SHA-512").digest(obj.getBytes(StandardCharsets.UTF_8))) + + def chunks(size: Int): Source[String, NotUsed] = Source(obj.grouped(size).toList) + + def camelToSnake: String = { + obj.replaceAll("([a-z])([A-Z]+)", "$1_$2").toLowerCase + // obj.replaceAll("([A-Z]+)([A-Z][a-z])", "$1_$2").replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase + } + } + + implicit class BetterByteString(private val obj: ByteString) extends AnyVal { + + def chunks(size: Int): Source[ByteString, NotUsed] = Source(obj.grouped(size).toList) + + def sha256: String = Hex.encodeHexString(MessageDigest.getInstance("SHA-256").digest(obj.toArray)) + + def sha512: String = Hex.encodeHexString(MessageDigest.getInstance("SHA-512").digest(obj.toArray)) + } + + implicit class BetterBoolean(private val obj: Boolean) extends AnyVal { + def json: JsValue = JsBoolean(obj) + } + + implicit class BetterDouble(private val obj: Double) extends AnyVal { + def json: JsValue = JsNumber(obj) + } + + implicit class BetterInt(private val obj: Int) extends AnyVal { + def json: JsValue = JsNumber(obj) + + def bytes: Array[Byte] = { + Array[Byte]( + ((obj >> 24) & 0xff).asInstanceOf[Byte], + ((obj >> 16) & 0xff).asInstanceOf[Byte], + ((obj >> 8) & 0xff).asInstanceOf[Byte], + ((obj >> 0) & 0xff).asInstanceOf[Byte] + ) + } + } + + implicit class BetterLong(private val obj: Long) extends AnyVal { + def json: JsValue = JsNumber(obj) + + def bytes: Array[Byte] = { + Array[Byte]( + ((obj >> 56) & 0xff).asInstanceOf[Byte], + ((obj >> 48) & 0xff).asInstanceOf[Byte], + ((obj >> 40) & 0xff).asInstanceOf[Byte], + ((obj >> 32) & 0xff).asInstanceOf[Byte], + ((obj >> 24) & 0xff).asInstanceOf[Byte], + ((obj >> 16) & 0xff).asInstanceOf[Byte], + ((obj >> 8) & 0xff).asInstanceOf[Byte], + ((obj >> 0) & 0xff).asInstanceOf[Byte] + ) + } + } + + implicit class BetterJsValue(private val obj: JsValue) extends AnyVal { + + def stringify: String = Json.stringify(obj) + + def prettify: String = Json.prettyPrint(obj) + + def select(name: String): JsLookupResult = obj \ name + + def select(index: Int): JsLookupResult = obj \ index + + def at(path: String): JsLookupResult = { + val parts = path.split("\\.").toSeq + parts.foldLeft(Option(obj)) { + case (Some(source: JsObject), part) => (source \ part).asOpt[JsValue] + case (Some(source: JsArray), part) => (source \ part.toInt).asOpt[JsValue] + case (Some(value), part) => None + case (None, _) => None + } match { + case None => JsUndefined(s"path '${path}' does not exists") + case Some(value) => JsDefined(value) + } + } + + def atPointer(path: String): JsLookupResult = { + val parts = path.split("/").toSeq.filterNot(_.trim.isEmpty) + parts.foldLeft(Option(obj)) { + case (Some(source: JsObject), part) => (source \ part).asOpt[JsValue] + case (Some(source: JsArray), part) => (source \ part.toInt).asOpt[JsValue] + case (Some(value), part) => None + case (None, _) => None + } match { + case None => JsUndefined(s"path '${path}' does not exists") + case Some(value) => JsDefined(value) + } + } + } + + implicit class BetterJsValueOption(private val obj: Option[JsValue]) extends AnyVal { + def orJsNull: JsValue = obj.getOrElse(JsNull) + } + + implicit class BetterJsLookupResult(private val obj: JsLookupResult) extends AnyVal { + def select(name: String): JsLookupResult = obj \ name + + def select(index: Int): JsLookupResult = obj \ index + + def strConvert(): Option[String] = { + obj.asOpt[JsValue].getOrElse(JsNull) match { + case JsNull => "null".some + case JsNumber(v) => v.toString().some + case JsString(v) => v.some + case JsBoolean(v) => v.toString.some + case o@JsObject(_) => o.stringify.some + case a@JsArray(_) => a.stringify.some + case _ => None + } + } + } + + implicit class BetterJsReadable(private val obj: JsReadable) extends AnyVal { + def asString: String = obj.as[String] + + def asInt: Int = obj.as[Int] + + def asDouble: Double = obj.as[Double] + + def asLong: Long = obj.as[Long] + + def asBoolean: Boolean = obj.as[Boolean] + + def asObject: JsObject = obj.as[JsObject] + + def asArray: JsArray = obj.as[JsArray] + + def asValue: JsValue = obj.as[JsValue] + + def asOptString: Option[String] = obj.asOpt[String] + + def asOptBoolean: Option[Boolean] = obj.asOpt[Boolean] + + def asOptInt: Option[Int] = obj.asOpt[Int] + + def asOptLong: Option[Long] = obj.asOpt[Long] + } + + implicit class BetterFuture[A](private val obj: Future[A]) extends AnyVal { + + def fleft[B](implicit ec: ExecutionContext): Future[Either[A, B]] = obj.map(v => Left(v)) + + def fright[B](implicit ec: ExecutionContext): Future[Either[B, A]] = obj.map(v => Right(v)) + + def asLeft[R](implicit executor: ExecutionContext): Future[Either[A, R]] = obj.map(a => Left[A, R](a)) + + def asRight[R](implicit executor: ExecutionContext): Future[Either[R, A]] = obj.map(a => Right[R, A](a)) + + def fold[U](pf: PartialFunction[Try[A], U])(implicit executor: ExecutionContext): Future[U] = { + val promise = Promise[U] + obj.andThen { + case underlying: Try[A] => { + try { + promise.trySuccess(pf(underlying)) + } catch { + case e: Throwable => promise.tryFailure(e) + } + } + } + promise.future + } + + def foldM[U](pf: PartialFunction[Try[A], Future[U]])(implicit executor: ExecutionContext): Future[U] = { + val promise = Promise[U] + obj.andThen { + case underlying: Try[A] => { + try { + pf(underlying).andThen { + case Success(v) => promise.trySuccess(v) + case Failure(e) => promise.tryFailure(e) + } + } catch { + case e: Throwable => promise.tryFailure(e) + } + } + } + promise.future + } + } + + implicit class BetterMapOfStringAndB[B](val theMap: Map[String, B]) extends AnyVal { + def addAll(other: Map[String, B]): Map[String, B] = theMap.++(other) + + def put(key: String, value: B): Map[String, B] = theMap.+((key, value)) + + def put(tuple: (String, B)): Map[String, B] = theMap.+(tuple) + + def remove(key: String): Map[String, B] = theMap.-(key) + + def removeIgnoreCase(key: String): Map[String, B] = theMap.-(key).-(key.toLowerCase()) + + def containsIgnoreCase(key: String): Boolean = theMap.contains(key) || theMap.contains(key.toLowerCase()) + + def getIgnoreCase(key: String): Option[B] = theMap.get(key).orElse(theMap.get(key.toLowerCase())) + + def removeAndPutIgnoreCase(tuple: (String, B)): Map[String, B] = removeIgnoreCase(tuple._1).put(tuple) + } + + implicit class BetterTrieMapOfStringAndB[B](val theMap: TrieMap[String, B]) extends AnyVal { + def add(tuple: (String, B)): TrieMap[String, B] = theMap.+=(tuple) + + def addAll(all: TraversableOnce[(String, B)]): TrieMap[String, B] = theMap.++=(all) + + def rem(key: String): TrieMap[String, B] = theMap.-=(key) + + def remIgnoreCase(key: String): TrieMap[String, B] = theMap.-=(key).-=(key.toLowerCase()) + + def remAll(keys: TraversableOnce[String]): TrieMap[String, B] = theMap.--=(keys) + + def remAllIgnoreCase(keys: TraversableOnce[String]): TrieMap[String, B] = + theMap.--=(keys).--=(keys.map(_.toLowerCase())) + + def containsIgnoreCase(key: String): Boolean = theMap.contains(key) || theMap.contains(key.toLowerCase()) + + def getIgnoreCase(key: String): Option[B] = theMap.get(key).orElse(theMap.get(key.toLowerCase())) + + def remAndAddIgnoreCase(tuple: (String, B)): TrieMap[String, B] = remIgnoreCase(tuple._1).add(tuple) + + def getOrUpdate(k: String)(op: => B): B = theMap.getOrElseUpdate(k, op) + } + + implicit class BetterSeqOfA[A](val seq: Seq[A]) extends AnyVal { + def avgBy(f: A => Int): Double = { + if (seq.isEmpty) 0.0 + else { + val sum = seq.map(f).foldLeft(0) { case (a, b) => + a + b + } + sum / seq.size + } + } + + def findFirstSome[B](f: A => Option[B]): Option[B] = { + if (seq.isEmpty) { + None + } else { + for (a <- seq) { + val res = f(a) + if (res.isDefined) { + return res + } + } + None + } + } + } +} \ No newline at end of file diff --git a/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/integration.scala b/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/integration.scala new file mode 100644 index 0000000000..e9b4ffd369 --- /dev/null +++ b/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/integration.scala @@ -0,0 +1,116 @@ +package io.otoroshi.common.wasm + +import akka.stream.Materializer +import io.otoroshi.common.utils.implicits._ +import org.extism.sdk.wasmotoroshi.{WasmOtoroshiHostFunction, WasmOtoroshiHostUserData} +import play.api.Logger +import play.api.libs.json.JsObject +import play.api.libs.ws.{WSClient, WSRequest} + +import scala.collection.JavaConverters._ +import scala.collection.concurrent.TrieMap +import scala.concurrent.duration._ +import scala.concurrent.{ExecutionContext, Future} + +trait AbsVmData { + def properties: Map[String, Array[Byte]] +} + +trait WasmIntegrationContext { + + def logger: Logger + def materializer: Materializer + def executionContext: ExecutionContext + + def wasmCacheTtl: Long + def wasmQueueBufferSize: Int + + def url(path: String): WSRequest + def mtlsUrl(path: String, tlsConfig: TlsConfig): WSRequest + + def wasmManagerSettings: Future[Option[WasmManagerSettings]] + def wasmConfig(path: String): Option[WasmConfiguration] + def wasmConfigs(): Seq[WasmConfiguration] + def hostFunctions(config: WasmConfiguration, pluginId: String): Array[WasmOtoroshiHostFunction[_ <: WasmOtoroshiHostUserData]] + + def wasmScriptCache: TrieMap[String, CacheableWasmScript] + def wasmExecutor: ExecutionContext +} + +class WasmIntegration(ic: WasmIntegrationContext) { + + def wasmVmById(id: String): Future[Option[(WasmVm, WasmConfiguration)]] = { + ic.wasmConfig(id).map(cfg => wasmVmFor(cfg)).getOrElse(Future.successful(None)) + } + + def wasmVmFor(config: WasmConfiguration): Future[Option[(WasmVm, WasmConfiguration)]] = { + implicit val ec = ic.executionContext + implicit val ev = ic + if (config.source.kind == WasmSourceKind.Local) { + ic.wasmConfig(config.source.path) match { + case None => None.vfuture + case Some(localConfig) => { + localConfig.pool().getPooledVm().map(vm => Some((vm, localConfig))) + } + } + } else { + config.pool().getPooledVm().map(vm => Some((vm, config))) + } + } + + def runVmLoaderJob(): Future[Unit] = { + implicit val ec = ic.executionContext + implicit val ev = ic + ic.wasmConfigs().foreach { plugin => + val now = System.currentTimeMillis() + ic.wasmScriptCache.get(plugin.source.cacheKey) match { + case None => plugin.source.getWasm() + case Some(CacheableWasmScript.CachedWasmScript(_, createAt)) if (createAt + ic.wasmCacheTtl) < now => + plugin.source.getWasm() + case Some(CacheableWasmScript.CachedWasmScript(_, createAt)) + if (createAt + ic.wasmCacheTtl) > now && (createAt + ic.wasmCacheTtl + 1000) < now => + plugin.source.getWasm() + case _ => () + } + } + ().vfuture + } + + def runVmCleanerJob(config: JsObject): Future[Unit] = { + val globalNotUsedDuration = config.select("not-used-duration").asOpt[Long].map(v => v.millis).getOrElse(5.minutes) + WasmVmPool.allInstances().foreach { case (key, pool) => + if (pool.inUseVms.isEmpty && pool.availableVms.isEmpty) { + ic.logger.warn(s"will destroy 1 wasm vms pool") + pool.destroyCurrentVms() + pool.close() + WasmVmPool.removePlugin(key) + } else { + val options = pool.wasmConfig().map(_.killOptions) + if (!options.exists(_.immortal)) { + val maxDur = options.map(_.maxUnusedDuration).getOrElse(globalNotUsedDuration) + val unusedVms = pool.availableVms.asScala.filter(_.hasNotBeenUsedInTheLast(maxDur)) + val tooMuchMemoryVms = (pool.availableVms.asScala ++ pool.inUseVms.asScala) + .filter(_.consumesMoreThanMemoryPercent(options.map(_.maxMemoryUsage).getOrElse(0.9))) + val tooSlowVms = (pool.availableVms.asScala ++ pool.inUseVms.asScala) + .filter(_.tooSlow(options.map(_.maxAvgCallDuration.toNanos).getOrElse(1.day.toNanos))) + val allVms = unusedVms ++ tooMuchMemoryVms ++ tooSlowVms + if (allVms.nonEmpty) { + ic.logger.warn(s"will destroy ${allVms.size} wasm vms") + if (unusedVms.nonEmpty) ic.logger.warn(s" - ${unusedVms.size} because unused for more than ${maxDur.toHours}") + if (tooMuchMemoryVms.nonEmpty) ic.logger.warn(s" - ${tooMuchMemoryVms.size} because of too much memory used") + if (tooSlowVms.nonEmpty) ic.logger.warn(s" - ${tooSlowVms.size} because of avg call duration too long") + } + allVms.foreach { vm => + if (vm.isBusy()) { + vm.destroyAtRelease() + } else { + vm.ignore() + vm.destroy() + } + } + } + } + } + ().vfuture + } +} diff --git a/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/manager.scala b/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/manager.scala new file mode 100644 index 0000000000..ab82e6632d --- /dev/null +++ b/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/manager.scala @@ -0,0 +1,39 @@ +package io.otoroshi.common.wasm + +import io.otoroshi.common.utils.implicits._ +import play.api.libs.json._ + +import scala.util.{Failure, Success, Try} + +case class WasmManagerSettings( + url: String = "http://localhost:5001", + clientId: String = "admin-api-apikey-id", + clientSecret: String = "admin-api-apikey-secret", + pluginsFilter: Option[String] = Some("*") + ) { + def json: JsValue = WasmManagerSettings.format.writes(this) +} +object WasmManagerSettings { + val format = new Format[WasmManagerSettings] { + override def writes(o: WasmManagerSettings): JsValue = + Json.obj( + "url" -> o.url, + "clientId" -> o.clientId, + "clientSecret" -> o.clientSecret, + "pluginsFilter" -> o.pluginsFilter.map(JsString).getOrElse(JsNull).as[JsValue] + ) + + override def reads(json: JsValue): JsResult[WasmManagerSettings] = + Try { + WasmManagerSettings( + url = (json \ "url").asOpt[String].getOrElse("http://localhost:5001"), + clientId = (json \ "clientId").asOpt[String].getOrElse("admin-api-apikey-id"), + clientSecret = (json \ "clientSecret").asOpt[String].getOrElse("admin-api-apikey-secret"), + pluginsFilter = (json \ "pluginsFilter").asOpt[String].getOrElse("*").some + ) + } match { + case Failure(e) => JsError(e.getMessage) + case Success(ac) => JsSuccess(ac) + } + } +} \ No newline at end of file diff --git a/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/opa.scala b/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/opa.scala new file mode 100644 index 0000000000..d05ee0d15d --- /dev/null +++ b/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/opa.scala @@ -0,0 +1,343 @@ +package io.otoroshi.common.wasm + +import akka.stream.Materializer +import akka.util.ByteString +import io.otoroshi.common.utils.implicits._ +import org.extism.sdk.wasmotoroshi._ +import play.api.libs.json._ + +import java.nio.charset.StandardCharsets +import java.util.Optional +import java.util.concurrent.atomic.AtomicReference +import scala.collection.concurrent.TrieMap +import scala.concurrent.duration.{DurationInt, FiniteDuration} +import scala.concurrent.{Await, ExecutionContext, Future}; + +trait AwaitCapable { + def await[T](future: Future[T], atMost: FiniteDuration = 5.seconds): T = { + Await.result(future, atMost) + } +} + +case class HostFunctionWithAuthorization( + function: WasmOtoroshiHostFunction[_ <: WasmOtoroshiHostUserData], + authorized: WasmConfiguration => Boolean +) + +case class EnvUserData( + ic: WasmIntegrationContext, + executionContext: ExecutionContext, + mat: Materializer, + config: WasmConfiguration +) extends WasmOtoroshiHostUserData + +case class StateUserData( + ic: WasmIntegrationContext, + executionContext: ExecutionContext, + mat: Materializer, + cache: TrieMap[String, TrieMap[String, ByteString]] +) extends WasmOtoroshiHostUserData + +case class EmptyUserData() extends WasmOtoroshiHostUserData + +object OPA extends AwaitCapable { + + def opaAbortFunction: WasmOtoroshiExtismFunction[EmptyUserData] = + ( + plugin: WasmOtoroshiInternal, + params: Array[WasmBridge.ExtismVal], + returns: Array[WasmBridge.ExtismVal], + data: Optional[EmptyUserData] + ) => { + System.out.println("opaAbortFunction"); + } + + def opaPrintlnFunction: WasmOtoroshiExtismFunction[EmptyUserData] = + ( + plugin: WasmOtoroshiInternal, + params: Array[WasmBridge.ExtismVal], + returns: Array[WasmBridge.ExtismVal], + data: Optional[EmptyUserData] + ) => { + System.out.println("opaPrintlnFunction"); + } + + def opaBuiltin0Function: WasmOtoroshiExtismFunction[EmptyUserData] = + ( + plugin: WasmOtoroshiInternal, + params: Array[WasmBridge.ExtismVal], + returns: Array[WasmBridge.ExtismVal], + data: Optional[EmptyUserData] + ) => { + System.out.println("opaBuiltin0Function"); + } + + def opaBuiltin1Function: WasmOtoroshiExtismFunction[EmptyUserData] = + ( + plugin: WasmOtoroshiInternal, + params: Array[WasmBridge.ExtismVal], + returns: Array[WasmBridge.ExtismVal], + data: Optional[EmptyUserData] + ) => { + System.out.println("opaBuiltin1Function"); + } + + def opaBuiltin2Function: WasmOtoroshiExtismFunction[EmptyUserData] = + ( + plugin: WasmOtoroshiInternal, + params: Array[WasmBridge.ExtismVal], + returns: Array[WasmBridge.ExtismVal], + data: Optional[EmptyUserData] + ) => { + System.out.println("opaBuiltin2Function"); + } + + def opaBuiltin3Function: WasmOtoroshiExtismFunction[EmptyUserData] = + ( + plugin: WasmOtoroshiInternal, + params: Array[WasmBridge.ExtismVal], + returns: Array[WasmBridge.ExtismVal], + data: Optional[EmptyUserData] + ) => { + System.out.println("opaBuiltin3Function"); + }; + + def opaBuiltin4Function: WasmOtoroshiExtismFunction[EmptyUserData] = + ( + plugin: WasmOtoroshiInternal, + params: Array[WasmBridge.ExtismVal], + returns: Array[WasmBridge.ExtismVal], + data: Optional[EmptyUserData] + ) => { + System.out.println("opaBuiltin4Function"); + } + + def opaAbort() = new WasmOtoroshiHostFunction[EmptyUserData]( + "opa_abort", + Array(WasmBridge.ExtismValType.I32), + Array(), + opaAbortFunction, + Optional.empty() + ) + + def opaPrintln() = new WasmOtoroshiHostFunction[EmptyUserData]( + "opa_println", + Array(WasmBridge.ExtismValType.I64), + Array(WasmBridge.ExtismValType.I64), + opaPrintlnFunction, + Optional.empty() + ) + + def opaBuiltin0() = new WasmOtoroshiHostFunction[EmptyUserData]( + "opa_builtin0", + Array(WasmBridge.ExtismValType.I32, WasmBridge.ExtismValType.I32), + Array(WasmBridge.ExtismValType.I32), + opaBuiltin0Function, + Optional.empty() + ) + + def opaBuiltin1() = new WasmOtoroshiHostFunction[EmptyUserData]( + "opa_builtin1", + Array(WasmBridge.ExtismValType.I32, WasmBridge.ExtismValType.I32, WasmBridge.ExtismValType.I32), + Array(WasmBridge.ExtismValType.I32), + opaBuiltin1Function, + Optional.empty() + ) + + def opaBuiltin2() = new WasmOtoroshiHostFunction[EmptyUserData]( + "opa_builtin2", + Array( + WasmBridge.ExtismValType.I32, + WasmBridge.ExtismValType.I32, + WasmBridge.ExtismValType.I32, + WasmBridge.ExtismValType.I32 + ), + Array(WasmBridge.ExtismValType.I32), + opaBuiltin2Function, + Optional.empty() + ) + + def opaBuiltin3() = new WasmOtoroshiHostFunction[EmptyUserData]( + "opa_builtin3", + Array( + WasmBridge.ExtismValType.I32, + WasmBridge.ExtismValType.I32, + WasmBridge.ExtismValType.I32, + WasmBridge.ExtismValType.I32, + WasmBridge.ExtismValType.I32 + ), + Array(WasmBridge.ExtismValType.I32), + opaBuiltin3Function, + Optional.empty() + ) + + def opaBuiltin4() = new WasmOtoroshiHostFunction[EmptyUserData]( + "opa_builtin4", + Array( + WasmBridge.ExtismValType.I32, + WasmBridge.ExtismValType.I32, + WasmBridge.ExtismValType.I32, + WasmBridge.ExtismValType.I32, + WasmBridge.ExtismValType.I32, + WasmBridge.ExtismValType.I32 + ), + Array(WasmBridge.ExtismValType.I32), + opaBuiltin4Function, + Optional.empty() + ) + + def getFunctions(config: WasmConfiguration): Seq[HostFunctionWithAuthorization] = { + Seq( + HostFunctionWithAuthorization(opaAbort(), _ => config.opa), + HostFunctionWithAuthorization(opaPrintln(), _ => config.opa), + HostFunctionWithAuthorization(opaBuiltin0(), _ => config.opa), + HostFunctionWithAuthorization(opaBuiltin1(), _ => config.opa), + HostFunctionWithAuthorization(opaBuiltin2(), _ => config.opa), + HostFunctionWithAuthorization(opaBuiltin3(), _ => config.opa), + HostFunctionWithAuthorization(opaBuiltin4(), _ => config.opa) + ) + } + + def getLinearMemories(): Seq[WasmOtoroshiLinearMemory] = { + Seq( + new WasmOtoroshiLinearMemory("memory", "env", new WasmOtoroshiLinearMemoryOptions(5, Optional.empty())) + ) + } + + def loadJSON(plugin: WasmOtoroshiInstance, value: Array[Byte]): Either[JsValue, Int] = { + if (value.length == 0) { + 0.right + } else { + val value_buf_len = value.length + var parameters = new WasmOtoroshiParameters(1) + .pushInt(value_buf_len) + + val raw_addr = plugin.call("opa_malloc", parameters, 1) + + if ( + plugin.writeBytes( + value, + value_buf_len, + raw_addr.getValue(0).v.i32 + ) == -1 + ) { + JsString("Cant' write in memory").left + } else { + parameters = new WasmOtoroshiParameters(2) + .pushInts(raw_addr.getValue(0).v.i32, value_buf_len) + val parsed_addr = plugin.call( + "opa_json_parse", + parameters, + 1 + ) + + if (parsed_addr.getValue(0).v.i32 == 0) { + JsString("failed to parse json value").left + } else { + parsed_addr.getValue(0).v.i32.right + } + } + } + } + + def initialize(plugin: WasmOtoroshiInstance): Either[JsValue, (String, ResultsWrapper)] = { + loadJSON(plugin, "{}".getBytes(StandardCharsets.UTF_8)) + .flatMap(dataAddr => { + val base_heap_ptr = plugin.call( + "opa_heap_ptr_get", + new WasmOtoroshiParameters(0), + 1 + ) + + val data_heap_ptr = base_heap_ptr.getValue(0).v.i32 + ( + Json.obj("dataAddr" -> dataAddr, "baseHeapPtr" -> data_heap_ptr).stringify, + ResultsWrapper(new WasmOtoroshiResults(0)) + ).right + }) + } + + def evaluate( + plugin: WasmOtoroshiInstance, + dataAddr: Int, + baseHeapPtr: Int, + input: String + ): Either[JsValue, (String, ResultsWrapper)] = { + val entrypoint = 0 + + // TODO - read and load builtins functions by calling dumpJSON + val input_len = input.getBytes(StandardCharsets.UTF_8).length + plugin.writeBytes( + input.getBytes(StandardCharsets.UTF_8), + input_len, + baseHeapPtr + ) + + val heap_ptr = baseHeapPtr + input_len + val input_addr = baseHeapPtr + + val ptr = new WasmOtoroshiParameters(7) + .pushInts(0, entrypoint, dataAddr, input_addr, input_len, heap_ptr, 0) + + val ret = plugin.call("opa_eval", ptr, 1) + + val memory = plugin.getMemory("memory") + + val offset: Int = ret.getValue(0).v.i32 + val arraySize: Int = 65356 + + val mem: Array[Byte] = memory.getByteArray(offset, arraySize) + val size: Int = lastValidByte(mem) + + ( + new String(java.util.Arrays.copyOf(mem, size), StandardCharsets.UTF_8), + ResultsWrapper(new WasmOtoroshiResults(0)) + ).right + } + + def lastValidByte(arr: Array[Byte]): Int = { + for (i <- arr.indices) { + if (arr(i) == 0) { + return i + } + } + arr.length + } +} + +object LinearMemories { + + private val memories: AtomicReference[Seq[WasmOtoroshiLinearMemory]] = + new AtomicReference[Seq[WasmOtoroshiLinearMemory]](Seq.empty[WasmOtoroshiLinearMemory]) + + def getMemories(config: WasmConfiguration): Array[WasmOtoroshiLinearMemory] = { + if (config.opa) { + if (memories.get.isEmpty) { + memories.set( + OPA.getLinearMemories() + ) + } + memories.get().toArray + } else { + Array.empty + } + } +} + +/* + String dumpJSON() { + Results addr = plugin.call("builtins", new WasmOtoroshiParameters(0), 1); + + Parameters parameters = new WasmOtoroshiParameters(1); + IntegerParameter builder = new IntegerParameter(); + builder.add(parameters, addr.getValue(0).v.i32, 0); + + Results rawAddr = plugin.call("opa_json_dump", parameters, 1); + + Pointer memory = WasmBridge.INSTANCE.extism_get_memory(plugin.getPointer(), plugin.getIndex(), "memory"); + byte[] mem = memory.getByteArray(rawAddr.getValue(0).v.i32, 65356); + int size = lastValidByte(mem); + + return new String(Arrays.copyOf(mem, size), StandardCharsets.UTF_8); + } +}*/ diff --git a/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/runtimev2.scala b/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/runtimev2.scala new file mode 100644 index 0000000000..25d6c9f2be --- /dev/null +++ b/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/runtimev2.scala @@ -0,0 +1,554 @@ +package io.otoroshi.common.wasm + +import akka.stream.OverflowStrategy +import akka.stream.scaladsl._ +import com.codahale.metrics.UniformReservoir +import io.otoroshi.common.utils.implicits._ +import io.otoroshi.common.wasm.CacheableWasmScript.CachedWasmScript +import org.extism.sdk.manifest.{Manifest, MemoryOptions} +import org.extism.sdk.wasm.WasmSourceResolver +import org.extism.sdk.wasmotoroshi._ +import play.api.Logger +import play.api.libs.json._ + +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic._ +import scala.collection.JavaConverters._ +import scala.collection.concurrent.TrieMap +import scala.concurrent.duration.{DurationInt, DurationLong, FiniteDuration} +import scala.concurrent.{Await, ExecutionContext, Future, Promise} +import scala.util.{Failure, Success, Try} + +sealed trait WasmVmAction + +object WasmVmAction { + case object WasmVmKillAction extends WasmVmAction + case class WasmVmCallAction( + parameters: WasmFunctionParameters, + context: Option[AbsVmData], + promise: Promise[Either[JsValue, (String, ResultsWrapper)]] + ) extends WasmVmAction +} + +case class OPAWasmVm(opaDataAddr: Int, opaBaseHeapPtr: Int) + +case class WasmVm( + index: Int, + maxCalls: Int, + maxMemory: Long, + resetMemory: Boolean, + instance: WasmOtoroshiInstance, + vmDataRef: AtomicReference[AbsVmData], + memories: Array[WasmOtoroshiLinearMemory], + functions: Array[WasmOtoroshiHostFunction[_ <: WasmOtoroshiHostUserData]], + pool: WasmVmPool, + var opaPointers: Option[OPAWasmVm] = None +) { + + private val callDurationReservoirNs = new UniformReservoir() + private val lastUsage: AtomicLong = new AtomicLong(System.currentTimeMillis()) + private val initializedRef: AtomicBoolean = new AtomicBoolean(false) + private val killAtRelease: AtomicBoolean = new AtomicBoolean(false) + private val inFlight = new AtomicInteger(0) + private val callCounter = new AtomicInteger(0) + private val queue = { + val env = pool.ic + Source + .queue[WasmVmAction](env.wasmQueueBufferSize, OverflowStrategy.dropTail) + .mapAsync(1)(handle) + .toMat(Sink.ignore)(Keep.both) + .run()(env.materializer) + ._1 + } + + def calls: Int = callCounter.get() + def current: Int = inFlight.get() + + private def handle(act: WasmVmAction): Future[Unit] = { + Future.apply { + lastUsage.set(System.currentTimeMillis()) + act match { + case WasmVmAction.WasmVmKillAction => destroy() + case action: WasmVmAction.WasmVmCallAction => { + try { + inFlight.decrementAndGet() + // action.context.foreach(ctx => WasmContextSlot.setCurrentContext(ctx)) + action.context.foreach(ctx => vmDataRef.set(ctx)) + if (pool.ic.logger.isDebugEnabled) + pool.ic.logger.debug(s"call vm ${index} with method ${action.parameters.functionName} on thread ${Thread + .currentThread() + .getName} on path ${action.context.map(_.properties.get("request.path").map(v => new String(v))).getOrElse("--")}") + val start = System.nanoTime() + val res = action.parameters.call(instance) + callDurationReservoirNs.update(System.nanoTime() - start) + if (res.isRight && res.right.get._2.results.getValues() != null) { + val ret = res.right.get._2.results.getValues()(0).v.i32 + if (ret > 7 || ret < 0) { // weird multi thread issues + ignore() + killAtRelease.set(true) + } + } + action.promise.trySuccess(res) + } catch { + case t: Throwable => action.promise.tryFailure(t) + } finally { + if (resetMemory) { + instance.reset() + } + pool.ic.logger.debug(s"functions: ${functions.size}") + pool.ic.logger.debug(s"memories: ${memories.size}") + // WasmContextSlot.clearCurrentContext() + // vmDataRef.set(null) + val count = callCounter.incrementAndGet() + if (count >= maxCalls) { + callCounter.set(0) + if (pool.ic.logger.isDebugEnabled) + pool.ic.logger.debug(s"killing vm ${index} with remaining ${inFlight.get()} calls (${count})") + destroyAtRelease() + } + } + } + } + () + }(pool.ic.wasmExecutor) + } + + def reset(): Unit = instance.reset() + + def destroy(): Unit = { + if (pool.ic.logger.isDebugEnabled) pool.ic.logger.debug(s"destroy vm: ${index}") + pool.ic.logger.debug(s"destroy vm: ${index}") + pool.clear(this) + instance.close() + } + + def isBusy(): Boolean = { + inFlight.get() > 0 + } + + def destroyAtRelease(): Unit = { + ignore() + killAtRelease.set(true) + } + + def release(): Unit = { + if (killAtRelease.get()) { + queue.offer(WasmVmAction.WasmVmKillAction) + } else { + pool.release(this) + } + } + + def lastUsedAt(): Long = lastUsage.get() + + def hasNotBeenUsedInTheLast(duration: FiniteDuration): Boolean = + if (duration.toNanos == 0L) false else !hasBeenUsedInTheLast(duration) + + def consumesMoreThanMemoryPercent(percent: Double): Boolean = if (percent == 0.0) { + false + } else { + val consumed: Double = instance.getMemorySize.toDouble / maxMemory.toDouble + val res = consumed > percent + if (pool.ic.logger.isDebugEnabled) + pool.ic.logger.debug( + s"consumesMoreThanMemoryPercent($percent) = (${instance.getMemorySize} / $maxMemory) > $percent : $res : (${consumed * 100.0}%)" + ) + res + } + + def tooSlow(max: Long): Boolean = { + if (max == 0L) { + false + } else { + callDurationReservoirNs.getSnapshot.getMean.toLong > max + } + } + + def hasBeenUsedInTheLast(duration: FiniteDuration): Boolean = { + val now = System.currentTimeMillis() + val limit = lastUsage.get() + duration.toMillis + now < limit + } + + def ignore(): Unit = pool.ignore(this) + + def initialized(): Boolean = initializedRef.get() + + def initialize(f: => Any): Unit = { + if (initializedRef.compareAndSet(false, true)) { + f + } + } + + def finitialize[A](f: => Future[A]): Future[Unit] = { + if (initializedRef.compareAndSet(false, true)) { + f.map(_ => ())(pool.ic.executionContext) + } else { + ().vfuture + } + } + + def call( + parameters: WasmFunctionParameters, + context: Option[AbsVmData] + )(implicit ic: WasmIntegrationContext, ec: ExecutionContext): Future[Either[JsValue, (String, ResultsWrapper)]] = { + val promise = Promise[Either[JsValue, (String, ResultsWrapper)]]() + inFlight.incrementAndGet() + lastUsage.set(System.currentTimeMillis()) + queue.offer(WasmVmAction.WasmVmCallAction(parameters, context, promise)) + promise.future + } +} + +case class WasmVmPoolAction(promise: Promise[WasmVm], options: WasmVmInitOptions) { + private[wasm] def provideVm(vm: WasmVm): Unit = promise.trySuccess(vm) + private[wasm] def fail(e: Throwable): Unit = promise.tryFailure(e) +} + +object WasmVmPool { + + private[wasm] val logger = Logger("otoroshi-wasm-vm-pool") + private[wasm] val engine = new WasmOtoroshiEngine() + private val instances = new TrieMap[String, WasmVmPool]() + + def allInstances(): Map[String, WasmVmPool] = instances.synchronized { + instances.toMap + } + + def forConfig(config: => WasmConfiguration)(implicit ic: WasmIntegrationContext): WasmVmPool = instances.synchronized { + val key = s"${config.source.cacheKey}?cfg=${config.json.stringify.sha512}" + instances.getOrUpdate(key) { + new WasmVmPool(key, config.some, ic) + } + } + + private[wasm] def removePlugin(id: String): Unit = instances.synchronized { + instances.remove(id) + } +} + +class WasmVmPool(stableId: => String, optConfig: => Option[WasmConfiguration], val ic: WasmIntegrationContext) { + + WasmVmPool.logger.debug("new WasmVmPool") + + private val engine = new WasmOtoroshiEngine() + private val counter = new AtomicInteger(-1) + private val templateRef = new AtomicReference[WasmOtoroshiTemplate](null) + private[wasm] val availableVms = new ConcurrentLinkedQueue[WasmVm]() + private[wasm] val inUseVms = new ConcurrentLinkedQueue[WasmVm]() + private val creatingRef = new AtomicBoolean(false) + private val lastPluginVersion = new AtomicReference[String](null) + private val requestsSource = Source.queue[WasmVmPoolAction](ic.wasmQueueBufferSize, OverflowStrategy.dropTail) + private val prioritySource = Source.queue[WasmVmPoolAction](ic.wasmQueueBufferSize, OverflowStrategy.dropTail) + private val (priorityQueue, requestsQueue) = { + prioritySource + .mergePrioritizedMat(requestsSource, 99, 1, false)(Keep.both) + .map(handleAction) + .toMat(Sink.ignore)(Keep.both) + .run()(ic.materializer) + ._1 + } + + // unqueue actions from the action queue + private def handleAction(action: WasmVmPoolAction): Unit = try { + wasmConfig() match { + case None => + // if we cannot find the current wasm config, something is wrong, we destroy the pool + destroyCurrentVms() + WasmVmPool.removePlugin(stableId) + action.fail(new RuntimeException(s"No more plugin ${stableId}")) + case Some(wcfg) => { + // first we ensure the wasm source has been fetched + if (!wcfg.source.isCached()(ic)) { + wcfg.source + .getWasm()(ic, ic.executionContext) + .andThen { case _ => + priorityQueue.offer(action) + }(ic.executionContext) + } else { + val changed = hasChanged(wcfg) + val available = hasAvailableVm(wcfg) + val creating = isVmCreating() + val atMax = atMaxPoolCapacity(wcfg) + // then we check if the underlying wasmcode + config has not changed since last time + if (changed) { + // if so, we destroy all current vms and recreate a new one + WasmVmPool.logger.warn("plugin has changed, destroying old instances") + destroyCurrentVms() + createVm(wcfg, action.options) + } + // check if a vm is available + if (!available) { + // if not, but a new one is creating, just wait a little bit more + if (creating) { + priorityQueue.offer(action) + } else { + // check if we hit the max possible instances + if (atMax) { + // if so, just wait + priorityQueue.offer(action) + } else { + // if not, create a new instance because we need one + createVm(wcfg, action.options) + priorityQueue.offer(action) + } + } + } else { + // if so, acquire one + val vm = acquireVm() + action.provideVm(vm) + } + } + } + } + } catch { + case t: Throwable => + t.printStackTrace() + action.fail(t) + } + + // create a new vm for the pool + // we try to create vm one by one and to not create more than needed + private def createVm(config: WasmConfiguration, options: WasmVmInitOptions): Unit = synchronized { + if (creatingRef.compareAndSet(false, true)) { + val index = counter.incrementAndGet() + WasmVmPool.logger.debug(s"creating vm: ${index}") + if (templateRef.get() == null) { + if (!config.source.isCached()(ic)) { + // this part should never happen anymore, but just in case + WasmVmPool.logger.warn("fetching missing source") + Await.result(config.source.getWasm()(ic, ic.executionContext), 30.seconds) + } + lastPluginVersion.set(computeHash(config, config.source.cacheKey, ic.wasmScriptCache)) + val cache = ic.wasmScriptCache + val key = config.source.cacheKey + val wasm = cache(key).asInstanceOf[CachedWasmScript].script + val hash = wasm.sha256 + val resolver = new WasmSourceResolver() + val source = resolver.resolve("wasm", wasm.toByteBuffer.array()) + templateRef.set( + new WasmOtoroshiTemplate( + engine, + hash, + new Manifest( + Seq[org.extism.sdk.wasm.WasmSource](source).asJava, + new MemoryOptions(config.memoryPages), + config.config.asJava, + config.allowedHosts.asJava, + config.allowedPaths.asJava + ) + ) + ) + } + val template = templateRef.get() + val vmDataRef = new AtomicReference[AbsVmData](null) + val addedFunctions = options.addHostFunctions(vmDataRef) + val functions: Array[WasmOtoroshiHostFunction[_ <: WasmOtoroshiHostUserData]] = + if (options.importDefaultHostFunctions) { + ic.hostFunctions(config, stableId) ++ addedFunctions + } else { + addedFunctions.toArray[WasmOtoroshiHostFunction[_ <: WasmOtoroshiHostUserData]] + } + val memories = LinearMemories.getMemories(config) + val instance = template.instantiate(engine, functions, memories, config.wasi) + val vm = WasmVm( + index, + config.killOptions.maxCalls, + config.memoryPages * (64L * 1024L), + options.resetMemory, + instance, + vmDataRef, + memories, + functions, + this + ) + availableVms.offer(vm) + creatingRef.compareAndSet(true, false) + } + } + + // acquire an available vm for work + private def acquireVm(): WasmVm = synchronized { + if (availableVms.size() > 0) { + availableVms.synchronized { + val vm = availableVms.poll() + availableVms.remove(vm) + inUseVms.offer(vm) + vm + } + } else { + throw new RuntimeException("no instances available") + } + } + + // release the vm to be available for other tasks + private[wasm] def release(vm: WasmVm): Unit = synchronized { + availableVms.synchronized { + availableVms.offer(vm) + inUseVms.remove(vm) + } + } + + // do not consider the vm anymore for more work (the vm is being dropped for some reason) + private[wasm] def ignore(vm: WasmVm): Unit = synchronized { + availableVms.synchronized { + inUseVms.remove(vm) + } + } + + // do not consider the vm anymore for more work (the vm is being dropped for some reason) + private[wasm] def clear(vm: WasmVm): Unit = synchronized { + availableVms.synchronized { + availableVms.remove(vm) + } + } + + private[wasm] def wasmConfig(): Option[WasmConfiguration] = { + optConfig.orElse(ic.wasmConfig(stableId)) + } + + private def hasAvailableVm(plugin: WasmConfiguration): Boolean = + availableVms.size() > 0 && (inUseVms.size < plugin.instances) + + private def isVmCreating(): Boolean = creatingRef.get() + + private def atMaxPoolCapacity(plugin: WasmConfiguration): Boolean = (availableVms.size + inUseVms.size) >= plugin.instances + + // close the current pool + private[wasm] def close(): Unit = availableVms.synchronized { + engine.close() + } + + // destroy all vms and clear everything in order to destroy the current pool + private[wasm] def destroyCurrentVms(): Unit = availableVms.synchronized { + WasmVmPool.logger.info("destroying all vms") + availableVms.asScala.foreach(_.destroy()) + availableVms.clear() + inUseVms.clear() + //counter.set(0) + templateRef.set(null) + creatingRef.set(false) + lastPluginVersion.set(null) + } + + // compute the current hash for a tuple (wasmcode + config) + private def computeHash( + config: WasmConfiguration, + key: String, + cache: TrieMap[String, CacheableWasmScript] + ): String = { + config.json.stringify.sha512 + "#" + cache + .get(key) + .map { + case CacheableWasmScript.CachedWasmScript(wasm, _) => wasm.sha512 + case _ => "fetching" + } + .getOrElse("null") + } + + // compute if the source (wasm code + config) is the same than current + private def hasChanged(config: WasmConfiguration): Boolean = availableVms.synchronized { + val key = config.source.cacheKey + val cache = ic.wasmScriptCache + var oldHash = lastPluginVersion.get() + if (oldHash == null) { + oldHash = computeHash(config, key, cache) + lastPluginVersion.set(oldHash) + } + cache.get(key) match { + case Some(CacheableWasmScript.CachedWasmScript(_, _)) => { + val currentHash = computeHash(config, key, cache) + oldHash != currentHash + } + case _ => false + } + } + + // get a pooled vm when one available. + // Do not forget to release it after usage + def getPooledVm(options: WasmVmInitOptions = WasmVmInitOptions.empty()): Future[WasmVm] = { + val p = Promise[WasmVm]() + requestsQueue.offer(WasmVmPoolAction(p, options)) + p.future + } + + // borrow a vm for sync operations + def withPooledVm[A](options: WasmVmInitOptions = WasmVmInitOptions.empty())(f: WasmVm => A): Future[A] = { + implicit val ev = ic + implicit val ec = ic.executionContext + getPooledVm(options).flatMap { vm => + val p = Promise[A]() + try { + val ret = f(vm) + p.trySuccess(ret) + } catch { + case e: Throwable => + p.tryFailure(e) + } finally { + vm.release() + } + p.future + } + } + + // borrow a vm for async operations + def withPooledVmF[A](options: WasmVmInitOptions = WasmVmInitOptions.empty())(f: WasmVm => Future[A]): Future[A] = { + implicit val ev = ic + implicit val ec = ic.executionContext + getPooledVm(options).flatMap { vm => + f(vm).andThen { case _ => + vm.release() + } + } + } +} + +case class WasmVmInitOptions( + importDefaultHostFunctions: Boolean = true, + resetMemory: Boolean = true, + addHostFunctions: (AtomicReference[AbsVmData]) => Seq[WasmOtoroshiHostFunction[_ <: WasmOtoroshiHostUserData]] = _ => + Seq.empty +) + +object WasmVmInitOptions { + def empty(): WasmVmInitOptions = WasmVmInitOptions( + importDefaultHostFunctions = true, + resetMemory = true, + addHostFunctions = _ => Seq.empty + ) +} + +case class WasmVmKillOptions( + immortal: Boolean = false, + maxCalls: Int = Int.MaxValue, + maxMemoryUsage: Double = 0.0, + maxAvgCallDuration: FiniteDuration = 0.nano, + maxUnusedDuration: FiniteDuration = 5.minutes +) { + def json: JsValue = WasmVmKillOptions.format.writes(this) +} + +object WasmVmKillOptions { + val default = WasmVmKillOptions() + val format = new Format[WasmVmKillOptions] { + override def writes(o: WasmVmKillOptions): JsValue = Json.obj( + "immortal" -> o.immortal, + "max_calls" -> o.maxCalls, + "max_memory_usage" -> o.maxMemoryUsage, + "max_avg_call_duration" -> o.maxAvgCallDuration.toMillis, + "max_unused_duration" -> o.maxUnusedDuration.toMillis + ) + override def reads(json: JsValue): JsResult[WasmVmKillOptions] = Try { + WasmVmKillOptions( + immortal = json.select("immortal").asOpt[Boolean].getOrElse(false), + maxCalls = json.select("max_calls").asOpt[Int].getOrElse(Int.MaxValue), + maxMemoryUsage = json.select("max_memory_usage").asOpt[Double].getOrElse(0.0), + maxAvgCallDuration = json.select("max_avg_call_duration").asOpt[Long].map(_.millis).getOrElse(0.nano), + maxUnusedDuration = json.select("max_unused_duration").asOpt[Long].map(_.millis).getOrElse(5.minutes) + ) + } match { + case Failure(e) => JsError(e.getMessage) + case Success(e) => JsSuccess(e) + } + } +} diff --git a/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/types.scala b/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/types.scala new file mode 100644 index 0000000000..a2ce613402 --- /dev/null +++ b/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/types.scala @@ -0,0 +1,136 @@ +package io.otoroshi.common.wasm + +import io.otoroshi.common.utils.implicits._ +import org.extism.sdk.Results +import org.extism.sdk.wasmotoroshi._ +import play.api.libs.json._ + +import java.nio.charset.StandardCharsets + +sealed abstract class WasmFunctionParameters { + def functionName: String + def input: Option[String] + def parameters: Option[WasmOtoroshiParameters] + def resultSize: Option[Int] + def call(plugin: WasmOtoroshiInstance): Either[JsValue, (String, ResultsWrapper)] + def withInput(input: Option[String]): WasmFunctionParameters + def withFunctionName(functionName: String): WasmFunctionParameters +} + +object WasmFunctionParameters { + def from( + functionName: String, + input: Option[String], + parameters: Option[WasmOtoroshiParameters], + resultSize: Option[Int] + ) = { + (input, parameters, resultSize) match { + case (_, Some(p), Some(s)) => BothParamsResults(functionName, p, s) + case (_, Some(p), None) => NoResult(functionName, p) + case (_, None, Some(s)) => NoParams(functionName, s) + case (Some(in), None, None) => ExtismFuntionCall(functionName, in) + case _ => UnknownCombination() + } + } + + case class UnknownCombination( + functionName: String = "unknown", + input: Option[String] = None, + parameters: Option[WasmOtoroshiParameters] = None, + resultSize: Option[Int] = None + ) extends WasmFunctionParameters { + override def call(plugin: WasmOtoroshiInstance): Either[JsValue, (String, ResultsWrapper)] = { + Left(Json.obj("error" -> "bad call combination")) + } + def withInput(input: Option[String]): WasmFunctionParameters = this.copy(input = input) + def withFunctionName(functionName: String): WasmFunctionParameters = this.copy(functionName = functionName) + } + + case class NoResult( + functionName: String, + params: WasmOtoroshiParameters, + input: Option[String] = None, + resultSize: Option[Int] = None + ) extends WasmFunctionParameters { + override def parameters: Option[WasmOtoroshiParameters] = Some(params) + override def call(plugin: WasmOtoroshiInstance): Either[JsValue, (String, ResultsWrapper)] = { + plugin.callWithoutResults(functionName, parameters.get) + Right[JsValue, (String, ResultsWrapper)](("", ResultsWrapper(new WasmOtoroshiResults(0), plugin))) + } + override def withInput(input: Option[String]): WasmFunctionParameters = this.copy(input = input) + override def withFunctionName(functionName: String): WasmFunctionParameters = this.copy(functionName = functionName) + } + + case class NoParams( + functionName: String, + result: Int, + input: Option[String] = None, + parameters: Option[WasmOtoroshiParameters] = None + ) extends WasmFunctionParameters { + override def resultSize: Option[Int] = Some(result) + override def call(plugin: WasmOtoroshiInstance): Either[JsValue, (String, ResultsWrapper)] = { + plugin + .callWithoutParams(functionName, resultSize.get) + .right + .map(_ => ("", ResultsWrapper(new WasmOtoroshiResults(0), plugin))) + } + override def withInput(input: Option[String]): WasmFunctionParameters = this.copy(input = input) + override def withFunctionName(functionName: String): WasmFunctionParameters = this.copy(functionName = functionName) + } + + case class BothParamsResults( + functionName: String, + params: WasmOtoroshiParameters, + result: Int, + input: Option[String] = None + ) extends WasmFunctionParameters { + override def parameters: Option[WasmOtoroshiParameters] = Some(params) + override def resultSize: Option[Int] = Some(result) + override def call(plugin: WasmOtoroshiInstance): Either[JsValue, (String, ResultsWrapper)] = { + plugin + .call(functionName, parameters.get, resultSize.get) + .right + .map(res => ("", ResultsWrapper(res, plugin))) + } + override def withInput(input: Option[String]): WasmFunctionParameters = this.copy(input = input) + override def withFunctionName(functionName: String): WasmFunctionParameters = this.copy(functionName = functionName) + } + + case class ExtismFuntionCall( + functionName: String, + in: String, + parameters: Option[WasmOtoroshiParameters] = None, + resultSize: Option[Int] = None + ) extends WasmFunctionParameters { + override def input: Option[String] = Some(in) + override def call(plugin: WasmOtoroshiInstance): Either[JsValue, (String, ResultsWrapper)] = { + plugin + .extismCall(functionName, input.get.getBytes(StandardCharsets.UTF_8)) + .right + .map { str => + (str, ResultsWrapper(new WasmOtoroshiResults(0), plugin)) + } + } + + override def withInput(input: Option[String]): WasmFunctionParameters = this.copy(in = input.get) + override def withFunctionName(functionName: String): WasmFunctionParameters = this.copy(functionName = functionName) + } + + case class OPACall(functionName: String, pointers: Option[OPAWasmVm] = None, in: String) + extends WasmFunctionParameters { + override def input: Option[String] = Some(in) + + override def call(plugin: WasmOtoroshiInstance): Either[JsValue, (String, ResultsWrapper)] = { + if (functionName == "initialize") + OPA.initialize(plugin) + else + OPA.evaluate(plugin, pointers.get.opaDataAddr, pointers.get.opaBaseHeapPtr, in) + } + + override def withInput(input: Option[String]): WasmFunctionParameters = this.copy(in = input.get) + + override def withFunctionName(functionName: String): WasmFunctionParameters = this + override def parameters: Option[WasmOtoroshiParameters] = None + override def resultSize: Option[Int] = None + } +} diff --git a/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/utils.scala b/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/utils.scala new file mode 100644 index 0000000000..3dd43eb70a --- /dev/null +++ b/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/utils.scala @@ -0,0 +1,124 @@ +package io.otoroshi.common.wasm + +import play.api.libs.json._ +import play.api.libs.ws.{DefaultWSProxyServer, WSProxyServer} + +import scala.util.{Failure, Success, Try} + +object WSProxyServerJson { + def maybeProxyToJson(p: Option[WSProxyServer]): JsValue = + p match { + case Some(proxy) => proxyToJson(proxy) + case None => JsNull + } + def proxyToJson(p: WSProxyServer): JsValue = + Json.obj( + "host" -> p.host, // host: String + "port" -> p.port, // port: Int + "protocol" -> p.protocol.map(JsString.apply).getOrElse(JsNull).as[JsValue], // protocol: Option[String] + "principal" -> p.principal.map(JsString.apply).getOrElse(JsNull).as[JsValue], // principal: Option[String] + "password" -> p.password.map(JsString.apply).getOrElse(JsNull).as[JsValue], // password: Option[String] + "ntlmDomain" -> p.ntlmDomain.map(JsString.apply).getOrElse(JsNull).as[JsValue], // ntlmDomain: Option[String] + "encoding" -> p.encoding.map(JsString.apply).getOrElse(JsNull).as[JsValue], // encoding: Option[String] + "nonProxyHosts" -> p.nonProxyHosts + .map(nph => JsArray(nph.map(JsString.apply))) + .getOrElse(JsNull) + .as[JsValue] // nonProxyHosts: Option[Seq[String]] + ) + def proxyFromJson(json: JsValue): Option[WSProxyServer] = { + val maybeHost = (json \ "host").asOpt[String].filterNot(_.trim.isEmpty) + val maybePort = (json \ "port").asOpt[Int] + (maybeHost, maybePort) match { + case (Some(host), Some(port)) => { + Some(DefaultWSProxyServer(host, port)) + .map { proxy => + (json \ "protocol") + .asOpt[String] + .filterNot(_.trim.isEmpty) + .map(v => proxy.copy(protocol = Some(v))) + .getOrElse(proxy) + } + .map { proxy => + (json \ "principal") + .asOpt[String] + .filterNot(_.trim.isEmpty) + .map(v => proxy.copy(principal = Some(v))) + .getOrElse(proxy) + } + .map { proxy => + (json \ "password") + .asOpt[String] + .filterNot(_.trim.isEmpty) + .map(v => proxy.copy(password = Some(v))) + .getOrElse(proxy) + } + .map { proxy => + (json \ "ntlmDomain") + .asOpt[String] + .filterNot(_.trim.isEmpty) + .map(v => proxy.copy(ntlmDomain = Some(v))) + .getOrElse(proxy) + } + .map { proxy => + (json \ "encoding") + .asOpt[String] + .filterNot(_.trim.isEmpty) + .map(v => proxy.copy(encoding = Some(v))) + .getOrElse(proxy) + } + .map { proxy => + (json \ "nonProxyHosts").asOpt[Seq[String]].map(v => proxy.copy(nonProxyHosts = Some(v))).getOrElse(proxy) + } + } + case _ => None + } + } +} + +case class TlsConfig( + certs: Seq[String] = Seq.empty, + trustedCerts: Seq[String] = Seq.empty, + enabled: Boolean = false, + loose: Boolean = false, + trustAll: Boolean = false + ) { + def json: JsValue = TlsConfig.format.writes(this) +} + +object TlsConfig { + val default = TlsConfig() + val format = new Format[TlsConfig] { + override def reads(json: JsValue): JsResult[TlsConfig] = { + Try { + TlsConfig( + certs = (json \ "certs") + .asOpt[Seq[String]] + .orElse((json \ "certId").asOpt[String].map(v => Seq(v))) + .orElse((json \ "cert_id").asOpt[String].map(v => Seq(v))) + .map(_.filter(_.trim.nonEmpty)) + .getOrElse(Seq.empty), + trustedCerts = (json \ "trusted_certs") + .asOpt[Seq[String]] + .map(_.filter(_.trim.nonEmpty)) + .getOrElse(Seq.empty), + enabled = (json \ "enabled").asOpt[Boolean].getOrElse(false), + loose = (json \ "loose").asOpt[Boolean].getOrElse(false), + trustAll = (json \ "trust_all").asOpt[Boolean].getOrElse(false) + ) + } match { + case Failure(e) => JsError(e.getMessage()) + case Success(v) => JsSuccess(v) + } + } + + override def writes(o: TlsConfig): JsValue = { + Json.obj( + "certs" -> JsArray(o.certs.map(JsString.apply)), + "trusted_certs" -> JsArray(o.trustedCerts.map(JsString.apply)), + "enabled" -> o.enabled, + "loose" -> o.loose, + "trust_all" -> o.trustAll + ) + } + } +} \ No newline at end of file diff --git a/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/wasm.scala b/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/wasm.scala new file mode 100644 index 0000000000..1261c31721 --- /dev/null +++ b/experiments/common-wasm/src/main/scala/io/otoroshi/common/wasm/wasm.scala @@ -0,0 +1,397 @@ +package io.otoroshi.common.wasm + +import akka.util.ByteString +import io.otoroshi.common.utils.implicits._ +import org.extism.sdk.wasmotoroshi._ +import play.api.libs.json._ + +import java.nio.file.{Files, Paths} +import scala.concurrent._ +import scala.concurrent.duration._ +import scala.util._ + +case class WasmDataRights(read: Boolean = false, write: Boolean = false) + +object WasmDataRights { + def fmt = + new Format[WasmDataRights] { + override def writes(o: WasmDataRights) = + Json.obj( + "read" -> o.read, + "write" -> o.write + ) + + override def reads(json: JsValue) = + Try { + JsSuccess( + WasmDataRights( + read = (json \ "read").asOpt[Boolean].getOrElse(false), + write = (json \ "write").asOpt[Boolean].getOrElse(false) + ) + ) + } recover { case e => + JsError(e.getMessage) + } get + } +} + +sealed trait WasmSourceKind { + def name: String + def json: JsValue = JsString(name) + def getWasm(path: String, opts: JsValue)(implicit ic: WasmIntegrationContext, ec: ExecutionContext): Future[Either[JsValue, ByteString]] + def getConfig(path: String, opts: JsValue)(implicit ic: WasmIntegrationContext, ec: ExecutionContext): Future[Option[WasmConfiguration]] = + None.vfuture +} + +object WasmSourceKind { + case object Unknown extends WasmSourceKind { + def name: String = "Unknown" + def getWasm(path: String, opts: JsValue)(implicit + ic: WasmIntegrationContext, + ec: ExecutionContext + ): Future[Either[JsValue, ByteString]] = { + Left(Json.obj("error" -> "unknown source")).vfuture + } + } + case object Base64 extends WasmSourceKind { + def name: String = "Base64" + def getWasm(path: String, opts: JsValue)(implicit + ic: WasmIntegrationContext, + ec: ExecutionContext + ): Future[Either[JsValue, ByteString]] = { + ByteString(path.replace("base64://", "")).decodeBase64.right.future + } + } + case object Http extends WasmSourceKind { + def name: String = "Http" + def getWasm(path: String, opts: JsValue)(implicit + ic: WasmIntegrationContext, + ec: ExecutionContext + ): Future[Either[JsValue, ByteString]] = { + val method = opts.select("method").asOpt[String].getOrElse("GET") + val headers = opts.select("headers").asOpt[Map[String, String]].getOrElse(Map.empty) + val timeout = opts.select("timeout").asOpt[Long].getOrElse(10000L).millis + val followRedirect = opts.select("followRedirect").asOpt[Boolean].getOrElse(true) + val proxy = opts.select("proxy").asOpt[JsObject].flatMap(v => WSProxyServerJson.proxyFromJson(v)) + val tlsConfig: Option[TlsConfig] = + opts.select("tls").asOpt(TlsConfig.format).orElse(opts.select("tls").asOpt(TlsConfig.format)) + (tlsConfig match { + case None => ic.url(path) + case Some(cfg) => ic.mtlsUrl(path, cfg) + }) + .withMethod(method) + .withFollowRedirects(followRedirect) + .withHttpHeaders(headers.toSeq: _*) + .withRequestTimeout(timeout) + .applyOnWithOpt(proxy) { case (req, proxy) => + req.withProxyServer(proxy) + } + .execute() + .map { resp => + if (resp.status == 200) { + val body = resp.bodyAsBytes + Right(body) + } else { + val body: String = resp.body + Left( + Json.obj( + "error" -> "bad response", + "status" -> resp.status, + "headers" -> resp.headers.mapValues(_.last), + "body" -> body + ) + ) + } + } + } + } + case object WasmManager extends WasmSourceKind { + def name: String = "WasmManager" + def getWasm(path: String, opts: JsValue)(implicit + ic: WasmIntegrationContext, + ec: ExecutionContext + ): Future[Either[JsValue, ByteString]] = { + ic.wasmManagerSettings.flatMap { + case Some(WasmManagerSettings(url, clientId, clientSecret, kind)) => { + ic.url(s"$url/wasm/$path") + .withFollowRedirects(false) + .withRequestTimeout(FiniteDuration(5 * 1000, MILLISECONDS)) + .withHttpHeaders( + "Accept" -> "application/json", + "Otoroshi-Client-Id" -> clientId, + "Otoroshi-Client-Secret" -> clientSecret, + "kind" -> kind.getOrElse("*") + ) + .get() + .flatMap { resp => + if (resp.status == 400) { + Left(Json.obj("error" -> "missing signed plugin url")).vfuture + } else { + Right(resp.bodyAsBytes).vfuture + } + } + } + case _ => + Left(Json.obj("error" -> "missing wasm manager url")).vfuture + } + } + } + case object Local extends WasmSourceKind { + def name: String = "Local" + override def getWasm(path: String, opts: JsValue)(implicit + ic: WasmIntegrationContext, + ec: ExecutionContext + ): Future[Either[JsValue, ByteString]] = { + ic.wasmConfig(path) match { + case None => Left(Json.obj("error" -> "resource not found")).vfuture + case Some(config) => config.source.getWasm() + } + } + override def getConfig(path: String, opts: JsValue)(implicit + ic: WasmIntegrationContext, + ec: ExecutionContext + ): Future[Option[WasmConfiguration]] = { + ic.wasmConfig(path).vfuture + } + } + case object File extends WasmSourceKind { + def name: String = "File" + def getWasm(path: String, opts: JsValue)(implicit + ic: WasmIntegrationContext, + ec: ExecutionContext + ): Future[Either[JsValue, ByteString]] = { + Right(ByteString(Files.readAllBytes(Paths.get(path.replace("file://", ""))))).vfuture + } + } + + def apply(value: String): WasmSourceKind = value.toLowerCase match { + case "base64" => Base64 + case "http" => Http + case "wasmmanager" => WasmManager + case "local" => Local + case "file" => File + case _ => Unknown + } +} + +case class WasmSource(kind: WasmSourceKind, path: String, opts: JsValue = Json.obj()) { + def json: JsValue = WasmSource.format.writes(this) + def cacheKey = s"${kind.name.toLowerCase}://${path}" + def getConfig()(implicit ic: WasmIntegrationContext, ec: ExecutionContext): Future[Option[WasmConfiguration]] = kind.getConfig(path, opts) + def isCached()(implicit ic: WasmIntegrationContext): Boolean = { + val cache = ic.wasmScriptCache + cache.get(cacheKey) match { + case Some(CacheableWasmScript.CachedWasmScript(_, _)) => true + case _ => false + } + } + def getWasm()(implicit ic: WasmIntegrationContext, ec: ExecutionContext): Future[Either[JsValue, ByteString]] = try { + val cache = ic.wasmScriptCache + def fetchAndAddToCache(): Future[Either[JsValue, ByteString]] = { + val promise = Promise[Either[JsValue, ByteString]]() + cache.put(cacheKey, CacheableWasmScript.FetchingWasmScript(promise.future)) + kind.getWasm(path, opts).map { + case Left(err) => + promise.trySuccess(err.left) + err.left + case Right(bs) => { + cache.put(cacheKey, CacheableWasmScript.CachedWasmScript(bs, System.currentTimeMillis())) + promise.trySuccess(bs.right) + bs.right + } + } + } + cache.get(cacheKey) match { + case None => fetchAndAddToCache() + case Some(CacheableWasmScript.FetchingWasmScript(fu)) => fu + case Some(CacheableWasmScript.CachedWasmScript(script, createAt)) + if createAt + ic.wasmCacheTtl < System.currentTimeMillis => + fetchAndAddToCache() + script.right.vfuture + case Some(CacheableWasmScript.CachedWasmScript(script, _)) => script.right.vfuture + } + } catch { + case e: Throwable => + e.printStackTrace() + Future.failed(e) + } +} + +object WasmSource { + val format = new Format[WasmSource] { + override def writes(o: WasmSource): JsValue = Json.obj( + "kind" -> o.kind.json, + "path" -> o.path, + "opts" -> o.opts + ) + override def reads(json: JsValue): JsResult[WasmSource] = Try { + WasmSource( + kind = json.select("kind").asOpt[String].map(WasmSourceKind.apply).getOrElse(WasmSourceKind.Unknown), + path = json.select("path").asString, + opts = json.select("opts").asOpt[JsValue].getOrElse(Json.obj()) + ) + } match { + case Success(s) => JsSuccess(s) + case Failure(e) => JsError(e.getMessage) + } + } +} + +sealed trait WasmVmLifetime { + def name: String + def json: JsValue = JsString(name) +} + +object WasmVmLifetime { + + case object Invocation extends WasmVmLifetime { def name: String = "Invocation" } + case object Request extends WasmVmLifetime { def name: String = "Request" } + case object Forever extends WasmVmLifetime { def name: String = "Forever" } + + def parse(str: String): Option[WasmVmLifetime] = str.toLowerCase() match { + case "invocation" => Invocation.some + case "request" => Request.some + case "forever" => Forever.some + case _ => None + } +} + +trait WasmConfiguration { + def source: WasmSource + def memoryPages: Int + def functionName: Option[String] + def config: Map[String, String] + def allowedHosts: Seq[String] + def allowedPaths: Map[String, String] + def wasi: Boolean + def opa: Boolean + def instances: Int + def killOptions: WasmVmKillOptions + def json: JsValue + def pool()(implicit ic: WasmIntegrationContext): WasmVmPool = WasmVmPool.forConfig(this) +} + +/* +case class WasmConfig( + source: WasmSource = WasmSource(WasmSourceKind.Unknown, "", Json.obj()), + memoryPages: Int = 20, + functionName: Option[String] = None, + config: Map[String, String] = Map.empty, + allowedHosts: Seq[String] = Seq.empty, + allowedPaths: Map[String, String] = Map.empty, + //// + // lifetime: WasmVmLifetime = WasmVmLifetime.Forever, + wasi: Boolean = false, + opa: Boolean = false, + instances: Int = 1, + killOptions: WasmVmKillOptions = WasmVmKillOptions.default, + authorizations: JsValue = Json.obj() +) { + // still here for compat reason + def lifetime: WasmVmLifetime = WasmVmLifetime.Forever + def pool()(implicit ic: WasmIntegrationContext): WasmVmPool = WasmVmPool.forConfig(this) + def json: JsValue = Json.obj( + "source" -> source.json, + "memoryPages" -> memoryPages, + "functionName" -> functionName, + "config" -> config, + "allowedHosts" -> allowedHosts, + "allowedPaths" -> allowedPaths, + "wasi" -> wasi, + "opa" -> opa, + // "lifetime" -> lifetime.json, + "authorizations" -> authorizations, + "instances" -> instances, + "killOptions" -> killOptions.json + ) +} + +object WasmConfig { + val format = new Format[WasmConfig] { + override def reads(json: JsValue): JsResult[WasmConfig] = Try { + val compilerSource = json.select("compiler_source").asOpt[String] + val rawSource = json.select("raw_source").asOpt[String] + val sourceOpt = json.select("source").asOpt[JsObject] + val source = if (sourceOpt.isDefined) { + WasmSource.format.reads(sourceOpt.get).get + } else { + compilerSource match { + case Some(source) => WasmSource(WasmSourceKind.WasmManager, source) + case None => + rawSource match { + case Some(source) if source.startsWith("http://") => WasmSource(WasmSourceKind.Http, source) + case Some(source) if source.startsWith("https://") => WasmSource(WasmSourceKind.Http, source) + case Some(source) if source.startsWith("file://") => + WasmSource(WasmSourceKind.File, source.replace("file://", "")) + case Some(source) if source.startsWith("base64://") => + WasmSource(WasmSourceKind.Base64, source.replace("base64://", "")) + case Some(source) if source.startsWith("entity://") => + WasmSource(WasmSourceKind.Local, source.replace("entity://", "")) + case Some(source) if source.startsWith("local://") => + WasmSource(WasmSourceKind.Local, source.replace("local://", "")) + case Some(source) => WasmSource(WasmSourceKind.Base64, source) + case _ => WasmSource(WasmSourceKind.Unknown, "") + } + } + } + WasmConfig( + source = source, + memoryPages = (json \ "memoryPages").asOpt[Int].getOrElse(20), + functionName = (json \ "functionName").asOpt[String].filter(_.nonEmpty), + config = (json \ "config").asOpt[Map[String, String]].getOrElse(Map.empty), + allowedHosts = (json \ "allowedHosts").asOpt[Seq[String]].getOrElse(Seq.empty), + allowedPaths = (json \ "allowedPaths").asOpt[Map[String, String]].getOrElse(Map.empty), + wasi = (json \ "wasi").asOpt[Boolean].getOrElse(false), + opa = (json \ "opa").asOpt[Boolean].getOrElse(false), + // lifetime = json + // .select("lifetime") + // .asOpt[String] + // .flatMap(WasmVmLifetime.parse) + // .orElse( + // (json \ "preserve").asOpt[Boolean].map { + // case true => WasmVmLifetime.Request + // case false => WasmVmLifetime.Forever + // } + // ) + // .getOrElse(WasmVmLifetime.Forever), + authorizations = (json \ "authorizations").asOpt[JsObject].getOrElse(Json.obj()), + instances = json.select("instances").asOpt[Int].getOrElse(1), + killOptions = json + .select("killOptions") + .asOpt[JsValue] + .flatMap(v => WasmVmKillOptions.format.reads(v).asOpt) + .getOrElse(WasmVmKillOptions.default) + ) + } match { + case Failure(ex) => JsError(ex.getMessage) + case Success(value) => JsSuccess(value) + } + override def writes(o: WasmConfig): JsValue = o.json + } +} +*/ + +object ResultsWrapper { + def apply(results: WasmOtoroshiResults): ResultsWrapper = new ResultsWrapper(results, None) + def apply(results: WasmOtoroshiResults, plugin: WasmOtoroshiInstance): ResultsWrapper = + new ResultsWrapper(results, Some(plugin)) +} + +case class ResultsWrapper(results: WasmOtoroshiResults, pluginOpt: Option[WasmOtoroshiInstance]) { + def free(): Unit = try { + if (results.getLength > 0) { + results.close() + } + } catch { + case t: Throwable => + t.printStackTrace() + () + } +} + +sealed trait CacheableWasmScript + +object CacheableWasmScript { + case class CachedWasmScript(script: ByteString, createAt: Long) extends CacheableWasmScript + case class FetchingWasmScript(f: Future[Either[JsValue, ByteString]]) extends CacheableWasmScript +} diff --git a/experiments/common-wasm/src/test/scala/io/otoroshi/common/wasm/test/WasmSpec.scala b/experiments/common-wasm/src/test/scala/io/otoroshi/common/wasm/test/WasmSpec.scala new file mode 100644 index 0000000000..0c53a8b0ae --- /dev/null +++ b/experiments/common-wasm/src/test/scala/io/otoroshi/common/wasm/test/WasmSpec.scala @@ -0,0 +1,86 @@ +package io.otoroshi.common.wasm.test + +import akka.actor.ActorSystem +import akka.stream.Materializer +import io.otoroshi.common.utils.implicits.{BetterJsValue, BetterSyntax} +import io.otoroshi.common.wasm.{CacheableWasmScript, TlsConfig, WasmConfiguration, WasmFunctionParameters, WasmIntegration, WasmIntegrationContext, WasmManagerSettings, WasmSource, WasmSourceKind, WasmVmKillOptions} +import org.extism.sdk.wasmotoroshi.{WasmOtoroshiHostFunction, WasmOtoroshiHostUserData} +import play.api.Logger +import play.api.libs.json.{JsValue, Json} +import play.api.libs.ws.WSRequest + +import java.util.concurrent.Executors +import scala.collection.concurrent.TrieMap +import scala.concurrent.duration.DurationInt +import scala.concurrent.{Await, ExecutionContext, Future} + +class WasmSpec extends munit.FunSuite { + + test("basic setup should work") { + + val testWasmConfigs: Map[String, WasmConfiguration] = Map( + "basic" -> new WasmConfiguration { + override def source: WasmSource = WasmSource(WasmSourceKind.File, "/Users/mathieuancelin/Downloads/8d7cd235-96b5-4823-b08e-77402471507c.wasm") + override def memoryPages: Int = 4 + override def functionName: Option[String] = "execute".some + override def config: Map[String, String] = Map.empty + override def allowedHosts: Seq[String] = Seq.empty + override def allowedPaths: Map[String, String] = Map.empty + override def wasi: Boolean = true + override def opa: Boolean = false + override def instances: Int = 1 + override def killOptions: WasmVmKillOptions = WasmVmKillOptions.default + override def json: JsValue = Json.obj() + } + ) + + implicit val ctx = new WasmIntegrationContext { + + val system = ActorSystem("test-common-wasm") + val materializer: Materializer = Materializer(system) + val executionContext: ExecutionContext = system.dispatcher + val logger: Logger = Logger("test-common-wasm") + val wasmCacheTtl: Long = 2000 + val wasmQueueBufferSize: Int = 100 + val wasmManagerSettings: Future[Option[WasmManagerSettings]] = Future.successful(None) + val wasmScriptCache: TrieMap[String, CacheableWasmScript] = new TrieMap[String, CacheableWasmScript]() + val wasmExecutor: ExecutionContext = ExecutionContext.fromExecutorService( + Executors.newWorkStealingPool(Math.max(32, (Runtime.getRuntime.availableProcessors * 4) + 1)) + ) + + override def url(path: String): WSRequest = ??? + override def mtlsUrl(path: String, tlsConfig: TlsConfig): WSRequest = ??? + + override def wasmConfig(path: String): Option[WasmConfiguration] = testWasmConfigs.get(path) + override def wasmConfigs(): Seq[WasmConfiguration] = testWasmConfigs.values.toSeq + override def hostFunctions(config: WasmConfiguration, pluginId: String): Array[WasmOtoroshiHostFunction[_ <: WasmOtoroshiHostUserData]] = Array.empty + } + + implicit val ec = ctx.executionContext + + val integration = new WasmIntegration(ctx) + integration.runVmLoaderJob() + + val fu = integration.wasmVmById("basic").flatMap { + case Some((vm, _)) => { + vm.call( + WasmFunctionParameters.ExtismFuntionCall( + "execute", + Json.obj("message" -> "coucou").stringify + ), + None + ).map { + case Left(error) => println(s"error: ${error.prettify}") + case Right((out, wrapper)) => { + assertEquals(out, "{\"input\":{\"message\":\"coucou\"},\"message\":\"yo\"}") + println(s"output: ${out}") + } + } + } + case _ => + println("vm not found !") + ().vfuture + } + Await.result(fu, 10.seconds) + } +} diff --git a/experiments/common-wasm/update-extism.sh b/experiments/common-wasm/update-extism.sh new file mode 100644 index 0000000000..71599eb906 --- /dev/null +++ b/experiments/common-wasm/update-extism.sh @@ -0,0 +1,43 @@ +# EXTISM_REPO=extism +EXTISM_REPO=MAIF + +EXTISM_VERSION=$(curl https://api.github.com/repos/${EXTISM_REPO}/extism/releases/latest | jq -r '.name') + +echo "latest extism version is: ${EXTISM_VERSION}" + +rm -rfv ./src/main/resources/native +rm -rfv ./src/main/resources/darwin-* +rm -rfv ./src/main/resources/linux-* + +mkdir ./src/main/resources/native + +curl -L -o "./src/main/resources/native/libextism-aarch64-apple-darwin-${EXTISM_VERSION}.tar.gz" "https://github.com/${EXTISM_REPO}/extism/releases/download/${EXTISM_VERSION}/libextism-aarch64-apple-darwin-${EXTISM_VERSION}.tar.gz" +curl -L -o "./src/main/resources/native/libextism-aarch64-apple-darwin-${EXTISM_VERSION}.tar.gz" "https://github.com/${EXTISM_REPO}/extism/releases/download/${EXTISM_VERSION}/libextism-aarch64-apple-darwin-${EXTISM_VERSION}.tar.gz" +curl -L -o "./src/main/resources/native/libextism-x86_64-unknown-linux-gnu-${EXTISM_VERSION}.tar.gz" "https://github.com/${EXTISM_REPO}/extism/releases/download/${EXTISM_VERSION}/libextism-x86_64-unknown-linux-gnu-${EXTISM_VERSION}.tar.gz" +curl -L -o "./src/main/resources/native/libextism-aarch64-unknown-linux-gnu-${EXTISM_VERSION}.tar.gz" "https://github.com/${EXTISM_REPO}/extism/releases/download/${EXTISM_VERSION}/libextism-aarch64-unknown-linux-gnu-${EXTISM_VERSION}.tar.gz" +curl -L -o "./src/main/resources/native/libextism-x86_64-apple-darwin-${EXTISM_VERSION}.tar.gz" "https://github.com/${EXTISM_REPO}/extism/releases/download/${EXTISM_VERSION}/libextism-x86_64-apple-darwin-${EXTISM_VERSION}.tar.gz" +curl -L -o "./lib/extism-${EXTISM_VERSION}.jar" "https://github.com/${EXTISM_REPO}/extism/releases/download/${EXTISM_VERSION}/extism-0.4.0.jar" + +mkdir ./src/main/resources/darwin-aarch64 +mkdir ./src/main/resources/darwin-x86-64 +mkdir ./src/main/resources/linux-aarch64 +mkdir ./src/main/resources/linux-x86-64 + +tar -xvf "./src/main/resources/native/libextism-aarch64-apple-darwin-${EXTISM_VERSION}.tar.gz" --directory ./src/main/resources/native/ +mv ./src/main/resources/native/libextism.dylib ./src/main/resources/darwin-aarch64/libextism.dylib +tar -xvf "./src/main/resources/native/libextism-x86_64-apple-darwin-${EXTISM_VERSION}.tar.gz" --directory ./src/main/resources/native/ +mv ./src/main/resources/native/libextism.dylib ./src/main/resources/darwin-x86-64/libextism.dylib + +tar -xvf "./src/main/resources/native/libextism-aarch64-unknown-linux-gnu-${EXTISM_VERSION}.tar.gz" --directory ./src/main/resources/native/ +mv ./src/main/resources/native/libextism.so ./src/main/resources/linux-aarch64/libextism.so +tar -xvf "./src/main/resources/native/libextism-x86_64-unknown-linux-gnu-${EXTISM_VERSION}.tar.gz" --directory ./src/main/resources/native/ +mv ./src/main/resources/native/libextism.so ./src/main/resources/linux-x86-64/libextism.so +rm -rfv ./src/main/resources/native + + +# curl -L -o "./src/main/resources/native/libextism-aarch64-unknown-linux-musl-${EXTISM_VERSION}.tar.gz" "https://github.com/extism/extism/releases/download/${EXTISM_VERSION}/libextism-aarch64-unknown-linux-musl-${EXTISM_VERSION}.tar.gz" +# tar -xvf "./src/main/resources/native/libextism-aarch64-unknown-linux-musl-${EXTISM_VERSION}.tar.gz" --directory ./src/main/resources/native/ +# curl -L -o "./src/main/resources/native/libextism-x86_64-pc-windows-gnu-${EXTISM_VERSION}.tar.gz" "https://github.com/extism/extism/releases/download/${EXTISM_VERSION}/libextism-x86_64-pc-windows-gnu-${EXTISM_VERSION}.tar.gz" +# curl -L -o "./src/main/resources/native/libextism-x86_64-pc-windows-msvc-${EXTISM_VERSION}.tar.gz" "https://github.com/extism/extism/releases/download/${EXTISM_VERSION}/libextism-x86_64-pc-windows-msvc-${EXTISM_VERSION}.tar.gz" +# mv ./src/main/resources/native/libextism.so ./src/main/resources/native/libextism-aarch64-unknown-linux-musl.so +# tar -xvf "./src/main/resources/native/libextism-x86_64-pc-windows-gnu-${EXTISM_VERSION}.tar.gz" --directory ./src/main/resources/native/ \ No newline at end of file diff --git a/manual/src/main/paradox/code/openapi.json b/manual/src/main/paradox/code/openapi.json index bd52ca8f8b..6ab1dec28c 100644 --- a/manual/src/main/paradox/code/openapi.json +++ b/manual/src/main/paradox/code/openapi.json @@ -20603,16 +20603,6 @@ "$ref" : "#/components/schemas/otoroshi.models.AutoCert", "description" : "Auto certs settings" }, - "wasmManagerSettings" : { - "oneOf" : [ { - "type" : "string", - "nullable" : true, - "description" : "null type" - }, { - "$ref" : "#/components/schemas/otoroshi.models.WasmManagerSettings" - } ], - "description" : "???" - }, "maintenanceMode" : { "type" : "boolean", "description" : "Global maintenant mode" @@ -22002,34 +21992,6 @@ "type" : "object", "description" : "Settings to use keypair from JWKS for verification" }, - "otoroshi.models.WasmManagerSettings" : { - "type" : "object", - "description" : "???", - "properties" : { - "url" : { - "type" : "string", - "description" : "???" - }, - "clientId" : { - "type" : "string", - "description" : "???" - }, - "clientSecret" : { - "type" : "string", - "description" : "???" - }, - "pluginsFilter" : { - "oneOf" : [ { - "type" : "string", - "nullable" : true, - "description" : "null type" - }, { - "type" : "string" - } ], - "description" : "???" - } - } - }, "otoroshi.ssl.pki.models.GenCsrResponse" : { "type" : "object", "description" : "Response for a csr generation operation", diff --git a/otoroshi/app/auth/wasm.scala b/otoroshi/app/auth/wasm.scala index e310f06846..9fc1b9621c 100644 --- a/otoroshi/app/auth/wasm.scala +++ b/otoroshi/app/auth/wasm.scala @@ -1,5 +1,6 @@ package otoroshi.auth +import io.otoroshi.common.wasm.WasmFunctionParameters import otoroshi.env.Env import otoroshi.gateway.Errors import otoroshi.models._ @@ -10,7 +11,6 @@ import otoroshi.next.utils.JsonHelpers import otoroshi.security.IdGenerator import otoroshi.utils.{JsonPathValidator, TypedMap} import otoroshi.utils.syntax.implicits._ -import otoroshi.wasm.{WasmFunctionParameters, WasmUtils, WasmVm} import play.api.Logger import play.api.libs.json._ import play.api.mvc._ @@ -129,7 +129,7 @@ class WasmAuthModule(val authConfig: WasmAuthModuleConfig) extends AuthModule { "is_route" -> isRoute ) val ctx = WasmAuthModuleContext(authConfig.json, route) - WasmVm.fromConfig(plugin.config).flatMap { + env.wasmIntegration.wasmVmFor(plugin.config).flatMap { case None => Errors .craftResponseResult( @@ -142,6 +142,7 @@ class WasmAuthModule(val authConfig: WasmAuthModuleConfig) extends AuthModule { maybeRoute = ctx.route.some ) case Some((vm, _)) => + implicit val ctx = env.wasmIntegrationCtx vm.call(WasmFunctionParameters.ExtismFuntionCall("pa_login_page", input.stringify), None) .map { case Left(err) => Results.InternalServerError(err) @@ -196,7 +197,7 @@ class WasmAuthModule(val authConfig: WasmAuthModuleConfig) extends AuthModule { "user" -> user.map(_.json).getOrElse(JsNull).asValue ) val ctx = WasmAuthModuleContext(authConfig.json, route) - WasmVm.fromConfig(plugin.config).flatMap { + env.wasmIntegration.wasmVmFor(plugin.config).flatMap { case None => Errors .craftResponseResult( @@ -210,6 +211,7 @@ class WasmAuthModule(val authConfig: WasmAuthModuleConfig) extends AuthModule { ) .map(_.left) case Some((vm, _)) => + implicit val ctx = env.wasmIntegrationCtx vm.call(WasmFunctionParameters.ExtismFuntionCall("pa_logout", input.stringify), None) .map { case Left(err) => Results.InternalServerError(err).left @@ -253,9 +255,10 @@ class WasmAuthModule(val authConfig: WasmAuthModuleConfig) extends AuthModule { "route" -> route.json ) val ctx = WasmAuthModuleContext(authConfig.json, route) - WasmVm.fromConfig(plugin.config).flatMap { + env.wasmIntegration.wasmVmFor(plugin.config).flatMap { case None => "plugin not found !".leftf case Some((vm, _)) => + implicit val ctx = env.wasmIntegrationCtx vm.call(WasmFunctionParameters.ExtismFuntionCall("pa_callback", input.stringify), None) .map { case Left(err) => err.stringify.left @@ -294,7 +297,7 @@ class WasmAuthModule(val authConfig: WasmAuthModuleConfig) extends AuthModule { "global_config" -> config.json ) val ctx = WasmAuthModuleContext(authConfig.json, NgRoute.empty) - WasmVm.fromConfig(plugin.config).flatMap { + env.wasmIntegration.wasmVmFor(plugin.config).flatMap { case None => Errors .craftResponseResult( @@ -307,6 +310,7 @@ class WasmAuthModule(val authConfig: WasmAuthModuleConfig) extends AuthModule { maybeRoute = ctx.route.some ) case Some((vm, _)) => + implicit val ctx = env.wasmIntegrationCtx vm.call(WasmFunctionParameters.ExtismFuntionCall("bo_login_page", input.stringify), None) .map { case Left(err) => Results.InternalServerError(err) @@ -356,7 +360,7 @@ class WasmAuthModule(val authConfig: WasmAuthModuleConfig) extends AuthModule { "user" -> user.json ) val ctx = WasmAuthModuleContext(authConfig.json, NgRoute.empty) - WasmVm.fromConfig(plugin.config).flatMap { + env.wasmIntegration.wasmVmFor(plugin.config).flatMap { case None => Errors .craftResponseResult( @@ -370,6 +374,7 @@ class WasmAuthModule(val authConfig: WasmAuthModuleConfig) extends AuthModule { ) .map(_.left) case Some((vm, _)) => + implicit val ctx = env.wasmIntegrationCtx vm.call(WasmFunctionParameters.ExtismFuntionCall("bo_logout", input.stringify), None) .map { case Left(err) => Results.InternalServerError(err).left @@ -410,9 +415,10 @@ class WasmAuthModule(val authConfig: WasmAuthModuleConfig) extends AuthModule { "global_config" -> config.json ) val ctx = WasmAuthModuleContext(authConfig.json, NgRoute.empty) - WasmVm.fromConfig(plugin.config).flatMap { + env.wasmIntegration.wasmVmFor(plugin.config).flatMap { case None => "plugin not found !".leftf case Some((vm, _)) => + implicit val ctx = env.wasmIntegrationCtx vm.call(WasmFunctionParameters.ExtismFuntionCall("bo_callback", input.stringify), None) .map { case Left(err) => err.stringify.left diff --git a/otoroshi/app/controllers/BackOfficeController.scala b/otoroshi/app/controllers/BackOfficeController.scala index a80b14d901..af00fc8938 100644 --- a/otoroshi/app/controllers/BackOfficeController.scala +++ b/otoroshi/app/controllers/BackOfficeController.scala @@ -10,6 +10,7 @@ import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm import com.google.common.base.Charsets import com.nimbusds.jose.jwk.KeyType +import io.otoroshi.common.wasm.WasmManagerSettings import org.joda.time.DateTime import org.mindrot.jbcrypt.BCrypt import org.slf4j.LoggerFactory diff --git a/otoroshi/app/env/Env.scala b/otoroshi/app/env/Env.scala index 264026df41..fa639df389 100644 --- a/otoroshi/app/env/Env.scala +++ b/otoroshi/app/env/Env.scala @@ -8,6 +8,7 @@ import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm import com.typesafe.config.{ConfigFactory, ConfigRenderOptions} import io.netty.util.internal.PlatformDependent +import io.otoroshi.common.wasm.WasmIntegration import otoroshi.metrics.{HasMetrics, Metrics} import org.joda.time.DateTime import org.mindrot.jbcrypt.BCrypt @@ -40,6 +41,7 @@ import otoroshi.tcp.TcpService import otoroshi.utils.JsonPathValidator import otoroshi.utils.http.{AkkWsClient, WsClientChooser} import otoroshi.utils.syntax.implicits._ +import otoroshi.wasm.OtoroshiWasmIntegrationContext import play.api._ import play.api.http.HttpConfiguration import play.api.inject.ApplicationLifecycle @@ -981,6 +983,9 @@ class Env( lazy val adminExtensions = AdminExtensions.current(this, adminExtensionsConfig) + lazy val wasmIntegrationCtx = new OtoroshiWasmIntegrationContext(this) + lazy val wasmIntegration = new WasmIntegration(wasmIntegrationCtx) + datastores.before(configuration, environment, lifecycle) // geoloc.start() // ua.start() diff --git a/otoroshi/app/events/OtoroshiEventsActor.scala b/otoroshi/app/events/OtoroshiEventsActor.scala index 675039ccfc..93812080cb 100644 --- a/otoroshi/app/events/OtoroshiEventsActor.scala +++ b/otoroshi/app/events/OtoroshiEventsActor.scala @@ -8,14 +8,7 @@ import akka.actor.{Actor, Props} import akka.http.scaladsl.model.{ContentType, ContentTypes} import akka.http.scaladsl.util.FastFuture import akka.stream.alpakka.s3.scaladsl.S3 -import akka.stream.alpakka.s3.{ - ApiVersion, - ListBucketResultContents, - MemoryBufferType, - MetaHeaders, - S3Attributes, - S3Settings -} +import akka.stream.alpakka.s3.{ApiVersion, ListBucketResultContents, MemoryBufferType, MetaHeaders, S3Attributes, S3Settings} import akka.stream.scaladsl.{Keep, Sink, Source, SourceQueueWithComplete} import akka.stream.{Attributes, OverflowStrategy, QueueOfferResult} import com.sksamuel.pulsar4s.Producer @@ -32,6 +25,7 @@ import io.opentelemetry.sdk.metrics.SdkMeterProvider import io.opentelemetry.sdk.metrics.`export`.{MetricExporter, PeriodicMetricReader} import io.opentelemetry.sdk.resources.Resource import io.opentelemetry.semconv.resource.attributes.ResourceAttributes +import io.otoroshi.common.wasm.WasmFunctionParameters import otoroshi.env.Env import otoroshi.events.DataExporter.DefaultDataExporter import otoroshi.events.impl.{ElasticWritesAnalytics, WebHookAnalytics} @@ -50,27 +44,14 @@ import otoroshi.utils.cache.types.UnboundedTrieMap import otoroshi.utils.json.JsonOperationsHelper import otoroshi.utils.mailer.{EmailLocation, MailerSettings} import play.api.Logger -import play.api.libs.json.{ - Format, - JsArray, - JsBoolean, - JsError, - JsNull, - JsNumber, - JsObject, - JsResult, - JsString, - JsSuccess, - JsValue, - Json -} +import play.api.libs.json.{Format, JsArray, JsBoolean, JsError, JsNull, JsNumber, JsObject, JsResult, JsString, JsSuccess, JsValue, Json} import scala.collection.concurrent.TrieMap import scala.concurrent.duration._ import scala.concurrent.{ExecutionContext, Future, Promise} import scala.util.{Failure, Success, Try} import otoroshi.utils.syntax.implicits._ -import otoroshi.wasm.{WasmConfig, WasmFunctionParameters, WasmUtils, WasmVm} +import otoroshi.wasm.WasmConfig import software.amazon.awssdk.auth.credentials.{AwsBasicCredentials, StaticCredentialsProvider} import software.amazon.awssdk.regions.Region import software.amazon.awssdk.regions.providers.AwsRegionProvider @@ -1268,9 +1249,10 @@ object Exporters { "config" -> configUnsafe.json ) // println(s"call send: ${events.size}") - WasmVm.fromConfig(plugin.config).flatMap { + env.wasmIntegration.wasmVmFor(plugin.config).flatMap { case None => ExportResult.ExportResultFailure("plugin not found !").vfuture case Some((vm, _)) => + implicit val ic = env.wasmIntegrationCtx vm.call( WasmFunctionParameters .ExtismFuntionCall("export_events", (input ++ Json.obj("events" -> JsArray(events))).stringify), diff --git a/otoroshi/app/models/config.scala b/otoroshi/app/models/config.scala index 04c8e04f21..06ff65f675 100644 --- a/otoroshi/app/models/config.scala +++ b/otoroshi/app/models/config.scala @@ -1,6 +1,7 @@ package otoroshi.models import akka.http.scaladsl.util.FastFuture +import io.otoroshi.common.wasm.WasmManagerSettings import org.joda.time.DateTime import otoroshi.auth.AuthModuleConfig import otoroshi.env.Env @@ -501,6 +502,7 @@ object TlsSettings { } } +/* case class WasmManagerSettings( url: String = "http://localhost:5001", clientId: String = "admin-api-apikey-id", @@ -532,7 +534,7 @@ object WasmManagerSettings { case Success(ac) => JsSuccess(ac) } } -} +}*/ case class DefaultTemplates( route: Option[JsObject] = Json.obj().some, // Option[NgRoute], diff --git a/otoroshi/app/models/wasm.scala b/otoroshi/app/models/wasm.scala index 4d7ead4584..909f62f01a 100644 --- a/otoroshi/app/models/wasm.scala +++ b/otoroshi/app/models/wasm.scala @@ -1,5 +1,6 @@ package otoroshi.models +import io.otoroshi.common.wasm.WasmVmPool import otoroshi.env.Env import otoroshi.next.plugins.api.{NgPluginCategory, NgPluginVisibility, NgStep} import otoroshi.script._ @@ -12,7 +13,6 @@ import play.api.libs.json._ import scala.concurrent.duration.{DurationInt, FiniteDuration} import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success, Try} -import otoroshi.wasm.WasmVmPool case class WasmPlugin( id: String, @@ -31,7 +31,7 @@ case class WasmPlugin( override def theDescription: String = description override def theTags: Seq[String] = tags override def theMetadata: Map[String, String] = metadata - def pool()(implicit env: Env): WasmVmPool = WasmVmPool.forPlugin(this) + //def pool()(implicit env: Env): WasmVmPool = WasmVmPool.forPlugin(this.id)(env.wasmIntegrationCtx) } object WasmPlugin { @@ -125,18 +125,19 @@ class WasmPluginsCacheManager extends Job { override def predicate(ctx: JobContext, env: Env): Option[Boolean] = None override def jobRun(ctx: JobContext)(implicit env: Env, ec: ExecutionContext): Future[Unit] = { - env.proxyState.allWasmPlugins().foreach { plugin => - val now = System.currentTimeMillis() - WasmUtils.scriptCache(env).get(plugin.config.source.cacheKey) match { - case None => plugin.config.source.getWasm() - case Some(CacheableWasmScript.CachedWasmScript(_, createAt)) if (createAt + env.wasmCacheTtl) < now => - plugin.config.source.getWasm() - case Some(CacheableWasmScript.CachedWasmScript(_, createAt)) - if (createAt + env.wasmCacheTtl) > now && (createAt + env.wasmCacheTtl + 1000) < now => - plugin.config.source.getWasm() - case _ => () - } - } - funit + // env.proxyState.allWasmPlugins().foreach { plugin => + // val now = System.currentTimeMillis() + // WasmUtils.scriptCache(env).get(plugin.config.source.cacheKey) match { + // case None => plugin.config.source.getWasm() + // case Some(CacheableWasmScript.CachedWasmScript(_, createAt)) if (createAt + env.wasmCacheTtl) < now => + // plugin.config.source.getWasm() + // case Some(CacheableWasmScript.CachedWasmScript(_, createAt)) + // if (createAt + env.wasmCacheTtl) > now && (createAt + env.wasmCacheTtl + 1000) < now => + // plugin.config.source.getWasm() + // case _ => () + // } + // } + // funit + env.wasmIntegration.runVmLoaderJob() } } diff --git a/otoroshi/app/next/plugins/Keys.scala b/otoroshi/app/next/plugins/Keys.scala index 40206d068e..d11d4b9fd8 100644 --- a/otoroshi/app/next/plugins/Keys.scala +++ b/otoroshi/app/next/plugins/Keys.scala @@ -3,7 +3,6 @@ package otoroshi.next.plugins import otoroshi.models.{ApiKey, ApikeyTuple, JwtInjection} import otoroshi.next.models._ import otoroshi.next.proxy.NgExecutionReport -import otoroshi.wasm.{WasmContext, WasmVm} import play.api.libs.typedmap.TypedKey import play.api.mvc.Result @@ -23,6 +22,5 @@ object Keys { val BodyAlreadyConsumedKey = TypedKey[AtomicBoolean]("otoroshi.next.core.BodyAlreadyConsumed") val JwtInjectionKey = TypedKey[JwtInjection]("otoroshi.next.core.JwtInjection") val ResultTransformerKey = TypedKey[Function[Result, Future[Result]]]("otoroshi.next.core.ResultTransformer") - val WasmContextKey = TypedKey[WasmContext]("otoroshi.next.core.WasmContext") val ResponseAddHeadersKey = TypedKey[Seq[(String, String)]]("otoroshi.next.core.ResponseAddHeaders") } diff --git a/otoroshi/app/next/plugins/graphql.scala b/otoroshi/app/next/plugins/graphql.scala index 60c973e2fa..fe43719a79 100644 --- a/otoroshi/app/next/plugins/graphql.scala +++ b/otoroshi/app/next/plugins/graphql.scala @@ -7,6 +7,7 @@ import akka.util.ByteString import com.arakelian.jq.{ImmutableJqLibrary, ImmutableJqRequest} import com.github.blemale.scaffeine.Scaffeine import com.jayway.jsonpath.PathNotFoundException +import io.otoroshi.common.wasm.{WasmFunctionParameters, WasmSource, WasmSourceKind} import otoroshi.el.GlobalExpressionLanguage import otoroshi.env.Env import otoroshi.next.models.NgTreeRouter @@ -24,29 +25,7 @@ import sangria.ast._ import sangria.execution.deferred.DeferredResolver import sangria.execution.{ExceptionHandler, Executor, HandledException, QueryReducer} import sangria.parser.QueryParser -import sangria.schema.{ - Action, - AdditionalTypes, - AnyFieldResolver, - Argument, - AstDirectiveContext, - AstSchemaBuilder, - AstSchemaMaterializer, - BooleanType, - DefaultAstSchemaBuilder, - Directive, - DirectiveResolver, - FieldResolver, - InstanceCheck, - IntType, - IntrospectionSchemaBuilder, - ListInputType, - OptionInputType, - ResolverBasedAstSchemaBuilder, - ScalarType, - Schema, - StringType -} +import sangria.schema.{Action, AdditionalTypes, AnyFieldResolver, Argument, AstDirectiveContext, AstSchemaBuilder, AstSchemaMaterializer, BooleanType, DefaultAstSchemaBuilder, Directive, DirectiveResolver, FieldResolver, InstanceCheck, IntType, IntrospectionSchemaBuilder, ListInputType, OptionInputType, ResolverBasedAstSchemaBuilder, ScalarType, Schema, StringType} import sangria.util.tag.@@ import sangria.validation.{QueryValidator, ValueCoercionViolation, Violation} @@ -774,9 +753,10 @@ class GraphQLBackend extends NgBackendCall { configurationAccess = wasmConfigurationAccess.getOrElse(false) ) ) - WasmVm.fromConfig(wsmCfg).flatMap { + env.wasmIntegration.wasmVmFor(wsmCfg).flatMap { case None => Future.failed(WasmException("plugin not found !")) case Some((vm, _)) => + implicit val ic = env.wasmIntegrationCtx vm.call(WasmFunctionParameters.ExtismFuntionCall("execute", input.stringify), None) .map { case Right(output) => diff --git a/otoroshi/app/next/plugins/wasm.scala b/otoroshi/app/next/plugins/wasm.scala index df0162143e..a120f77dbc 100644 --- a/otoroshi/app/next/plugins/wasm.scala +++ b/otoroshi/app/next/plugins/wasm.scala @@ -4,6 +4,7 @@ import akka.Done import akka.stream.Materializer import akka.stream.scaladsl.Source import akka.util.ByteString +import io.otoroshi.common.wasm.{OPAWasmVm, WasmFunctionParameters, WasmVm} import otoroshi.env.Env import otoroshi.gateway.Errors import otoroshi.next.models.{NgMatchedRoute, NgRoute} @@ -106,13 +107,14 @@ class WasmRouteMatcher extends NgRouteMatcher { override def steps: Seq[NgStep] = Seq(NgStep.MatchRoute) override def matches(ctx: NgRouteMatcherContext)(implicit env: Env): Boolean = { - implicit val ec = WasmUtils.executor + implicit val ec = env.wasmIntegrationCtx.wasmExecutor val config = ctx .cachedConfig(internalName)(WasmConfig.format) .getOrElse(WasmConfig()) - val fu = WasmVm.fromConfig(config).flatMap { + val fu = env.wasmIntegration.wasmVmFor(config).flatMap { case None => Left(Json.obj("error" -> "plugin not found")).vfuture case Some((vm, localConfig)) => + implicit val ic = env.wasmIntegrationCtx vm.call( WasmFunctionParameters.ExtismFuntionCall( config.functionName.orElse(localConfig.functionName).getOrElse("matches_route"), @@ -156,7 +158,7 @@ class WasmPreRoute extends NgPreRouting { .cachedConfig(internalName)(WasmConfig.format) .getOrElse(WasmConfig()) val input = ctx.wasmJson - WasmVm.fromConfig(config).flatMap { + env.wasmIntegration.wasmVmFor(config).flatMap { case None => Errors .craftResponseResult( @@ -170,6 +172,7 @@ class WasmPreRoute extends NgPreRouting { ) .map(r => NgPreRoutingErrorWithResult(r).left) case Some((vm, localConfig)) => + implicit val ic = env.wasmIntegrationCtx vm.call( WasmFunctionParameters.ExtismFuntionCall( config.functionName.orElse(localConfig.functionName).getOrElse("pre_route"), @@ -239,10 +242,10 @@ class WasmBackend extends NgBackendCall { val config = ctx .cachedConfig(internalName)(WasmConfig.format) .getOrElse(WasmConfig()) - WasmUtils.debugLog.debug("callBackend") + //WasmUtils.debugLog.debug("callBackend") ctx.wasmJson .flatMap { input => - WasmVm.fromConfig(config).flatMap { + env.wasmIntegration.wasmVmFor(config).flatMap { case None => Errors .craftResponseResult( @@ -256,6 +259,7 @@ class WasmBackend extends NgBackendCall { ) .map(r => NgProxyEngineError.NgResultProxyEngineError(r).left) case Some((vm, localConfig)) => + implicit val ic = env.wasmIntegrationCtx vm.call( WasmFunctionParameters.ExtismFuntionCall( config.functionName.orElse(localConfig.functionName).getOrElse("call_backend"), @@ -392,7 +396,9 @@ class WasmAccessValidator extends NgAccessValidator { .cachedConfig(internalName)(WasmConfig.format) .getOrElse(WasmConfig()) - WasmVm.fromConfig(config).flatMap { + println("access") + + env.wasmIntegration.wasmVmFor(config).flatMap { case None => Errors .craftResponseResult( @@ -406,6 +412,8 @@ class WasmAccessValidator extends NgAccessValidator { ) .map(r => NgAccess.NgDenied(r)) case Some((vm, localConfig)) => + println("got vm") + implicit val ic = env.wasmIntegrationCtx vm.call( WasmFunctionParameters.ExtismFuntionCall( config.functionName.orElse(localConfig.functionName).getOrElse("access"), @@ -414,6 +422,7 @@ class WasmAccessValidator extends NgAccessValidator { None ).flatMap { case Right(res) => + println("res") val response = Json.parse(res._1) AttrsHelper.updateAttrs(ctx.attrs, response) val result = (response \ "result").asOpt[Boolean].getOrElse(false) @@ -434,6 +443,7 @@ class WasmAccessValidator extends NgAccessValidator { .map(r => NgAccess.NgDenied(r)) } case Left(err) => + println("err") Errors .craftResponseResult( (err \ "error").asOpt[String].getOrElse("An error occured"), @@ -445,8 +455,10 @@ class WasmAccessValidator extends NgAccessValidator { maybeRoute = ctx.route.some ) .map(r => NgAccess.NgDenied(r)) - }.andThen { case _ => - vm.release() + }.andThen { + case e => + println(e) + vm.release() } } //} else { @@ -515,7 +527,7 @@ class WasmRequestTransformer extends NgRequestTransformer { .getOrElse(WasmConfig()) ctx.wasmJson .flatMap(input => { - WasmVm.fromConfig(config).flatMap { + env.wasmIntegration.wasmVmFor(config).flatMap { case None => Errors .craftResponseResult( @@ -529,6 +541,7 @@ class WasmRequestTransformer extends NgRequestTransformer { ) .map(r => Left(r)) case Some((vm, localConfig)) => + implicit val ic = env.wasmIntegrationCtx vm.call( WasmFunctionParameters.ExtismFuntionCall( config.functionName.orElse(localConfig.functionName).getOrElse("transform_request"), @@ -602,7 +615,7 @@ class WasmResponseTransformer extends NgRequestTransformer { .getOrElse(WasmConfig()) ctx.wasmJson .flatMap(input => { - WasmVm.fromConfig(config).flatMap { + env.wasmIntegration.wasmVmFor(config).flatMap { case None => Errors .craftResponseResult( @@ -616,6 +629,7 @@ class WasmResponseTransformer extends NgRequestTransformer { ) .map(r => Left(r)) case Some((vm, localConfig)) => + implicit val ic = env.wasmIntegrationCtx vm.call( WasmFunctionParameters.ExtismFuntionCall( config.functionName.orElse(localConfig.functionName).getOrElse("transform_response"), @@ -676,9 +690,10 @@ class WasmSink extends NgRequestSink { case JsSuccess(value, _) => value case JsError(_) => WasmConfig() } - val fu = WasmVm.fromConfig(config).flatMap { + val fu = env.wasmIntegration.wasmVmFor(config).flatMap { case None => false.vfuture case Some((vm, localConfig)) => + implicit val ic = env.wasmIntegrationCtx vm.call(WasmFunctionParameters.ExtismFuntionCall("sink_matches", ctx.wasmJson.stringify), None) .map { case Left(error) => false @@ -715,7 +730,7 @@ class WasmSink extends NgRequestSink { requestToWasmJson(ctx.body).flatMap { body => val input = ctx.wasmJson.asObject ++ Json.obj("body_bytes" -> body) - WasmVm.fromConfig(config).flatMap { + env.wasmIntegration.wasmVmFor(config).flatMap { case None => Errors .craftResponseResult( @@ -728,6 +743,7 @@ class WasmSink extends NgRequestSink { attrs = ctx.attrs ) case Some((vm, localConfig)) => + implicit val ic = env.wasmIntegrationCtx vm.call( WasmFunctionParameters.ExtismFuntionCall( config.functionName.orElse(localConfig.functionName).getOrElse("sink_handle"), @@ -838,7 +854,7 @@ class WasmRequestHandler extends RequestHandler { case Some(config) => { requestToWasmJson(request).flatMap { json => val fakeCtx = FakeWasmContext(configJson) - WasmVm.fromConfig(config).flatMap { + env.wasmIntegration.wasmVmFor(config).flatMap { case None => Errors .craftResponseResult( @@ -851,6 +867,7 @@ class WasmRequestHandler extends RequestHandler { attrs = TypedMap.empty ) case Some((vm, localConfig)) => + implicit val ic = env.wasmIntegrationCtx vm.call( WasmFunctionParameters.ExtismFuntionCall( config.functionName.orElse(localConfig.functionName).getOrElse("handle_request"), @@ -951,9 +968,10 @@ class WasmJob(config: WasmJobsConfig) extends Job { None // TODO: make it configurable base on global env ??? override def jobStart(ctx: JobContext)(implicit env: Env, ec: ExecutionContext): Future[Unit] = Try { - WasmVm.fromConfig(config.config).flatMap { + env.wasmIntegration.wasmVmFor(config.config).flatMap { case None => Future.failed(new RuntimeException("no plugin found")) case Some((vm, _)) => + implicit val ic = env.wasmIntegrationCtx vm.call(WasmFunctionParameters.ExtismFuntionCall("job_start", ctx.wasmJson.stringify), None) .map { case Left(err) => logger.error(s"error while starting wasm job ${config.uniqueId}: ${err.stringify}") @@ -970,9 +988,10 @@ class WasmJob(config: WasmJobsConfig) extends Job { case Success(s) => s } override def jobStop(ctx: JobContext)(implicit env: Env, ec: ExecutionContext): Future[Unit] = Try { - WasmVm.fromConfig(config.config).flatMap { + env.wasmIntegration.wasmVmFor(config.config).flatMap { case None => Future.failed(new RuntimeException("no plugin found")) case Some((vm, _)) => + implicit val ic = env.wasmIntegrationCtx vm.call(WasmFunctionParameters.ExtismFuntionCall("job_stop", ctx.wasmJson.stringify), None) .map { case Left(err) => logger.error(s"error while stopping wasm job ${config.uniqueId}: ${err.stringify}") @@ -989,9 +1008,10 @@ class WasmJob(config: WasmJobsConfig) extends Job { case Success(s) => s } override def jobRun(ctx: JobContext)(implicit env: Env, ec: ExecutionContext): Future[Unit] = Try { - WasmVm.fromConfig(config.config).flatMap { + env.wasmIntegration.wasmVmFor(config.config).flatMap { case None => Future.failed(new RuntimeException("no plugin found")) case Some((vm, localConfig)) => + implicit val ic = env.wasmIntegrationCtx vm.call( WasmFunctionParameters.ExtismFuntionCall( config.config.functionName.orElse(localConfig.functionName).getOrElse("job_run"), @@ -1107,6 +1127,7 @@ class WasmOPA extends NgAccessValidator { .map(r => NgAccess.NgDenied(r)) private def execute(vm: WasmVm, ctx: NgAccessContext)(implicit env: Env, ec: ExecutionContext) = { + implicit val ic = env.wasmIntegrationCtx vm.call(WasmFunctionParameters.OPACall("execute", vm.opaPointers, ctx.wasmJson.stringify), None) .flatMap { case Right((rawResult, _)) => @@ -1129,7 +1150,7 @@ class WasmOPA extends NgAccessValidator { .cachedConfig(internalName)(WasmConfig.format) .getOrElse(WasmConfig()) - WasmVm.fromConfig(config).flatMap { + env.wasmIntegration.wasmVmFor(config).flatMap { case None => Errors .craftResponseResult( @@ -1144,6 +1165,7 @@ class WasmOPA extends NgAccessValidator { .map(r => NgAccess.NgDenied(r)) case Some((vm, localConfig)) => if (!vm.initialized()) { + implicit val ic = env.wasmIntegrationCtx vm.call(WasmFunctionParameters.OPACall("initialize", in = ctx.wasmJson.stringify), None) .flatMap { case Left(error) => onError(error.stringify, ctx) @@ -1180,9 +1202,10 @@ class WasmRouter extends NgRouter { override def findRoute(ctx: NgRouterContext)(implicit env: Env, ec: ExecutionContext): Option[NgMatchedRoute] = { val config = WasmConfig.format.reads(ctx.config).getOrElse(WasmConfig()) Await.result( - WasmVm.fromConfig(config).flatMap { + env.wasmIntegration.wasmVmFor(config).flatMap { case None => Left(Json.obj("error" -> "plugin not found")).vfuture case Some((vm, localConfig)) => + implicit val ic = env.wasmIntegrationCtx val ret = vm .call( WasmFunctionParameters.ExtismFuntionCall( diff --git a/otoroshi/app/next/proxy/engine.scala b/otoroshi/app/next/proxy/engine.scala index 53a592899a..ee2bbe76a9 100644 --- a/otoroshi/app/next/proxy/engine.scala +++ b/otoroshi/app/next/proxy/engine.scala @@ -476,9 +476,6 @@ class ProxyEngine() extends RequestHandler { RequestFlowReport(report, route).toAnalytics() } } - attrs.get(otoroshi.next.plugins.Keys.WasmContextKey).foreach { ctx => - ctx.close() - } } .applyOnIf( /*env.isDev && */ (debug || debugHeaders))(_.map { res => val addHeaders = @@ -658,9 +655,6 @@ class ProxyEngine() extends RequestHandler { RequestFlowReport(report, route).toAnalytics() } } - attrs.get(otoroshi.next.plugins.Keys.WasmContextKey).foreach { ctx => - ctx.close() - } } } diff --git a/otoroshi/app/wasm/host.scala b/otoroshi/app/wasm/host.scala index 703c82ee94..a06c90f8fd 100644 --- a/otoroshi/app/wasm/host.scala +++ b/otoroshi/app/wasm/host.scala @@ -3,6 +3,7 @@ package otoroshi.wasm import akka.http.scaladsl.model.Uri import akka.stream.Materializer import akka.util.ByteString +import io.otoroshi.common.wasm.{EmptyUserData, EnvUserData, HostFunctionWithAuthorization, OPA, StateUserData} import org.extism.sdk.wasmotoroshi._ import org.joda.time.DateTime import otoroshi.cluster.ClusterConfig @@ -41,7 +42,7 @@ object Utils { Json.parse(rawBytePtrToString(plugin, params(0).v.i64, params(1).v.i32)) } } - +/* case class EnvUserData( env: Env, executionContext: ExecutionContext, @@ -56,7 +57,7 @@ case class StateUserData( cache: UnboundedTrieMap[String, UnboundedTrieMap[String, ByteString]] ) extends WasmOtoroshiHostUserData case class EmptyUserData() extends WasmOtoroshiHostUserData - +*/ object LogLevel extends Enumeration { type LogLevel = Value @@ -70,10 +71,10 @@ object Status extends Enumeration { StatusUnimplemented = Value } -case class HostFunctionWithAuthorization( - function: WasmOtoroshiHostFunction[_ <: WasmOtoroshiHostUserData], - authorized: WasmAuthorizations => Boolean -) +// case class HostFunctionWithAuthorization( +// function: WasmOtoroshiHostFunction[_ <: WasmOtoroshiHostUserData], +// authorized: WasmAuthorizations => Boolean +// ) trait AwaitCapable { def await[T](future: Future[T], atMost: FiniteDuration = 5.seconds)(implicit env: Env): T = { @@ -97,7 +98,7 @@ object HFunction { )( f: (WasmOtoroshiInternal, Array[WasmBridge.ExtismVal], Array[WasmBridge.ExtismVal], EnvUserData) => Unit )(implicit env: Env, ec: ExecutionContext, mat: Materializer): WasmOtoroshiHostFunction[EnvUserData] = { - val ev = EnvUserData(env, ec, mat, config) + val ev = EnvUserData(env.wasmIntegrationCtx, ec, mat, config) defineFunction[EnvUserData](fname, ev.some, returnType, params: _*)((p1, p2, p3, _) => f(p1, p2, p3, ev)) } @@ -107,7 +108,7 @@ object HFunction { )( f: (WasmOtoroshiInternal, Array[WasmBridge.ExtismVal], Array[WasmBridge.ExtismVal], EnvUserData) => Unit )(implicit env: Env, ec: ExecutionContext, mat: Materializer): WasmOtoroshiHostFunction[EnvUserData] = { - val ev = EnvUserData(env, ec, mat, config) + val ev = EnvUserData(env.wasmIntegrationCtx, ec, mat, config) defineFunction[EnvUserData]( fname, ev.some, @@ -188,7 +189,7 @@ object Logging extends AwaitCapable { val data = Utils.contextParamsToJson(plugin, params: _*) val route = data.select("route_id").asOpt[String].flatMap(env.proxyState.route) val event = WasmLogEvent( - `@id` = ud.env.snowflakeGenerator.nextIdStr(), + `@id` = ud.asInstanceOf[OtoroshiWasmIntegrationContext].ev.snowflakeGenerator.nextIdStr(), `@service` = route.map(_.name).getOrElse(""), `@serviceId` = route.map(_.id).getOrElse(""), `@timestamp` = DateTime.now(), @@ -234,13 +235,13 @@ object Http extends AwaitCapable { RegexPool(h).matches(urlHost) ) if (allowed) { - val builder = hostData.env.Ws + val builder = hostData.ic.asInstanceOf[OtoroshiWasmIntegrationContext].ev.Ws .url(url) .withMethod((context \ "method").asOpt[String].getOrElse("GET")) .withHttpHeaders((context \ "headers").asOpt[Map[String, String]].getOrElse(Map.empty).toSeq: _*) .withRequestTimeout( Duration( - (context \ "request_timeout").asOpt[Long].getOrElse(hostData.env.clusterConfig.worker.timeout), + (context \ "request_timeout").asOpt[Long].getOrElse(hostData.asInstanceOf[OtoroshiWasmIntegrationContext].ev.clusterConfig.worker.timeout), TimeUnit.MILLISECONDS ) ) @@ -271,7 +272,7 @@ object Http extends AwaitCapable { "body_base64" -> body ) }, - Duration(hostData.config.authorizations.proxyHttpCallTimeout, TimeUnit.MILLISECONDS) + Duration(hostData.config.asInstanceOf[WasmConfig].authorizations.proxyHttpCallTimeout, TimeUnit.MILLISECONDS) ) plugin.returnString(returns(0), Json.stringify(out)) } else { @@ -452,7 +453,7 @@ object Http extends AwaitCapable { mat: Materializer ): Seq[HostFunctionWithAuthorization] = { Seq( - HostFunctionWithAuthorization(proxyHttpCall(config), _.httpAccess), + HostFunctionWithAuthorization(proxyHttpCall(config), _.asInstanceOf[WasmConfig].authorizations.httpAccess), HostFunctionWithAuthorization(getAttributes(config, attrs), _ => true), HostFunctionWithAuthorization(getAttribute(config, attrs), _ => true), HostFunctionWithAuthorization(setAttribute(config, attrs), _ => true), @@ -480,7 +481,7 @@ object DataStore extends AwaitCapable { { val key = Utils.contextParamsToString(plugin, params: _*) val path = prefix.map(p => s"wasm:$p:").getOrElse("") - val future = env.datastores.rawDataStore.allMatching(s"${hostData.env.storageRoot}:$path$key").map { values => + val future = env.datastores.rawDataStore.allMatching(s"${hostData.asInstanceOf[OtoroshiWasmIntegrationContext].ev.storageRoot}:$path$key").map { values => values.map(v => JsString(v.encodeBase64.utf8String)) } val res = await(future) @@ -505,7 +506,7 @@ object DataStore extends AwaitCapable { { val key = Utils.contextParamsToString(plugin, params: _*) val path = prefix.map(p => s"wasm:$p:").getOrElse("") - val future = env.datastores.rawDataStore.keys(s"${hostData.env.storageRoot}:$path$key").map { values => + val future = env.datastores.rawDataStore.keys(s"${hostData.asInstanceOf[OtoroshiWasmIntegrationContext].ev.storageRoot}:$path$key").map { values => JsArray(values.map(JsString.apply)).stringify } val out = await(future) @@ -530,7 +531,7 @@ object DataStore extends AwaitCapable { { val key = Utils.contextParamsToString(plugin, params: _*) val path = prefix.map(p => s"wasm:$p:").getOrElse("") - val future = env.datastores.rawDataStore.get(s"${hostData.env.storageRoot}:$path$key") + val future = env.datastores.rawDataStore.get(s"${hostData.asInstanceOf[OtoroshiWasmIntegrationContext].ev.storageRoot}:$path$key") val out = await(future) val bytes = out.map(_.toArray).getOrElse(Array.empty[Byte]) plugin.returnBytes(returns(0), bytes) @@ -554,7 +555,7 @@ object DataStore extends AwaitCapable { { val key = Utils.contextParamsToString(plugin, params: _*) val path = prefix.map(p => s"wasm:$p:").getOrElse("") - val future = env.datastores.rawDataStore.exists(s"${hostData.env.storageRoot}:$path$key") + val future = env.datastores.rawDataStore.exists(s"${hostData.asInstanceOf[OtoroshiWasmIntegrationContext].ev.storageRoot}:$path$key") val out = await(future) plugin.returnInt(returns(0), if (out) 1 else 0) } @@ -577,7 +578,7 @@ object DataStore extends AwaitCapable { { val key = Utils.contextParamsToString(plugin, params: _*) val path = prefix.map(p => s"wasm:$p:").getOrElse("") - val future = env.datastores.rawDataStore.pttl(s"${hostData.env.storageRoot}:$path$key") + val future = env.datastores.rawDataStore.pttl(s"${hostData.asInstanceOf[OtoroshiWasmIntegrationContext].ev.storageRoot}:$path$key") returns(0).v.i64 = await(future) } } @@ -606,7 +607,7 @@ object DataStore extends AwaitCapable { .orElse(data.select("value_base64").asOpt[String].map(s => ByteString(s).decodeBase64)) .get val ttl = (data \ "ttl").asOpt[Long] - val future = env.datastores.rawDataStore.setnx(s"${hostData.env.storageRoot}:$path$key", value, ttl) + val future = env.datastores.rawDataStore.setnx(s"${hostData.asInstanceOf[OtoroshiWasmIntegrationContext].ev.storageRoot}:$path$key", value, ttl) val out = await(future) plugin.returnInt(returns(0), if (out) 1 else 0) } @@ -636,7 +637,7 @@ object DataStore extends AwaitCapable { .orElse(data.select("value_base64").asOpt[String].map(s => ByteString(s).decodeBase64)) .get val ttl = (data \ "ttl").asOpt[Long] - val future = env.datastores.rawDataStore.set(s"${hostData.env.storageRoot}:$path$key", value, ttl) + val future = env.datastores.rawDataStore.set(s"${hostData.asInstanceOf[OtoroshiWasmIntegrationContext].ev.storageRoot}:$path$key", value, ttl) val out = await(future) plugin.returnInt(returns(0), if (out) 1 else 0) } @@ -661,7 +662,7 @@ object DataStore extends AwaitCapable { val path = prefix.map(p => s"wasm:$p:").getOrElse("") val future = env.datastores.rawDataStore .del( - (data \ "keys").asOpt[Seq[String]].getOrElse(Seq.empty).map(r => s"${hostData.env.storageRoot}:$path$r") + (data \ "keys").asOpt[Seq[String]].getOrElse(Seq.empty).map(r => s"${hostData.asInstanceOf[OtoroshiWasmIntegrationContext].ev.storageRoot}:$path$r") ) val out = await(future) returns(0).v.i64 = out @@ -687,7 +688,7 @@ object DataStore extends AwaitCapable { val path = prefix.map(p => s"wasm:$p:").getOrElse("") val key = (data \ "key").as[String] val incr = (data \ "incr").asOpt[String].map(_.toInt).getOrElse((data \ "incr").asOpt[Int].getOrElse(0)) - val future = env.datastores.rawDataStore.incrby(s"${hostData.env.storageRoot}:$path$key", incr) + val future = env.datastores.rawDataStore.incrby(s"${hostData.asInstanceOf[OtoroshiWasmIntegrationContext].ev.storageRoot}:$path$key", incr) val out = await(future) returns(0).v.i64 = out } @@ -712,7 +713,7 @@ object DataStore extends AwaitCapable { val path = prefix.map(p => s"wasm:$p:").getOrElse("") val key = (data \ "key").as[String] val pttl = (data \ "pttl").asOpt[String].map(_.toInt).getOrElse((data \ "pttl").asOpt[Int].getOrElse(0)) - val future = env.datastores.rawDataStore.pexpire(s"${hostData.env.storageRoot}:$path$key", pttl) + val future = env.datastores.rawDataStore.pexpire(s"${hostData.asInstanceOf[OtoroshiWasmIntegrationContext].ev.storageRoot}:$path$key", pttl) val out = await(future) plugin.returnInt(returns(0), if (out) 1 else 0) } @@ -725,55 +726,55 @@ object DataStore extends AwaitCapable { mat: Materializer ): Seq[HostFunctionWithAuthorization] = Seq( - HostFunctionWithAuthorization(proxyDataStoreKeys(config = config), _.globalDataStoreAccess.read), - HostFunctionWithAuthorization(proxyDataStoreGet(config = config), _.globalDataStoreAccess.read), - HostFunctionWithAuthorization(proxyDataStoreExists(config = config), _.globalDataStoreAccess.read), - HostFunctionWithAuthorization(proxyDataStorePttl(config = config), _.globalDataStoreAccess.read), - HostFunctionWithAuthorization(proxyDataStoreSet(config = config), _.globalDataStoreAccess.write), - HostFunctionWithAuthorization(proxyDataStoreSetnx(config = config), _.globalDataStoreAccess.write), - HostFunctionWithAuthorization(proxyDataStoreDel(config = config), _.globalDataStoreAccess.write), - HostFunctionWithAuthorization(proxyDataStoreIncrby(config = config), _.globalDataStoreAccess.write), - HostFunctionWithAuthorization(proxyDataStorePexpire(config = config), _.globalDataStoreAccess.write), - HostFunctionWithAuthorization(proxyDataStoreAllMatching(config = config), _.globalDataStoreAccess.read), + HostFunctionWithAuthorization(proxyDataStoreKeys(config = config), _.asInstanceOf[WasmConfig].authorizations.globalDataStoreAccess.read), + HostFunctionWithAuthorization(proxyDataStoreGet(config = config), _.asInstanceOf[WasmConfig].authorizations.globalDataStoreAccess.read), + HostFunctionWithAuthorization(proxyDataStoreExists(config = config), _.asInstanceOf[WasmConfig].authorizations.globalDataStoreAccess.read), + HostFunctionWithAuthorization(proxyDataStorePttl(config = config), _.asInstanceOf[WasmConfig].authorizations.globalDataStoreAccess.read), + HostFunctionWithAuthorization(proxyDataStoreSet(config = config), _.asInstanceOf[WasmConfig].authorizations.globalDataStoreAccess.write), + HostFunctionWithAuthorization(proxyDataStoreSetnx(config = config), _.asInstanceOf[WasmConfig].authorizations.globalDataStoreAccess.write), + HostFunctionWithAuthorization(proxyDataStoreDel(config = config), _.asInstanceOf[WasmConfig].authorizations.globalDataStoreAccess.write), + HostFunctionWithAuthorization(proxyDataStoreIncrby(config = config), _.asInstanceOf[WasmConfig].authorizations.globalDataStoreAccess.write), + HostFunctionWithAuthorization(proxyDataStorePexpire(config = config), _.asInstanceOf[WasmConfig].authorizations.globalDataStoreAccess.write), + HostFunctionWithAuthorization(proxyDataStoreAllMatching(config = config), _.asInstanceOf[WasmConfig].authorizations.globalDataStoreAccess.read), HostFunctionWithAuthorization( proxyDataStoreKeys(config = config, pluginRestricted = true, prefix = pluginId.some), - _.pluginDataStoreAccess.read + _.asInstanceOf[WasmConfig].authorizations.pluginDataStoreAccess.read ), HostFunctionWithAuthorization( proxyDataStoreGet(config = config, pluginRestricted = true, prefix = pluginId.some), - _.pluginDataStoreAccess.read + _.asInstanceOf[WasmConfig].authorizations.pluginDataStoreAccess.read ), HostFunctionWithAuthorization( proxyDataStoreExists(config = config, pluginRestricted = true, prefix = pluginId.some), - _.pluginDataStoreAccess.read + _.asInstanceOf[WasmConfig].authorizations.pluginDataStoreAccess.read ), HostFunctionWithAuthorization( proxyDataStorePttl(config = config, pluginRestricted = true, prefix = pluginId.some), - _.pluginDataStoreAccess.read + _.asInstanceOf[WasmConfig].authorizations.pluginDataStoreAccess.read ), HostFunctionWithAuthorization( proxyDataStoreAllMatching(config = config, pluginRestricted = true, prefix = pluginId.some), - _.pluginDataStoreAccess.read + _.asInstanceOf[WasmConfig].authorizations.pluginDataStoreAccess.read ), HostFunctionWithAuthorization( proxyDataStoreSet(config = config, pluginRestricted = true, prefix = pluginId.some), - _.pluginDataStoreAccess.write + _.asInstanceOf[WasmConfig].authorizations.pluginDataStoreAccess.write ), HostFunctionWithAuthorization( proxyDataStoreSetnx(config = config, pluginRestricted = true, prefix = pluginId.some), - _.pluginDataStoreAccess.write + _.asInstanceOf[WasmConfig].authorizations.pluginDataStoreAccess.write ), HostFunctionWithAuthorization( proxyDataStoreDel(config = config, pluginRestricted = true, prefix = pluginId.some), - _.pluginDataStoreAccess.write + _.asInstanceOf[WasmConfig].authorizations.pluginDataStoreAccess.write ), HostFunctionWithAuthorization( proxyDataStoreIncrby(config = config, pluginRestricted = true, prefix = pluginId.some), - _.pluginDataStoreAccess.write + _.asInstanceOf[WasmConfig].authorizations.pluginDataStoreAccess.write ), HostFunctionWithAuthorization( proxyDataStorePexpire(config = config, pluginRestricted = true, prefix = pluginId.some), - _.pluginDataStoreAccess.write + _.asInstanceOf[WasmConfig].authorizations.pluginDataStoreAccess.write ) ) } @@ -795,7 +796,7 @@ object State { WasmBridge.ExtismValType.I64 ) { (plugin, _, returns, hostData) => { - val proxyState = hostData.env.proxyState + val proxyState = hostData.asInstanceOf[OtoroshiWasmIntegrationContext].ev.proxyState val state = Json .obj( @@ -834,51 +835,52 @@ object State { val entity = (context \ "entity").asOpt[String].getOrElse("") val id: Option[String] = (context \ "id").asOpt[String] + val env = userData.ic.asInstanceOf[OtoroshiWasmIntegrationContext].ev plugin.returnString( returns(0), ((entity, id) match { - case ("raw_routes", None) => JsArray(userData.env.proxyState.allRawRoutes().map(_.json)) - case ("routes", None) => JsArray(userData.env.proxyState.allRoutes().map(_.json)) - case ("routeCompositions", None) => JsArray(userData.env.proxyState.allRouteCompositions().map(_.json)) - case ("apikeys", None) => JsArray(userData.env.proxyState.allApikeys().map(_.json)) - case ("ngbackends", None) => JsArray(userData.env.proxyState.allBackends().map(_.json)) - case ("jwtVerifiers", None) => JsArray(userData.env.proxyState.allJwtVerifiers().map(_.json)) - case ("certificates", None) => JsArray(userData.env.proxyState.allCertificates().map(_.json)) - case ("authModules", None) => JsArray(userData.env.proxyState.allAuthModules().map(_.json)) - case ("services", None) => JsArray(userData.env.proxyState.allServices().map(_.json)) - case ("teams", None) => JsArray(userData.env.proxyState.allTeams().map(_.json)) - case ("tenants", None) => JsArray(userData.env.proxyState.allTenants().map(_.json)) - case ("serviceGroups", None) => JsArray(userData.env.proxyState.allServiceGroups().map(_.json)) - case ("dataExporters", None) => JsArray(userData.env.proxyState.allDataExporters().map(_.json)) - case ("otoroshiAdmins", None) => JsArray(userData.env.proxyState.allOtoroshiAdmins().map(_.json)) - case ("backofficeSessions", None) => JsArray(userData.env.proxyState.allBackofficeSessions().map(_.json)) - case ("privateAppsSessions", None) => JsArray(userData.env.proxyState.allPrivateAppsSessions().map(_.json)) - case ("tcpServices", None) => JsArray(userData.env.proxyState.allTcpServices().map(_.json)) - case ("scripts", None) => JsArray(userData.env.proxyState.allScripts().map(_.json)) - - case ("raw_routes", Some(key)) => userData.env.proxyState.rawRoute(key).map(_.json).getOrElse(JsNull) - case ("routes", Some(key)) => userData.env.proxyState.route(key).map(_.json).getOrElse(JsNull) + case ("raw_routes", None) => JsArray(env.proxyState.allRawRoutes().map(_.json)) + case ("routes", None) => JsArray(env.proxyState.allRoutes().map(_.json)) + case ("routeCompositions", None) => JsArray(env.proxyState.allRouteCompositions().map(_.json)) + case ("apikeys", None) => JsArray(env.proxyState.allApikeys().map(_.json)) + case ("ngbackends", None) => JsArray(env.proxyState.allBackends().map(_.json)) + case ("jwtVerifiers", None) => JsArray(env.proxyState.allJwtVerifiers().map(_.json)) + case ("certificates", None) => JsArray(env.proxyState.allCertificates().map(_.json)) + case ("authModules", None) => JsArray(env.proxyState.allAuthModules().map(_.json)) + case ("services", None) => JsArray(env.proxyState.allServices().map(_.json)) + case ("teams", None) => JsArray(env.proxyState.allTeams().map(_.json)) + case ("tenants", None) => JsArray(env.proxyState.allTenants().map(_.json)) + case ("serviceGroups", None) => JsArray(env.proxyState.allServiceGroups().map(_.json)) + case ("dataExporters", None) => JsArray(env.proxyState.allDataExporters().map(_.json)) + case ("otoroshiAdmins", None) => JsArray(env.proxyState.allOtoroshiAdmins().map(_.json)) + case ("backofficeSessions", None) => JsArray(env.proxyState.allBackofficeSessions().map(_.json)) + case ("privateAppsSessions", None) => JsArray(env.proxyState.allPrivateAppsSessions().map(_.json)) + case ("tcpServices", None) => JsArray(env.proxyState.allTcpServices().map(_.json)) + case ("scripts", None) => JsArray(env.proxyState.allScripts().map(_.json)) + + case ("raw_routes", Some(key)) => env.proxyState.rawRoute(key).map(_.json).getOrElse(JsNull) + case ("routes", Some(key)) => env.proxyState.route(key).map(_.json).getOrElse(JsNull) case ("routeCompositions", Some(key)) => - userData.env.proxyState.routeComposition(key).map(_.json).getOrElse(JsNull) - case ("apikeys", Some(key)) => userData.env.proxyState.apikey(key).map(_.json).getOrElse(JsNull) - case ("ngbackends", Some(key)) => userData.env.proxyState.backend(key).map(_.json).getOrElse(JsNull) - case ("jwtVerifiers", Some(key)) => userData.env.proxyState.jwtVerifier(key).map(_.json).getOrElse(JsNull) - case ("certificates", Some(key)) => userData.env.proxyState.certificate(key).map(_.json).getOrElse(JsNull) - case ("authModules", Some(key)) => userData.env.proxyState.authModule(key).map(_.json).getOrElse(JsNull) - case ("services", Some(key)) => userData.env.proxyState.service(key).map(_.json).getOrElse(JsNull) - case ("teams", Some(key)) => userData.env.proxyState.team(key).map(_.json).getOrElse(JsNull) - case ("tenants", Some(key)) => userData.env.proxyState.tenant(key).map(_.json).getOrElse(JsNull) - case ("serviceGroups", Some(key)) => userData.env.proxyState.serviceGroup(key).map(_.json).getOrElse(JsNull) - case ("dataExporters", Some(key)) => userData.env.proxyState.dataExporter(key).map(_.json).getOrElse(JsNull) + env.proxyState.routeComposition(key).map(_.json).getOrElse(JsNull) + case ("apikeys", Some(key)) => env.proxyState.apikey(key).map(_.json).getOrElse(JsNull) + case ("ngbackends", Some(key)) => env.proxyState.backend(key).map(_.json).getOrElse(JsNull) + case ("jwtVerifiers", Some(key)) => env.proxyState.jwtVerifier(key).map(_.json).getOrElse(JsNull) + case ("certificates", Some(key)) => env.proxyState.certificate(key).map(_.json).getOrElse(JsNull) + case ("authModules", Some(key)) => env.proxyState.authModule(key).map(_.json).getOrElse(JsNull) + case ("services", Some(key)) => env.proxyState.service(key).map(_.json).getOrElse(JsNull) + case ("teams", Some(key)) => env.proxyState.team(key).map(_.json).getOrElse(JsNull) + case ("tenants", Some(key)) => env.proxyState.tenant(key).map(_.json).getOrElse(JsNull) + case ("serviceGroups", Some(key)) => env.proxyState.serviceGroup(key).map(_.json).getOrElse(JsNull) + case ("dataExporters", Some(key)) => env.proxyState.dataExporter(key).map(_.json).getOrElse(JsNull) case ("otoroshiAdmins", Some(key)) => - userData.env.proxyState.otoroshiAdmin(key).map(_.json).getOrElse(JsNull) + env.proxyState.otoroshiAdmin(key).map(_.json).getOrElse(JsNull) case ("backofficeSessions", Some(key)) => - userData.env.proxyState.backofficeSession(key).map(_.json).getOrElse(JsNull) + env.proxyState.backofficeSession(key).map(_.json).getOrElse(JsNull) case ("privateAppsSessions", Some(key)) => - userData.env.proxyState.privateAppsSession(key).map(_.json).getOrElse(JsNull) - case ("tcpServices", Some(key)) => userData.env.proxyState.tcpService(key).map(_.json).getOrElse(JsNull) - case ("scripts", Some(key)) => userData.env.proxyState.script(key).map(_.json).getOrElse(JsNull) + env.proxyState.privateAppsSession(key).map(_.json).getOrElse(JsNull) + case ("tcpServices", Some(key)) => env.proxyState.tcpService(key).map(_.json).getOrElse(JsNull) + case ("scripts", Some(key)) => env.proxyState.script(key).map(_.json).getOrElse(JsNull) case (_, __) => JsNull }).stringify @@ -897,7 +899,7 @@ object State { WasmBridge.ExtismValType.I64 ) { (plugin, _, returns, hostData) => { - val cc = hostData.env.configurationJson.stringify + val cc = hostData.asInstanceOf[OtoroshiWasmIntegrationContext].ev.configurationJson.stringify plugin.returnString(returns(0), cc) } } @@ -913,7 +915,7 @@ object State { WasmBridge.ExtismValType.I64 ) { (plugin, _, returns, hostData) => { - val cc = hostData.env.datastores.globalConfigDataStore.latest().json.stringify + val cc = hostData.asInstanceOf[OtoroshiWasmIntegrationContext].ev.datastores.globalConfigDataStore.latest().json.stringify plugin.returnString(returns(0), cc) } } @@ -929,7 +931,7 @@ object State { WasmBridge.ExtismValType.I64 ) { (plugin, _, returns, hostData) => { - val cc = hostData.env.clusterConfig + val cc = hostData.asInstanceOf[OtoroshiWasmIntegrationContext].ev.clusterConfig plugin.returnString(returns(0), getClusterState(cc).stringify) } } @@ -942,7 +944,7 @@ object State { { val path = Utils.contextParamsToString(plugin, params: _*) - val cc = userData.env.clusterConfig + val cc = userData.asInstanceOf[OtoroshiWasmIntegrationContext].ev.clusterConfig plugin.returnString(returns(0), JsonOperationsHelper.getValueAtPath(path, getClusterState(cc))._2.stringify) } } @@ -955,7 +957,7 @@ object State { ): WasmOtoroshiHostFunction[StateUserData] = { HFunction.defineFunction[StateUserData]( if (pluginRestricted) "proxy_plugin_map_set" else "proxy_global_map_set", - StateUserData(env, executionContext, mat, cache).some, + StateUserData(env.wasmIntegrationCtx, executionContext, mat, cache).some, WasmBridge.ExtismValType.I64, WasmBridge.ExtismValType.I64, WasmBridge.ExtismValType.I64 @@ -991,7 +993,7 @@ object State { ): WasmOtoroshiHostFunction[StateUserData] = { HFunction.defineFunction[StateUserData]( if (pluginRestricted) "proxy_plugin_map_del" else "proxy_global_map_del", - StateUserData(env, executionContext, mat, cache).some, + StateUserData(env.wasmIntegrationCtx, executionContext, mat, cache).some, WasmBridge.ExtismValType.I64, WasmBridge.ExtismValType.I64, WasmBridge.ExtismValType.I64 @@ -1022,7 +1024,7 @@ object State { ): WasmOtoroshiHostFunction[StateUserData] = { HFunction.defineFunction[StateUserData]( if (pluginRestricted) "proxy_plugin_map_get" else "proxy_global_map_get", - StateUserData(env, executionContext, mat, cache).some, + StateUserData(env.wasmIntegrationCtx, executionContext, mat, cache).some, WasmBridge.ExtismValType.I64, WasmBridge.ExtismValType.I64, WasmBridge.ExtismValType.I64 @@ -1052,7 +1054,7 @@ object State { ): WasmOtoroshiHostFunction[StateUserData] = { HFunction.defineFunction[StateUserData]( if (pluginRestricted) "proxy_plugin_map" else "proxy_global_map", - StateUserData(env, executionContext, mat, cache).some, + StateUserData(env.wasmIntegrationCtx, executionContext, mat, cache).some, WasmBridge.ExtismValType.I64, WasmBridge.ExtismValType.I64 ) { (plugin, _, returns, userData: Option[StateUserData]) => @@ -1082,20 +1084,20 @@ object State { mat: Materializer ): Seq[HostFunctionWithAuthorization] = Seq( - HostFunctionWithAuthorization(getProxyState(config), _.proxyStateAccess), - HostFunctionWithAuthorization(proxyStateGetValue(config), _.proxyStateAccess), - HostFunctionWithAuthorization(getGlobalProxyConfig(config), _.proxyStateAccess), - HostFunctionWithAuthorization(getClusterState(config), _.configurationAccess), - HostFunctionWithAuthorization(proxyClusteStateGetValue(config), _.configurationAccess), - HostFunctionWithAuthorization(getProxyConfig(config), _.configurationAccess), - HostFunctionWithAuthorization(proxyGlobalMapDel(), _.globalMapAccess.write), - HostFunctionWithAuthorization(proxyGlobalMapSet(), _.globalMapAccess.write), - HostFunctionWithAuthorization(proxyGlobalMapGet(), _.globalMapAccess.read), - HostFunctionWithAuthorization(proxyGlobalMap(), _.globalMapAccess.read), - HostFunctionWithAuthorization(proxyGlobalMapDel(pluginRestricted = true, pluginId.some), _.pluginMapAccess.write), - HostFunctionWithAuthorization(proxyGlobalMapSet(pluginRestricted = true, pluginId.some), _.pluginMapAccess.write), - HostFunctionWithAuthorization(proxyGlobalMapGet(pluginRestricted = true, pluginId.some), _.pluginMapAccess.read), - HostFunctionWithAuthorization(proxyGlobalMap(pluginRestricted = true, pluginId.some), _.pluginMapAccess.read) + HostFunctionWithAuthorization(getProxyState(config), _.asInstanceOf[WasmConfig].authorizations.proxyStateAccess), + HostFunctionWithAuthorization(proxyStateGetValue(config), _.asInstanceOf[WasmConfig].authorizations.proxyStateAccess), + HostFunctionWithAuthorization(getGlobalProxyConfig(config), _.asInstanceOf[WasmConfig].authorizations.proxyStateAccess), + HostFunctionWithAuthorization(getClusterState(config), _.asInstanceOf[WasmConfig].authorizations.configurationAccess), + HostFunctionWithAuthorization(proxyClusteStateGetValue(config), _.asInstanceOf[WasmConfig].authorizations.configurationAccess), + HostFunctionWithAuthorization(getProxyConfig(config), _.asInstanceOf[WasmConfig].authorizations.configurationAccess), + HostFunctionWithAuthorization(proxyGlobalMapDel(), _.asInstanceOf[WasmConfig].authorizations.globalMapAccess.write), + HostFunctionWithAuthorization(proxyGlobalMapSet(), _.asInstanceOf[WasmConfig].authorizations.globalMapAccess.write), + HostFunctionWithAuthorization(proxyGlobalMapGet(), _.asInstanceOf[WasmConfig].authorizations.globalMapAccess.read), + HostFunctionWithAuthorization(proxyGlobalMap(), _.asInstanceOf[WasmConfig].authorizations.globalMapAccess.read), + HostFunctionWithAuthorization(proxyGlobalMapDel(pluginRestricted = true, pluginId.some), _.asInstanceOf[WasmConfig].authorizations.pluginMapAccess.write), + HostFunctionWithAuthorization(proxyGlobalMapSet(pluginRestricted = true, pluginId.some), _.asInstanceOf[WasmConfig].authorizations.pluginMapAccess.write), + HostFunctionWithAuthorization(proxyGlobalMapGet(pluginRestricted = true, pluginId.some), _.asInstanceOf[WasmConfig].authorizations.pluginMapAccess.read), + HostFunctionWithAuthorization(proxyGlobalMap(pluginRestricted = true, pluginId.some), _.asInstanceOf[WasmConfig].authorizations.pluginMapAccess.read) ) } @@ -1117,9 +1119,8 @@ object HostFunctions { functions .collect { - case func if func.authorized(config.authorizations) => func.function + case func if func.authorized(config) => func.function } - .seffectOn(_.map(_.name).mkString(", ")) .toArray } } diff --git a/otoroshi/app/wasm/opa.scala b/otoroshi/app/wasm/opa.scala index 59253fda8b..ab03051c36 100644 --- a/otoroshi/app/wasm/opa.scala +++ b/otoroshi/app/wasm/opa.scala @@ -1,4 +1,4 @@ -package otoroshi.wasm; +/*package otoroshi.wasm; import org.extism.sdk.wasmotoroshi._ import otoroshi.utils.syntax.implicits.{BetterJsValue, BetterSyntax} @@ -310,3 +310,4 @@ object LinearMemories { return new String(Arrays.copyOf(mem, size), StandardCharsets.UTF_8); } }*/ +*/ \ No newline at end of file diff --git a/otoroshi/app/wasm/proxywasm/api.scala b/otoroshi/app/wasm/proxywasm/api.scala index db1382e4ab..4559861074 100644 --- a/otoroshi/app/wasm/proxywasm/api.scala +++ b/otoroshi/app/wasm/proxywasm/api.scala @@ -2,13 +2,13 @@ package otoroshi.wasm.proxywasm import akka.util.ByteString import com.sun.jna.Pointer +import io.otoroshi.common.wasm.AbsVmData import org.extism.sdk.wasmotoroshi._ import otoroshi.env.Env import otoroshi.next.plugins.api.NgPluginHttpResponse import otoroshi.utils.TypedMap import otoroshi.utils.http.RequestImplicits._ import otoroshi.utils.syntax.implicits._ -import otoroshi.wasm.WasmVm import play.api.libs.json.JsValue import play.api.mvc import play.api.mvc.RequestHeader @@ -88,7 +88,7 @@ case class VmData( respRef: AtomicReference[play.api.mvc.Result], bodyInRef: AtomicReference[ByteString], bodyOutRef: AtomicReference[ByteString] -) extends WasmOtoroshiHostUserData { +) extends WasmOtoroshiHostUserData with AbsVmData { def withRequest(request: RequestHeader, attrs: TypedMap)(implicit env: Env): VmData = { VmData .from(request, attrs) diff --git a/otoroshi/app/wasm/proxywasm/coraza.scala b/otoroshi/app/wasm/proxywasm/coraza.scala index 09b1f29aad..717d25da3a 100644 --- a/otoroshi/app/wasm/proxywasm/coraza.scala +++ b/otoroshi/app/wasm/proxywasm/coraza.scala @@ -3,6 +3,7 @@ package otoroshi.wasm.proxywasm import akka.stream.Materializer import akka.util.ByteString import com.sksamuel.exts.concurrent.Futures.RichFuture +import io.otoroshi.common.wasm.{AbsVmData, EnvUserData, ResultsWrapper, WasmFunctionParameters, WasmSource, WasmSourceKind, WasmVm, WasmVmInitOptions, WasmVmKillOptions, WasmVmPool} import org.extism.sdk.wasmotoroshi._ import org.joda.time.DateTime import otoroshi.api.{GenericResourceAccessApiWithState, Resource, ResourceVersion} @@ -63,7 +64,7 @@ object CorazaPluginKeys { class CorazaPlugin(wasm: WasmConfig, val config: CorazaWafConfig, key: String, env: Env) { - WasmVmPool.logger.debug("new CorazaPlugin") + // WasmVmPool.logger.debug("new CorazaPlugin") private implicit val ev = env private implicit val ec = env.otoroshiExecutionContext @@ -78,7 +79,7 @@ class CorazaPlugin(wasm: WasmConfig, val config: CorazaWafConfig, key: String, e private lazy val contextId = new AtomicInteger(0) private lazy val state = new ProxyWasmState(CorazaPlugin.rootContextIds.incrementAndGet(), contextId, Some((l, m) => logCallback(l, m)), env) - private lazy val pool: WasmVmPool = new WasmVmPool(key, wasm.some, env) + private lazy val pool: WasmVmPool = new WasmVmPool(key, wasm.some, env.wasmIntegrationCtx) def logCallback(level: org.slf4j.event.Level, msg: String): Unit = { CorazaTrailEvent(level, msg).toAnalytics() @@ -86,7 +87,7 @@ class CorazaPlugin(wasm: WasmConfig, val config: CorazaWafConfig, key: String, e def isStarted(): Boolean = started.get() - def createFunctions(ref: AtomicReference[VmData]): Seq[WasmOtoroshiHostFunction[EnvUserData]] = { + def createFunctions(ref: AtomicReference[AbsVmData]): Seq[WasmOtoroshiHostFunction[EnvUserData]] = { ProxyWasmFunctions.build(state, ref) } @@ -103,6 +104,7 @@ class CorazaPlugin(wasm: WasmConfig, val config: CorazaWafConfig, key: String, e Left(Json.obj("error" -> "no vm found in attrs")).vfuture case Some(vm) => { WasmUtils.traceHostVm(function + s" - vm: ${vm.index}") + implicit val ic = env.wasmIntegrationCtx vm.call(WasmFunctionParameters.NoResult(function, params), Some(data)) .map { opt => opt.map { res => @@ -131,6 +133,7 @@ class CorazaPlugin(wasm: WasmConfig, val config: CorazaWafConfig, key: String, e Future.failed(new RuntimeException("no vm found in attrs")) case Some(vm) => { WasmUtils.traceHostVm(function + s" - vm: ${vm.index}") + implicit val ic = env.wasmIntegrationCtx vm.call(WasmFunctionParameters.BothParamsResults(function, params, results), Some(data)) .flatMap { case Left(err) => diff --git a/otoroshi/app/wasm/proxywasm/functions.scala b/otoroshi/app/wasm/proxywasm/functions.scala index a6d4a1b202..6a6eac3ec7 100644 --- a/otoroshi/app/wasm/proxywasm/functions.scala +++ b/otoroshi/app/wasm/proxywasm/functions.scala @@ -1,6 +1,7 @@ package otoroshi.wasm.proxywasm import akka.stream.Materializer +import io.otoroshi.common.wasm.{AbsVmData, EnvUserData} import org.extism.sdk.wasmotoroshi._ import otoroshi.env.Env import otoroshi.wasm._ @@ -23,7 +24,7 @@ object ProxyWasmFunctions { def build( state: ProxyWasmState, - vmDataRef: AtomicReference[VmData] + vmDataRef: AtomicReference[AbsVmData] )(implicit ec: ExecutionContext, env: Env, mat: Materializer): Seq[WasmOtoroshiHostFunction[EnvUserData]] = { def getCurrentVmData(): VmData = { Option(vmDataRef.get()) match { diff --git a/otoroshi/app/wasm/runtimev1.scala b/otoroshi/app/wasm/runtimev1.scala index cac4cc0edd..f71b3092f2 100644 --- a/otoroshi/app/wasm/runtimev1.scala +++ b/otoroshi/app/wasm/runtimev1.scala @@ -1,4 +1,4 @@ -package otoroshi.wasm +/*package otoroshi.wasm import akka.stream.OverflowStrategy import akka.stream.scaladsl.{Keep, Sink, Source, SourceQueueWithComplete} @@ -534,3 +534,4 @@ object WasmUtils { } } } +*/ \ No newline at end of file diff --git a/otoroshi/app/wasm/runtimev2.scala b/otoroshi/app/wasm/runtimev2.scala index 18aea0bb00..34e192ad41 100644 --- a/otoroshi/app/wasm/runtimev2.scala +++ b/otoroshi/app/wasm/runtimev2.scala @@ -1,4 +1,4 @@ -package otoroshi.wasm +/*package otoroshi.wasm import akka.stream.OverflowStrategy import akka.stream.scaladsl.{Keep, Sink, Source} @@ -650,3 +650,4 @@ object WasmVmKillOptions { } } } +*/ \ No newline at end of file diff --git a/otoroshi/app/wasm/types.scala b/otoroshi/app/wasm/types.scala index ba946b82f2..afdcb91b9a 100644 --- a/otoroshi/app/wasm/types.scala +++ b/otoroshi/app/wasm/types.scala @@ -1,4 +1,4 @@ -package otoroshi.wasm +/*package otoroshi.wasm import org.extism.sdk.Results import org.extism.sdk.wasmotoroshi._ @@ -134,3 +134,5 @@ object WasmFunctionParameters { override def resultSize: Option[Int] = None } } + +*/ diff --git a/otoroshi/app/wasm/wasm.scala b/otoroshi/app/wasm/wasm.scala index 7931c339cb..70482541f5 100644 --- a/otoroshi/app/wasm/wasm.scala +++ b/otoroshi/app/wasm/wasm.scala @@ -1,5 +1,27 @@ package otoroshi.wasm +import akka.stream.Materializer +import io.otoroshi.common.wasm._ +import org.extism.sdk.wasmotoroshi.{WasmOtoroshiHostFunction, WasmOtoroshiHostUserData} +import otoroshi.env.Env +import otoroshi.next.models.NgTlsConfig +import otoroshi.next.plugins.api.{NgPluginConfig, NgPluginVisibility, NgStep} +import otoroshi.script.{Job, JobContext, JobId, JobInstantiation, JobKind, JobStarting} +import otoroshi.utils.TypedMap +import otoroshi.utils.cache.types.UnboundedTrieMap +import otoroshi.utils.syntax.implicits._ +import play.api.Logger +import play.api.libs.json._ +import play.api.libs.ws.{DefaultWSCookie, WSCookie, WSRequest} +import play.api.mvc.Cookie + +import java.util.concurrent.Executors +import scala.collection.concurrent.TrieMap +import scala.concurrent.duration.{DurationInt, FiniteDuration} +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success, Try} + +/* import akka.util.ByteString import org.extism.sdk.wasmotoroshi._ import otoroshi.env.Env @@ -16,31 +38,6 @@ import scala.concurrent.duration.{DurationLong, FiniteDuration, MILLISECONDS} import scala.concurrent.{ExecutionContext, Future, Promise} import scala.util.{Failure, Success, Try} -case class WasmDataRights(read: Boolean = false, write: Boolean = false) - -object WasmDataRights { - def fmt = - new Format[WasmDataRights] { - override def writes(o: WasmDataRights) = - Json.obj( - "read" -> o.read, - "write" -> o.write - ) - - override def reads(json: JsValue) = - Try { - JsSuccess( - WasmDataRights( - read = (json \ "read").asOpt[Boolean].getOrElse(false), - write = (json \ "write").asOpt[Boolean].getOrElse(false) - ) - ) - } recover { case e => - JsError(e.getMessage) - } get - } -} - sealed trait WasmSourceKind { def name: String def json: JsValue = JsString(name) @@ -313,24 +310,139 @@ object WasmVmLifetime { } } +object ResultsWrapper { + def apply(results: WasmOtoroshiResults): ResultsWrapper = new ResultsWrapper(results, None) + def apply(results: WasmOtoroshiResults, plugin: WasmOtoroshiInstance): ResultsWrapper = + new ResultsWrapper(results, Some(plugin)) +} + +case class ResultsWrapper(results: WasmOtoroshiResults, pluginOpt: Option[WasmOtoroshiInstance]) { + def free(): Unit = try { + if (results.getLength > 0) { + results.close() + } + } catch { + case t: Throwable => + t.printStackTrace() + () + } +} + +class WasmContext( + plugins: UnboundedTrieMap[String, WasmContextSlot] = new UnboundedTrieMap[String, WasmContextSlot]() +) { + def put(id: String, slot: WasmContextSlot): Unit = plugins.put(id, slot) + def get(id: String): Option[WasmContextSlot] = plugins.get(id) + def close(): Unit = { + if (WasmUtils.logger.isDebugEnabled) + WasmUtils.logger.debug(s"[WasmContext] will close ${plugins.size} wasm plugin instances") + plugins.foreach(_._2.forceClose()) + plugins.clear() + } +} + +sealed trait CacheableWasmScript + +object CacheableWasmScript { + case class CachedWasmScript(script: ByteString, createAt: Long) extends CacheableWasmScript + case class FetchingWasmScript(f: Future[Either[JsValue, ByteString]]) extends CacheableWasmScript +} +*/ + +case class WasmDataRights(read: Boolean = false, write: Boolean = false) + +object WasmDataRights { + def fmt = + new Format[WasmDataRights] { + override def writes(o: WasmDataRights) = + Json.obj( + "read" -> o.read, + "write" -> o.write + ) + + override def reads(json: JsValue) = + Try { + JsSuccess( + WasmDataRights( + read = (json \ "read").asOpt[Boolean].getOrElse(false), + write = (json \ "write").asOpt[Boolean].getOrElse(false) + ) + ) + } recover { case e => + JsError(e.getMessage) + } get + } +} + +case class WasmAuthorizations( + httpAccess: Boolean = false, + globalDataStoreAccess: WasmDataRights = WasmDataRights(), + pluginDataStoreAccess: WasmDataRights = WasmDataRights(), + globalMapAccess: WasmDataRights = WasmDataRights(), + pluginMapAccess: WasmDataRights = WasmDataRights(), + proxyStateAccess: Boolean = false, + configurationAccess: Boolean = false, + proxyHttpCallTimeout: Int = 5000 + ) { + def json: JsValue = WasmAuthorizations.format.writes(this) +} + +object WasmAuthorizations { + val format = new Format[WasmAuthorizations] { + override def writes(o: WasmAuthorizations): JsValue = Json.obj( + "httpAccess" -> o.httpAccess, + "proxyHttpCallTimeout" -> o.proxyHttpCallTimeout, + "globalDataStoreAccess" -> WasmDataRights.fmt.writes(o.globalDataStoreAccess), + "pluginDataStoreAccess" -> WasmDataRights.fmt.writes(o.pluginDataStoreAccess), + "globalMapAccess" -> WasmDataRights.fmt.writes(o.globalMapAccess), + "pluginMapAccess" -> WasmDataRights.fmt.writes(o.pluginMapAccess), + "proxyStateAccess" -> o.proxyStateAccess, + "configurationAccess" -> o.configurationAccess + ) + override def reads(json: JsValue): JsResult[WasmAuthorizations] = Try { + WasmAuthorizations( + httpAccess = (json \ "httpAccess").asOpt[Boolean].getOrElse(false), + proxyHttpCallTimeout = (json \ "proxyHttpCallTimeout").asOpt[Int].getOrElse(5000), + globalDataStoreAccess = (json \ "globalDataStoreAccess") + .asOpt[WasmDataRights](WasmDataRights.fmt.reads) + .getOrElse(WasmDataRights()), + pluginDataStoreAccess = (json \ "pluginDataStoreAccess") + .asOpt[WasmDataRights](WasmDataRights.fmt.reads) + .getOrElse(WasmDataRights()), + globalMapAccess = (json \ "globalMapAccess") + .asOpt[WasmDataRights](WasmDataRights.fmt.reads) + .getOrElse(WasmDataRights()), + pluginMapAccess = (json \ "pluginMapAccess") + .asOpt[WasmDataRights](WasmDataRights.fmt.reads) + .getOrElse(WasmDataRights()), + proxyStateAccess = (json \ "proxyStateAccess").asOpt[Boolean].getOrElse(false), + configurationAccess = (json \ "configurationAccess").asOpt[Boolean].getOrElse(false) + ) + } match { + case Failure(ex) => JsError(ex.getMessage) + case Success(value) => JsSuccess(value) + } + } +} + case class WasmConfig( - source: WasmSource = WasmSource(WasmSourceKind.Unknown, "", Json.obj()), - memoryPages: Int = 20, - functionName: Option[String] = None, - config: Map[String, String] = Map.empty, - allowedHosts: Seq[String] = Seq.empty, - allowedPaths: Map[String, String] = Map.empty, - //// - // lifetime: WasmVmLifetime = WasmVmLifetime.Forever, - wasi: Boolean = false, - opa: Boolean = false, - instances: Int = 1, - killOptions: WasmVmKillOptions = WasmVmKillOptions.default, - authorizations: WasmAuthorizations = WasmAuthorizations() -) extends NgPluginConfig { + source: WasmSource = WasmSource(WasmSourceKind.Unknown, "", Json.obj()), + memoryPages: Int = 20, + functionName: Option[String] = None, + config: Map[String, String] = Map.empty, + allowedHosts: Seq[String] = Seq.empty, + allowedPaths: Map[String, String] = Map.empty, + //// + // lifetime: WasmVmLifetime = WasmVmLifetime.Forever, + wasi: Boolean = false, + opa: Boolean = false, + instances: Int = 1, + killOptions: WasmVmKillOptions = WasmVmKillOptions.default, + authorizations: WasmAuthorizations = WasmAuthorizations() + ) extends NgPluginConfig with WasmConfiguration { // still here for compat reason def lifetime: WasmVmLifetime = WasmVmLifetime.Forever - def pool()(implicit env: Env): WasmVmPool = WasmVmPool.forConfig(this) + //def wasmPool()(implicit env: Env): WasmVmPool = WasmVmPool.forConfig(this) def json: JsValue = Json.obj( "source" -> source.json, "memoryPages" -> memoryPages, @@ -416,40 +528,108 @@ object WasmConfig { } } -object ResultsWrapper { - def apply(results: WasmOtoroshiResults): ResultsWrapper = new ResultsWrapper(results, None) - def apply(results: WasmOtoroshiResults, plugin: WasmOtoroshiInstance): ResultsWrapper = - new ResultsWrapper(results, Some(plugin)) -} +class OtoroshiWasmIntegrationContext(env: Env) extends WasmIntegrationContext { -case class ResultsWrapper(results: WasmOtoroshiResults, pluginOpt: Option[WasmOtoroshiInstance]) { - def free(): Unit = try { - if (results.getLength > 0) { - results.close() - } - } catch { - case t: Throwable => - t.printStackTrace() - () + implicit val ec = env.otoroshiExecutionContext + implicit val ev = env + + val logger: Logger = Logger("otoroshi-wasm-integration") + val materializer: Materializer = env.otoroshiMaterializer + val executionContext: ExecutionContext = env.otoroshiExecutionContext + val wasmCacheTtl: Long = env.wasmCacheTtl + val wasmQueueBufferSize: Int = env.wasmQueueBufferSize + val wasmScriptCache: TrieMap[String, CacheableWasmScript] = new TrieMap[String, CacheableWasmScript]() + val wasmExecutor: ExecutionContext = ExecutionContext.fromExecutorService( + Executors.newWorkStealingPool(Math.max(32, (Runtime.getRuntime.availableProcessors * 4) + 1)) + ) + + override def url(path: String): WSRequest = env.Ws.url(path) + + override def mtlsUrl(path: String, tlsConfig: TlsConfig): WSRequest = { + val cfg = NgTlsConfig.format.reads(tlsConfig.json).get.legacy + env.MtlsWs.url(path, cfg) } -} -class WasmContext( - plugins: UnboundedTrieMap[String, WasmContextSlot] = new UnboundedTrieMap[String, WasmContextSlot]() -) { - def put(id: String, slot: WasmContextSlot): Unit = plugins.put(id, slot) - def get(id: String): Option[WasmContextSlot] = plugins.get(id) - def close(): Unit = { - if (WasmUtils.logger.isDebugEnabled) - WasmUtils.logger.debug(s"[WasmContext] will close ${plugins.size} wasm plugin instances") - plugins.foreach(_._2.forceClose()) - plugins.clear() + override def wasmManagerSettings: Future[Option[WasmManagerSettings]] = env.datastores.globalConfigDataStore.latest().wasmManagerSettings.vfuture + + override def wasmConfig(path: String): Option[WasmConfiguration] = env.proxyState.wasmPlugin(path).map(_.config) + + override def wasmConfigs(): Seq[WasmConfiguration] = env.proxyState.allWasmPlugins().map(_.config) + + override def hostFunctions(config: WasmConfiguration, pluginId: String): Array[WasmOtoroshiHostFunction[_ <: WasmOtoroshiHostUserData]] = { + HostFunctions.getFunctions(config.asInstanceOf[WasmConfig], pluginId, None) } } -sealed trait CacheableWasmScript +class WasmVmPoolCleaner extends Job { -object CacheableWasmScript { - case class CachedWasmScript(script: ByteString, createAt: Long) extends CacheableWasmScript - case class FetchingWasmScript(f: Future[Either[JsValue, ByteString]]) extends CacheableWasmScript + private val logger = Logger("otoroshi-wasm-vm-pool-cleaner") + + override def uniqueId: JobId = JobId("otoroshi.wasm.WasmVmPoolCleaner") + + override def visibility: NgPluginVisibility = NgPluginVisibility.NgInternal + + override def steps: Seq[NgStep] = Seq(NgStep.Job) + + override def kind: JobKind = JobKind.ScheduledEvery + + override def starting: JobStarting = JobStarting.Automatically + + override def instantiation(ctx: JobContext, env: Env): JobInstantiation = + JobInstantiation.OneInstancePerOtoroshiInstance + + override def initialDelay(ctx: JobContext, env: Env): Option[FiniteDuration] = 10.seconds.some + + override def interval(ctx: JobContext, env: Env): Option[FiniteDuration] = 60.seconds.some + + override def jobRun(ctx: JobContext)(implicit env: Env, ec: ExecutionContext): Future[Unit] = { + val config = env.datastores.globalConfigDataStore + .latest() + .plugins + .config + .select("wasm-vm-pool-cleaner-config") + .asOpt[JsObject] + .getOrElse(Json.obj()) + env.wasmIntegration.runVmCleanerJob(config) + } } + +object WasmUtils { + + def convertJsonCookies(wasmResponse: JsValue): Option[Seq[WSCookie]] = + wasmResponse + .select("cookies") + .asOpt[Seq[JsObject]] + .map { arr => + arr.map { c => + DefaultWSCookie( + name = c.select("name").asString, + value = c.select("value").asString, + maxAge = c.select("maxAge").asOpt[Long], + path = c.select("path").asOpt[String], + domain = c.select("domain").asOpt[String], + secure = c.select("secure").asOpt[Boolean].getOrElse(false), + httpOnly = c.select("httpOnly").asOpt[Boolean].getOrElse(false) + ) + } + } + + def convertJsonPlayCookies(wasmResponse: JsValue): Option[Seq[Cookie]] = + wasmResponse + .select("cookies") + .asOpt[Seq[JsObject]] + .map { arr => + arr.map { c => + Cookie( + name = c.select("name").asString, + value = c.select("value").asString, + maxAge = c.select("maxAge").asOpt[Int], + path = c.select("path").asOpt[String].getOrElse("/"), + domain = c.select("domain").asOpt[String], + secure = c.select("secure").asOpt[Boolean].getOrElse(false), + httpOnly = c.select("httpOnly").asOpt[Boolean].getOrElse(false), + sameSite = c.select("domain").asOpt[String].flatMap(Cookie.SameSite.parse) + ) + } + } +} \ No newline at end of file diff --git a/otoroshi/build.sbt b/otoroshi/build.sbt index 31ae041d52..bcfcda4a5f 100644 --- a/otoroshi/build.sbt +++ b/otoroshi/build.sbt @@ -345,7 +345,9 @@ reStart / javaOptions ++= Seq( "-Dotoroshi.next.experimental.netty-server.accesslog=true", "-Dotoroshi.next.experimental.netty-server.wiretap=false", "-Dotoroshi.next.experimental.netty-server.http3.enabled=true", - "-Dotoroshi.loggers.otoroshi-wasm-debug=INFO", + "-Dotoroshi.loggers.otoroshi-wasm-debug=DEBUG", + "-Dotoroshi.loggers.otoroshi-wasm-vm-pool=DEBUG", + "-Dotoroshi.loggers.otoroshi-wasm-integration=DEBUG", "-Dotoroshi.loggers.otoroshi-proxy-wasm=TRACE", "-Dotoroshi.options.enable-json-media-type-with-open-charset=true", "-Dotoroshi.next.state-sync-interval=1000" diff --git a/otoroshi/conf/schemas/openapi-cfg.json b/otoroshi/conf/schemas/openapi-cfg.json index f32f5a9e91..d0c434b036 100644 --- a/otoroshi/conf/schemas/openapi-cfg.json +++ b/otoroshi/conf/schemas/openapi-cfg.json @@ -172,7 +172,6 @@ "entity_description.otoroshi.models.UserRight": "Represent a user right (teams, organizations) in otoroshi-ui", "entity_description.otoroshi.models.UserRights": "Represent a list of user rights", "entity_description.otoroshi.models.VerificationSettings": "jwt token verification settings", - "entity_description.otoroshi.models.WasmManagerSettings": "???", "entity_description.otoroshi.models.WasmPlugin": "???", "entity_description.otoroshi.models.WebAuthnOtoroshiAdmin": "An otoroshi admin user that uses webauthn at login", "entity_description.otoroshi.models.Webhook": "Settings for webhook call", @@ -642,9 +641,6 @@ "entity_description.otoroshi.wasm.WasmAuthorizations": "???", "entity_description.otoroshi.wasm.WasmConfig": "???", "entity_description.otoroshi.wasm.WasmDataRights": "???", - "entity_description.otoroshi.wasm.WasmSource": "???", - "entity_description.otoroshi.wasm.WasmSourceKind": "???", - "entity_description.otoroshi.wasm.WasmVmKillOptions": "???", "entity_description.otoroshi.wasm.proxywasm.CorazaWafConfig": "???", "operations.otoroshi.controllers.PrivateAppsController.registerSession_privateapps": "Registers a private app session", "operations.otoroshi.controllers.PrivateAppsController.sendSelfUpdateLink_privateapps": "Send an email to a user to update its own settings", @@ -1846,7 +1842,6 @@ "otoroshi.models.GlobalConfig.useAkkaHttpClient": "Globally use akka http client for everything", "otoroshi.models.GlobalConfig.useCircuitBreakers": "If enabled, services will be authorized to use circuit breakers", "otoroshi.models.GlobalConfig.userAgentSettings": "Settings for useragent extraction", - "otoroshi.models.GlobalConfig.wasmManagerSettings": "???", "otoroshi.models.GlobalJwtVerifier.algoSettings": "Algo settings of the verifier", "otoroshi.models.GlobalJwtVerifier.desc": "Verifier description", "otoroshi.models.GlobalJwtVerifier.id": "Verifier id", @@ -2163,10 +2158,6 @@ "otoroshi.models.UserRights.rights": "Access rights of a user", "otoroshi.models.VerificationSettings.arrayFields": "Fields array validation", "otoroshi.models.VerificationSettings.fields": "Fields validation", - "otoroshi.models.WasmManagerSettings.clientId": "???", - "otoroshi.models.WasmManagerSettings.clientSecret": "???", - "otoroshi.models.WasmManagerSettings.pluginsFilter": "???", - "otoroshi.models.WasmManagerSettings.url": "???", "otoroshi.models.WasmPlugin.config": "???", "otoroshi.models.WasmPlugin.description": "???", "otoroshi.models.WasmPlugin.id": "???", @@ -3028,10 +3019,8 @@ "otoroshi.next.plugins.WasmAccessValidator.config": "???", "otoroshi.next.plugins.WasmAccessValidator.functionName": "???", "otoroshi.next.plugins.WasmAccessValidator.instances": "???", - "otoroshi.next.plugins.WasmAccessValidator.killOptions": "???", "otoroshi.next.plugins.WasmAccessValidator.memoryPages": "???", "otoroshi.next.plugins.WasmAccessValidator.opa": "???", - "otoroshi.next.plugins.WasmAccessValidator.source": "???", "otoroshi.next.plugins.WasmAccessValidator.wasi": "???", "otoroshi.next.plugins.WasmBackend.allowedHosts": "???", "otoroshi.next.plugins.WasmBackend.allowedPaths": "???", @@ -3039,10 +3028,8 @@ "otoroshi.next.plugins.WasmBackend.config": "???", "otoroshi.next.plugins.WasmBackend.functionName": "???", "otoroshi.next.plugins.WasmBackend.instances": "???", - "otoroshi.next.plugins.WasmBackend.killOptions": "???", "otoroshi.next.plugins.WasmBackend.memoryPages": "???", "otoroshi.next.plugins.WasmBackend.opa": "???", - "otoroshi.next.plugins.WasmBackend.source": "???", "otoroshi.next.plugins.WasmBackend.wasi": "???", "otoroshi.next.plugins.WasmException.message": "???", "otoroshi.next.plugins.WasmJob.config": "???", @@ -3060,10 +3047,8 @@ "otoroshi.next.plugins.WasmOPA.config": "???", "otoroshi.next.plugins.WasmOPA.functionName": "???", "otoroshi.next.plugins.WasmOPA.instances": "???", - "otoroshi.next.plugins.WasmOPA.killOptions": "???", "otoroshi.next.plugins.WasmOPA.memoryPages": "???", "otoroshi.next.plugins.WasmOPA.opa": "???", - "otoroshi.next.plugins.WasmOPA.source": "???", "otoroshi.next.plugins.WasmOPA.wasi": "???", "otoroshi.next.plugins.WasmPreRoute.allowedHosts": "???", "otoroshi.next.plugins.WasmPreRoute.allowedPaths": "???", @@ -3071,10 +3056,8 @@ "otoroshi.next.plugins.WasmPreRoute.config": "???", "otoroshi.next.plugins.WasmPreRoute.functionName": "???", "otoroshi.next.plugins.WasmPreRoute.instances": "???", - "otoroshi.next.plugins.WasmPreRoute.killOptions": "???", "otoroshi.next.plugins.WasmPreRoute.memoryPages": "???", "otoroshi.next.plugins.WasmPreRoute.opa": "???", - "otoroshi.next.plugins.WasmPreRoute.source": "???", "otoroshi.next.plugins.WasmPreRoute.wasi": "???", "otoroshi.next.plugins.WasmRequestTransformer.allowedHosts": "???", "otoroshi.next.plugins.WasmRequestTransformer.allowedPaths": "???", @@ -3082,10 +3065,8 @@ "otoroshi.next.plugins.WasmRequestTransformer.config": "???", "otoroshi.next.plugins.WasmRequestTransformer.functionName": "???", "otoroshi.next.plugins.WasmRequestTransformer.instances": "???", - "otoroshi.next.plugins.WasmRequestTransformer.killOptions": "???", "otoroshi.next.plugins.WasmRequestTransformer.memoryPages": "???", "otoroshi.next.plugins.WasmRequestTransformer.opa": "???", - "otoroshi.next.plugins.WasmRequestTransformer.source": "???", "otoroshi.next.plugins.WasmRequestTransformer.wasi": "???", "otoroshi.next.plugins.WasmResponseTransformer.allowedHosts": "???", "otoroshi.next.plugins.WasmResponseTransformer.allowedPaths": "???", @@ -3093,10 +3074,8 @@ "otoroshi.next.plugins.WasmResponseTransformer.config": "???", "otoroshi.next.plugins.WasmResponseTransformer.functionName": "???", "otoroshi.next.plugins.WasmResponseTransformer.instances": "???", - "otoroshi.next.plugins.WasmResponseTransformer.killOptions": "???", "otoroshi.next.plugins.WasmResponseTransformer.memoryPages": "???", "otoroshi.next.plugins.WasmResponseTransformer.opa": "???", - "otoroshi.next.plugins.WasmResponseTransformer.source": "???", "otoroshi.next.plugins.WasmResponseTransformer.wasi": "???", "otoroshi.next.plugins.WasmRouteMatcher.allowedHosts": "???", "otoroshi.next.plugins.WasmRouteMatcher.allowedPaths": "???", @@ -3104,10 +3083,8 @@ "otoroshi.next.plugins.WasmRouteMatcher.config": "???", "otoroshi.next.plugins.WasmRouteMatcher.functionName": "???", "otoroshi.next.plugins.WasmRouteMatcher.instances": "???", - "otoroshi.next.plugins.WasmRouteMatcher.killOptions": "???", "otoroshi.next.plugins.WasmRouteMatcher.memoryPages": "???", "otoroshi.next.plugins.WasmRouteMatcher.opa": "???", - "otoroshi.next.plugins.WasmRouteMatcher.source": "???", "otoroshi.next.plugins.WasmRouteMatcher.wasi": "???", "otoroshi.next.plugins.WasmRouter.allowedHosts": "???", "otoroshi.next.plugins.WasmRouter.allowedPaths": "???", @@ -3115,10 +3092,8 @@ "otoroshi.next.plugins.WasmRouter.config": "???", "otoroshi.next.plugins.WasmRouter.functionName": "???", "otoroshi.next.plugins.WasmRouter.instances": "???", - "otoroshi.next.plugins.WasmRouter.killOptions": "???", "otoroshi.next.plugins.WasmRouter.memoryPages": "???", "otoroshi.next.plugins.WasmRouter.opa": "???", - "otoroshi.next.plugins.WasmRouter.source": "???", "otoroshi.next.plugins.WasmRouter.wasi": "???", "otoroshi.next.plugins.WasmSink.allowedHosts": "???", "otoroshi.next.plugins.WasmSink.allowedPaths": "???", @@ -3126,10 +3101,8 @@ "otoroshi.next.plugins.WasmSink.config": "???", "otoroshi.next.plugins.WasmSink.functionName": "???", "otoroshi.next.plugins.WasmSink.instances": "???", - "otoroshi.next.plugins.WasmSink.killOptions": "???", "otoroshi.next.plugins.WasmSink.memoryPages": "???", "otoroshi.next.plugins.WasmSink.opa": "???", - "otoroshi.next.plugins.WasmSink.source": "???", "otoroshi.next.plugins.WasmSink.wasi": "???", "otoroshi.next.plugins.XmlToJsonRequest.filter": "???", "otoroshi.next.plugins.XmlToJsonResponse.filter": "???", @@ -3499,21 +3472,11 @@ "otoroshi.wasm.WasmConfig.config": "???", "otoroshi.wasm.WasmConfig.functionName": "???", "otoroshi.wasm.WasmConfig.instances": "???", - "otoroshi.wasm.WasmConfig.killOptions": "???", "otoroshi.wasm.WasmConfig.memoryPages": "???", "otoroshi.wasm.WasmConfig.opa": "???", - "otoroshi.wasm.WasmConfig.source": "???", "otoroshi.wasm.WasmConfig.wasi": "???", "otoroshi.wasm.WasmDataRights.read": "???", "otoroshi.wasm.WasmDataRights.write": "???", - "otoroshi.wasm.WasmSource.kind": "???", - "otoroshi.wasm.WasmSource.opts": "???", - "otoroshi.wasm.WasmSource.path": "???", - "otoroshi.wasm.WasmVmKillOptions.immortal": "???", - "otoroshi.wasm.WasmVmKillOptions.maxAvgCallDuration": "???", - "otoroshi.wasm.WasmVmKillOptions.maxCalls": "???", - "otoroshi.wasm.WasmVmKillOptions.maxMemoryUsage": "???", - "otoroshi.wasm.WasmVmKillOptions.maxUnusedDuration": "???", "otoroshi.wasm.proxywasm.CorazaWafConfig.config": "???", "otoroshi.wasm.proxywasm.CorazaWafConfig.description": "???", "otoroshi.wasm.proxywasm.CorazaWafConfig.id": "???", diff --git a/otoroshi/conf/schemas/openapi.json b/otoroshi/conf/schemas/openapi.json index bd52ca8f8b..6ab1dec28c 100644 --- a/otoroshi/conf/schemas/openapi.json +++ b/otoroshi/conf/schemas/openapi.json @@ -20603,16 +20603,6 @@ "$ref" : "#/components/schemas/otoroshi.models.AutoCert", "description" : "Auto certs settings" }, - "wasmManagerSettings" : { - "oneOf" : [ { - "type" : "string", - "nullable" : true, - "description" : "null type" - }, { - "$ref" : "#/components/schemas/otoroshi.models.WasmManagerSettings" - } ], - "description" : "???" - }, "maintenanceMode" : { "type" : "boolean", "description" : "Global maintenant mode" @@ -22002,34 +21992,6 @@ "type" : "object", "description" : "Settings to use keypair from JWKS for verification" }, - "otoroshi.models.WasmManagerSettings" : { - "type" : "object", - "description" : "???", - "properties" : { - "url" : { - "type" : "string", - "description" : "???" - }, - "clientId" : { - "type" : "string", - "description" : "???" - }, - "clientSecret" : { - "type" : "string", - "description" : "???" - }, - "pluginsFilter" : { - "oneOf" : [ { - "type" : "string", - "nullable" : true, - "description" : "null type" - }, { - "type" : "string" - } ], - "description" : "???" - } - } - }, "otoroshi.ssl.pki.models.GenCsrResponse" : { "type" : "object", "description" : "Response for a csr generation operation", diff --git a/otoroshi/lib/common-wasm_2.12-1.0.0-SNAPSHOT.jar b/otoroshi/lib/common-wasm_2.12-1.0.0-SNAPSHOT.jar new file mode 100644 index 0000000000..e3c9515f1d Binary files /dev/null and b/otoroshi/lib/common-wasm_2.12-1.0.0-SNAPSHOT.jar differ diff --git a/otoroshi/public/openapi.json b/otoroshi/public/openapi.json index bd52ca8f8b..6ab1dec28c 100644 --- a/otoroshi/public/openapi.json +++ b/otoroshi/public/openapi.json @@ -20603,16 +20603,6 @@ "$ref" : "#/components/schemas/otoroshi.models.AutoCert", "description" : "Auto certs settings" }, - "wasmManagerSettings" : { - "oneOf" : [ { - "type" : "string", - "nullable" : true, - "description" : "null type" - }, { - "$ref" : "#/components/schemas/otoroshi.models.WasmManagerSettings" - } ], - "description" : "???" - }, "maintenanceMode" : { "type" : "boolean", "description" : "Global maintenant mode" @@ -22002,34 +21992,6 @@ "type" : "object", "description" : "Settings to use keypair from JWKS for verification" }, - "otoroshi.models.WasmManagerSettings" : { - "type" : "object", - "description" : "???", - "properties" : { - "url" : { - "type" : "string", - "description" : "???" - }, - "clientId" : { - "type" : "string", - "description" : "???" - }, - "clientSecret" : { - "type" : "string", - "description" : "???" - }, - "pluginsFilter" : { - "oneOf" : [ { - "type" : "string", - "nullable" : true, - "description" : "null type" - }, { - "type" : "string" - } ], - "description" : "???" - } - } - }, "otoroshi.ssl.pki.models.GenCsrResponse" : { "type" : "object", "description" : "Response for a csr generation operation",