From f32dc2c0eec8962cee6669a269e7a8c7aff7f3a3 Mon Sep 17 00:00:00 2001 From: Mathieu Ancelin Date: Fri, 4 Aug 2023 16:14:47 +0200 Subject: [PATCH] boostrap green score extension --- otoroshi/app/gateway/errors.scala | 6 +- otoroshi/app/gateway/http.scala | 2 +- otoroshi/app/gateway/websockets.scala | 2 +- otoroshi/app/greenscore/ecometrics.scala | 117 ++++++++ otoroshi/app/greenscore/extension.scala | 183 +++++++++++++ otoroshi/app/greenscore/greenrules.scala | 176 ++++++++++++ otoroshi/app/next/proxy/engine.scala | 4 +- otoroshi/conf/schemas/openapi.json | 2 +- otoroshi/javascript/src/backoffice.js | 7 + .../src/components/nginputs/components.js | 11 +- .../src/components/nginputs/form.js | 2 +- .../src/extensions/greenscore/greenscore.js | 254 ++++++++++++++++++ .../javascript/src/style/layout/_sidebar.scss | 4 +- .../extensions/coraza-extension.js | 90 +++---- otoroshi/public/openapi.json | 2 +- 15 files changed, 799 insertions(+), 63 deletions(-) create mode 100644 otoroshi/app/greenscore/ecometrics.scala create mode 100644 otoroshi/app/greenscore/extension.scala create mode 100644 otoroshi/app/greenscore/greenrules.scala create mode 100644 otoroshi/javascript/src/extensions/greenscore/greenscore.js diff --git a/otoroshi/app/gateway/errors.scala b/otoroshi/app/gateway/errors.scala index dd1c81d86e..193c000e62 100644 --- a/otoroshi/app/gateway/errors.scala +++ b/otoroshi/app/gateway/errors.scala @@ -114,7 +114,7 @@ object Errors { duration = duration, overhead = overhead, cbDuration = cbDuration, - overheadWoCb = overhead - cbDuration, + overheadWoCb = Math.abs(overhead - cbDuration), callAttempts = callAttempts, url = url, method = req.method, @@ -204,7 +204,7 @@ object Errors { duration = duration, overhead = overhead, cbDuration = cbDuration, - overheadWoCb = overhead - cbDuration, + overheadWoCb = Math.abs(overhead - cbDuration), callAttempts = callAttempts, url = url, method = req.method, @@ -260,7 +260,7 @@ object Errors { duration = duration, overhead = overhead, cbDuration = cbDuration, - overheadWoCb = overhead - cbDuration, + overheadWoCb = Math.abs(overhead - cbDuration), callAttempts = callAttempts, url = s"${req.theProtocol}://${req.theHost}${req.relativeUri}", method = req.method, diff --git a/otoroshi/app/gateway/http.scala b/otoroshi/app/gateway/http.scala index b5b7f989c9..d5e2120f0c 100644 --- a/otoroshi/app/gateway/http.scala +++ b/otoroshi/app/gateway/http.scala @@ -322,7 +322,7 @@ class HttpHandler()(implicit env: Env) { duration = duration, overhead = overhead, cbDuration = cbDuration, - overheadWoCb = overhead - cbDuration, + overheadWoCb = Math.abs(overhead - cbDuration), callAttempts = callAttempts, url = url, method = req.method, diff --git a/otoroshi/app/gateway/websockets.scala b/otoroshi/app/gateway/websockets.scala index 82b61c4ae1..85492eca8c 100644 --- a/otoroshi/app/gateway/websockets.scala +++ b/otoroshi/app/gateway/websockets.scala @@ -247,7 +247,7 @@ class WebSocketHandler()(implicit env: Env) { duration = duration, overhead = overhead, cbDuration = cbDuration, - overheadWoCb = overhead - cbDuration, + overheadWoCb = Math.abs(overhead - cbDuration), callAttempts = callAttempts, url = url, method = req.method, diff --git a/otoroshi/app/greenscore/ecometrics.scala b/otoroshi/app/greenscore/ecometrics.scala new file mode 100644 index 0000000000..82306389a0 --- /dev/null +++ b/otoroshi/app/greenscore/ecometrics.scala @@ -0,0 +1,117 @@ +package otoroshi.greenscore + +import com.codahale.metrics.UniformReservoir +import otoroshi.env.Env +import otoroshi.utils.cache.types.UnboundedTrieMap +import play.api.Logger +import play.api.libs.json.JsValue + +import java.util.{Timer => _} +import scala.collection.concurrent.TrieMap + +class GlobalScore { + private val backendsScore: UnboundedTrieMap[String, BackendScore] = TrieMap.empty + private val routesScore: UnboundedTrieMap[String, RouteScore] = TrieMap.empty + + def updateBackend(backendId: String, dataIn: Long, + dataOut: Long, + headers: Long, + headersOut: Long) = { + backendsScore.getOrElseUpdate(backendId, new BackendScore()).update(dataIn, dataOut, headers, headersOut) + } + + def updateRoute(routeId: String, overhead: Long, + overheadWithoutCircuitBreaker: Long, + circuitBreakerDuration: Long, + duration: Long, + plugins: Int) = { + routesScore.getOrElseUpdate(routeId, new RouteScore()) + .update(overhead, overheadWithoutCircuitBreaker, circuitBreakerDuration, duration, plugins) + } + + def compute(): Double = { + backendsScore.values.foldLeft(0.0) { case (acc, item) => acc + item.compute() } + + routesScore.values.foldLeft(0.0) { case (acc, item) => acc + item.compute() } + } +} + +class RouteScore { + private val overheadReservoir: UniformReservoir = new UniformReservoir() + private val overheadWithoutCircuitBreakerReservoir: UniformReservoir = new UniformReservoir() + private val circuitBreakerDurationReservoir: UniformReservoir = new UniformReservoir() + private val durationReservoir: UniformReservoir = new UniformReservoir() + private val pluginsReservoir: UniformReservoir = new UniformReservoir() + + def update(overhead: Long, + overheadWithoutCircuitBreaker: Long, + circuitBreakerDuration: Long, + duration: Long, + plugins: Int) = { + overheadReservoir.update(overhead) + overheadWithoutCircuitBreakerReservoir.update(overheadWithoutCircuitBreaker) + circuitBreakerDurationReservoir.update(circuitBreakerDuration) + durationReservoir.update(duration) + pluginsReservoir.update(plugins) + } + + def compute(): Double = { + overheadReservoir.getSnapshot.getMean + + overheadWithoutCircuitBreakerReservoir.getSnapshot.getMean + + circuitBreakerDurationReservoir.getSnapshot.getMean + + durationReservoir.getSnapshot.getMean + + pluginsReservoir.getSnapshot.getMean + } +} + +class BackendScore { + private val dataInReservoir: UniformReservoir = new UniformReservoir() + private val headersOutReservoir: UniformReservoir = new UniformReservoir() + private val dataOutReservoir: UniformReservoir = new UniformReservoir() + private val headersReservoir: UniformReservoir = new UniformReservoir() + + def update(dataIn: Long, + dataOut: Long, + headers: Long, + headersOut: Long) = { + dataInReservoir.update(dataIn) + dataOutReservoir.update(dataOut) + headersReservoir.update(headers) + headersOutReservoir.update(headersOut) + } + + def compute(): Double = { + dataInReservoir.getSnapshot.getMean + + dataOutReservoir.getSnapshot.getMean + + headersReservoir.getSnapshot.getMean + + headersOutReservoir.getSnapshot.getMean + } +} + +class EcoMetrics(env: Env) { + + private implicit val ev = env + private implicit val ec = env.otoroshiExecutionContext + + private val logger = Logger("otoroshi-eco-metrics") + + private val registry = new GlobalScore() + + def compute() = registry.compute() + + def updateBackend(backendId: String, + dataIn: Long, + dataOut: Long, + headers: Long, + headersOut: Long) = { + registry.updateBackend(backendId, dataIn, dataOut, headers, headersOut) + } + + def updateRoute(routeId: String, + overhead: Long, + overheadWoCb: Long, + cbDuration: Long, + duration: Long, + plugins: Int) = { + registry.updateRoute(routeId, overhead, overheadWoCb, cbDuration, duration, plugins) + } +} \ No newline at end of file diff --git a/otoroshi/app/greenscore/extension.scala b/otoroshi/app/greenscore/extension.scala new file mode 100644 index 0000000000..13ba4c1599 --- /dev/null +++ b/otoroshi/app/greenscore/extension.scala @@ -0,0 +1,183 @@ +package otoroshi.greenscore + +import akka.actor.{Actor, ActorRef, Props} +import otoroshi.api.{GenericResourceAccessApiWithState, Resource, ResourceVersion} +import otoroshi.env.Env +import otoroshi.events.{GatewayEvent, OtoroshiEvent} +import otoroshi.models.{EntityLocation, EntityLocationSupport} +import otoroshi.next.extensions.{AdminExtension, AdminExtensionEntity, AdminExtensionId} +import otoroshi.storage.{BasicStore, RedisLike, RedisLikeStore} +import otoroshi.utils.cache.types.UnboundedTrieMap +import otoroshi.utils.syntax.implicits._ +import play.api.Logger +import play.api.libs.json._ +import play.api.mvc.Results + +import scala.concurrent.Future +import scala.util._ + +object OtoroshiEventListener { + def props(ext: GreenScoreExtension, env: Env) = Props(new OtoroshiEventListener(ext, env)) +} + +class OtoroshiEventListener(ext: GreenScoreExtension, env: Env) extends Actor { + override def receive: Receive = { + case evt: GatewayEvent => { + val routeId = evt.route.map(_.id).getOrElse(evt.`@serviceId`) + ext.ecoMetrics.updateRoute( + routeId = routeId, + overhead = evt.overhead, + overheadWoCb = evt.overheadWoCb, + cbDuration = evt.cbDuration, + duration = evt.duration, + plugins = evt.route.map(_.plugins.slots.size).getOrElse(0), + ) + ext.ecoMetrics.updateBackend( + backendId = evt.target.scheme + evt.target.host + evt.target.uri, + dataIn = evt.data.dataIn, + dataOut = evt.data.dataOut, + headers = evt.headers.foldLeft(0L) { case (acc, item) => + acc + item.key.byteString.size + item.value.byteString.size + 3 // 3 = -> + } + evt.method.byteString.size + evt.url.byteString.size + evt.protocol.byteString.size + 2, + headersOut = evt.headersOut.foldLeft(0L) { case (acc, item) => + acc + item.key.byteString.size + item.value.byteString.size + 3 // 3 = -> + } + evt.protocol.byteString.size + 1 + 3 + Results.Status(evt.status).header.reasonPhrase.map(_.byteString.size).getOrElse(0) + ) + ext.logger.debug(s"global score for ${routeId}: ${ext.ecoMetrics.compute()}") + } + case _ => + } +} + +case class GreenScoreEntity( + location: EntityLocation, + id: String, + name: String, + description: String, + tags: Seq[String], + metadata: Map[String, String], + routes: Seq[String], + config: GreenScoreConfig, +) extends EntityLocationSupport { + override def internalId: String = id + override def json: JsValue = GreenScoreEntity.format.writes(this) + override def theName: String = name + override def theDescription: String = description + override def theTags: Seq[String] = tags + override def theMetadata: Map[String, String] = metadata +} + +object GreenScoreEntity { + val format = new Format[GreenScoreEntity] { + override def writes(o: GreenScoreEntity): JsValue = o.location.jsonWithKey ++ Json.obj( + "id" -> o.id, + "name" -> o.name, + "description" -> o.description, + "metadata" -> o.metadata, + "tags" -> JsArray(o.tags.map(JsString.apply)), + "routes" -> JsArray(o.routes.map(JsString.apply)), + "config" -> o.config.json, + ) + + override def reads(json: JsValue): JsResult[GreenScoreEntity] = Try { + GreenScoreEntity( + location = otoroshi.models.EntityLocation.readFromKey(json), + id = (json \ "id").as[String], + name = (json \ "name").as[String], + description = (json \ "description").as[String], + metadata = (json \ "metadata").asOpt[Map[String, String]].getOrElse(Map.empty), + tags = (json \ "tags").asOpt[Seq[String]].getOrElse(Seq.empty[String]), + routes = json.select("routes").asOpt[Seq[String]].getOrElse(Seq.empty), + config = json.select("config").asOpt[JsObject].map(v => GreenScoreConfig.format.reads(v).get).get, + ) + } match { + case Failure(ex) => JsError(ex.getMessage) + case Success(value) => JsSuccess(value) + } + } +} + +trait GreenScoreDataStore extends BasicStore[GreenScoreEntity] + +class KvGreenScoreDataStore(extensionId: AdminExtensionId, redisCli: RedisLike, _env: Env) + extends GreenScoreDataStore + with RedisLikeStore[GreenScoreEntity] { + override def fmt: Format[GreenScoreEntity] = GreenScoreEntity.format + override def redisLike(implicit env: Env): RedisLike = redisCli + override def key(id: String): String = s"${_env.storageRoot}:extensions:${extensionId.cleanup}:greenscores:$id" + override def extractId(value: GreenScoreEntity): String = value.id +} + +class GreenScoreAdminExtensionDatastores(env: Env, extensionId: AdminExtensionId) { + val greenscoresDatastore: GreenScoreDataStore = new KvGreenScoreDataStore(extensionId, env.datastores.redis, env) +} + +class GreenScoreAdminExtensionState(env: Env) { + + private val greenScores = new UnboundedTrieMap[String, GreenScoreEntity]() + + def greenScore(id: String): Option[GreenScoreEntity] = greenScores.get(id) + def allGreenScores(): Seq[GreenScoreEntity] = greenScores.values.toSeq + + private[greenscore] def updateGreenScores(values: Seq[GreenScoreEntity]): Unit = { + greenScores.addAll(values.map(v => (v.id, v))).remAll(greenScores.keySet.toSeq.diff(values.map(_.id))) + } +} + +class GreenScoreExtension(val env: Env) extends AdminExtension { + + private[greenscore] val logger = Logger("otoroshi-extension-green-score") + private[greenscore] val ecoMetrics = new EcoMetrics(env) + private val listener: ActorRef = env.analyticsActorSystem.actorOf(OtoroshiEventListener.props(this, env)) + private lazy val datastores = new GreenScoreAdminExtensionDatastores(env, id) + private lazy val states = new GreenScoreAdminExtensionState(env) + + override def id: AdminExtensionId = AdminExtensionId("otoroshi.extensions.GreenScore") + + override def enabled: Boolean = env.isDev || configuration.getOptional[Boolean]("enabled").getOrElse(false) + + override def name: String = "Green Score" + + override def description: Option[String] = None + + override def start(): Unit = { + env.analyticsActorSystem.eventStream.subscribe(listener, classOf[OtoroshiEvent]) + } + + override def stop(): Unit = { + env.analyticsActorSystem.eventStream.unsubscribe(listener) + } + + override def syncStates(): Future[Unit] = { + implicit val ec = env.otoroshiExecutionContext + implicit val ev = env + for { + scores <- datastores.greenscoresDatastore.findAll() + } yield { + states.updateGreenScores(scores) + () + } + } + + override def entities(): Seq[AdminExtensionEntity[EntityLocationSupport]] = { + Seq( + AdminExtensionEntity( + Resource( + "GreenScore", + "green-scores", + "green-score", + "green-score.extensions.otoroshi.io", + ResourceVersion("v1", true, false, true), + GenericResourceAccessApiWithState[GreenScoreEntity]( + GreenScoreEntity.format, + id => datastores.greenscoresDatastore.key(id), + c => datastores.greenscoresDatastore.extractId(c), + stateAll = () => states.allGreenScores(), + stateOne = id => states.greenScore(id), + stateUpdate = values => states.updateGreenScores(values), + ) + ) + ) + ) + } +} diff --git a/otoroshi/app/greenscore/greenrules.scala b/otoroshi/app/greenscore/greenrules.scala new file mode 100644 index 0000000000..a5488ffcf4 --- /dev/null +++ b/otoroshi/app/greenscore/greenrules.scala @@ -0,0 +1,176 @@ +package otoroshi.greenscore + +import otoroshi.next.plugins.api._ +import otoroshi.utils.syntax.implicits._ +import play.api.libs.json._ + +import scala.util.{Failure, Success, Try} + +case class RuleId(value: String) +case class SectionId(value: String) + +case class RulesSection(id: SectionId, rules: Seq[Rule]) { + def json(): JsValue = Json.obj( + "id" -> id.value, + "rules" -> rules.map(_.json()) + ) +} + +object RulesSection { + def reads(json: JsValue): JsResult[Seq[RulesSection]] = { + Try { + JsSuccess(json.as[JsArray].value.map(item => RulesSection( + id = SectionId(item.select("id").as[String]), + rules = item.select("rules").as[Seq[Rule]](Rule.reads) + ))) + } recover { case e => + JsError(e.getMessage) + } get + } +} + +case class Rule(id: RuleId, + description: Option[String] = None, + advice: Option[String] = None, + weight: Double, + sectionWeight: Double, + enabled: Boolean = true + ) { + def json(): JsValue = Json.obj( + "id" -> id.value, + "description" -> description, + "advice" -> advice, + "weight" -> weight, + "section_weight" -> sectionWeight, + "enabled" -> enabled + ) +} + +object Rule { + def reads(json: JsValue): JsResult[Seq[Rule]] = { + Try { + JsSuccess(json.as[JsArray].value.map(item => Rule( + id = RuleId(item.select("id").as[String]), + description = item.select("description").asOpt[String], + advice = item.select("advice").asOpt[String], + weight = item.select("weight").as[Double], + sectionWeight = item.select("section_weight").as[Double], + enabled = item.select("enabled").as[Boolean] + ))) + } recover { case e => + JsError(e.getMessage) + } get + } +} + +object RulesManager { + val sections = Seq( + RulesSection(SectionId("architecture"), Seq( + Rule(RuleId("AR01"), weight = 25, sectionWeight = 25, + description = "Use Event Driven Architecture to avoid polling madness and inform subscribers of an update.".some, + advice = "Use Event Driven Architecture to avoid polling madness.".some), + Rule(RuleId("AR02"), weight = 25, sectionWeight = 25, + description = "API runtime close to the Consumer.".some, + advice = "Deploy the API near the consumer".some), + Rule(RuleId("AR03"), weight = 25, sectionWeight = 25, + description = "Ensure the same API does not exist *.".some, + advice = "Ensure only one API fit the same need".some), + Rule(RuleId("AR04"), weight = 25, sectionWeight = 25, + description = "Use scalable infrastructure to avoid over-provisioning.".some, + advice = "Use scalable infrastructure to avoid over-provisioning".some), + )), + RulesSection(SectionId("design"), Seq( + Rule(RuleId("DE01"), weight = 25, sectionWeight = 40, + description = "Choose an exchange format with the smallest size (JSON is smallest than XML).".some, + advice = "Prefer an exchange format with the smallest size (JSON is smaller than XML).".some), + Rule(RuleId("DE02"), weight = 15, sectionWeight = 40, + description = "new API --> cache usage.".some, + advice = "Use cache to avoid useless requests and preserve compute resources.".some), + Rule(RuleId("DE03"), weight = 20, sectionWeight = 40, + description = "Existing API --> cache usage efficiency.".some, + advice = "Use the cache efficiently to avoid useless resources consumtion.".some), + Rule(RuleId("DE04"), weight = 2, sectionWeight = 40, + description = "Opaque token usage.".some, + advice = "Prefer opaque token usage prior to JWT".some), + Rule(RuleId("DE05"), weight = 4, sectionWeight = 40, + description = "Align the cache refresh with the datasource **.".some, + advice = "Align cache refresh strategy with the data source ".some), + Rule(RuleId("DE06"), weight = 4, sectionWeight = 40, + description = "Allow part refresh of cache.".some, + advice = "Allow a part cache refresh".some), + Rule(RuleId("DE07"), weight = 10, sectionWeight = 40, + description = "Is System, Business or cx API ?.".some, + advice = "Use Business & Cx APIs closer to the business need".some), + Rule(RuleId("DE08"), weight = 2.5, sectionWeight = 40, + description = "Possibility to filter results.".some, + advice = "Implement filtering mechanism to limit the payload size".some), + Rule(RuleId("DE09"), weight = 10, sectionWeight = 40, + description = "Leverage OData or GraphQL for your databases APIs.".some, + advice = "Leverage OData or GraphQL when relevant".some), + Rule(RuleId("DE10"), weight = 5, sectionWeight = 40, + description = "Redundant data information in the same API.".some, + advice = "Avoid redundant data information in the same API".some), + Rule(RuleId("DE11"), weight = 2.5, sectionWeight = 40, + description = "Possibility to fitler pagination results.".some, + advice = "Implement pagination mechanism to limit the payload size".some) + )), + RulesSection(SectionId("usage"), Seq( + Rule(RuleId("US01"), weight = 5, sectionWeight = 25, + description = "Use query parameters for GET Methods.".some, + advice = "Implement filters to limit which data are returned by the API (send just the data the consumer need).".some), + Rule(RuleId("US02"), weight = 10, sectionWeight = 25, + description = "Decomission end of life or not used APIs.".some, + advice = "Decomission end of life or not used APIs".some), + Rule(RuleId("US03"), weight = 10, sectionWeight = 25, + description = "Number of API version <=2 .".some, + advice = "Compute resources saved & Network impact reduced".some), + Rule(RuleId("US04"), weight = 10, sectionWeight = 25, + description = "Usage of Pagination of results available.".some, + advice = "Optimize queries to limit the information returned to what is strictly necessary.".some), + Rule(RuleId("US05"), weight = 20, sectionWeight = 25, + description = "Choosing relevant data representation (user don’t need to do multiple calls) is Cx API ?.".some, + advice = "Choose the correct API based on use case to avoid requests on multiple systems or large number of requests. Refer to the data catalog to validate the data source.".some), + Rule(RuleId("US06"), weight = 25, sectionWeight = 25, + description = "Number of Consumers.".some, + advice = "Deploy an API well designed and documented to increase the reuse rate. Rate based on number of different consumers".some), + Rule(RuleId("US07"), weight = 20, sectionWeight = 25, + description = "Error rate.".some, + advice = "Monitor and decrease the error rate to avoid over processing".some) + )), + RulesSection(SectionId("log"), Seq( + Rule(RuleId("LO01"), weight = 100, sectionWeight = 10, description = "Logs retention.".some, advice = "Align log retention period to the business need (ops and Legal)".some) + )) + ) +} + +case class GreenScoreConfig(sections: Seq[RulesSection]) extends NgPluginConfig { + def json: JsValue = Json.obj( + "sections" -> sections.map(section => { + Json.obj( + "id" -> section.id.value, + "rules" -> section.rules.map(_.json()) + ) + }) + ) +} + +object GreenScoreConfig { + def readFrom(lookup: JsLookupResult): GreenScoreConfig = { + lookup match { + case JsDefined(value) => format.reads(value).getOrElse(GreenScoreConfig(sections = RulesManager.sections)) + case _: JsUndefined => GreenScoreConfig(sections = RulesManager.sections) + } + } + + val format = new Format[GreenScoreConfig] { + override def reads(json: JsValue): JsResult[GreenScoreConfig] = Try { + GreenScoreConfig( + sections = json.select("sections").as[Seq[RulesSection]](RulesSection.reads) + ) + } match { + case Failure(e) => JsError(e.getMessage) + case Success(c) => JsSuccess(c) + } + override def writes(o: GreenScoreConfig): JsValue = o.json + } +} diff --git a/otoroshi/app/next/proxy/engine.scala b/otoroshi/app/next/proxy/engine.scala index 266669cea8..53a592899a 100644 --- a/otoroshi/app/next/proxy/engine.scala +++ b/otoroshi/app/next/proxy/engine.scala @@ -3395,7 +3395,7 @@ class ProxyEngine() extends RequestHandler { duration = duration, overhead = overhead, cbDuration = cbDuration, - overheadWoCb = overhead - cbDuration, + overheadWoCb = Math.abs(overhead - cbDuration), callAttempts = sb.attempts, url = rawRequest.theUrl, method = rawRequest.method, @@ -3551,7 +3551,7 @@ class ProxyEngine() extends RequestHandler { duration = duration, overhead = overhead, cbDuration = cbDuration, - overheadWoCb = overhead - cbDuration, + overheadWoCb = Math.abs(overhead - cbDuration), callAttempts = sb.attempts, url = rawRequest.theUrl, method = rawRequest.method, diff --git a/otoroshi/conf/schemas/openapi.json b/otoroshi/conf/schemas/openapi.json index fb5d3c74df..bb95a00d86 100644 --- a/otoroshi/conf/schemas/openapi.json +++ b/otoroshi/conf/schemas/openapi.json @@ -3,7 +3,7 @@ "info" : { "title" : "Otoroshi Admin API", "description" : "Admin API of the Otoroshi reverse proxy", - "version" : "16.6.0-dev", + "version" : "16.7.0-dev", "contact" : { "name" : "Otoroshi Team", "email" : "oss@maif.fr" diff --git a/otoroshi/javascript/src/backoffice.js b/otoroshi/javascript/src/backoffice.js index 41e353f353..8cca0a0821 100644 --- a/otoroshi/javascript/src/backoffice.js +++ b/otoroshi/javascript/src/backoffice.js @@ -27,6 +27,8 @@ import { v4 as uuid } from 'uuid'; import { registerAlert, registerConfirm, registerPrompt, registerPopup } from './components/window'; +import { setupGreenScoreExtension } from './extensions/greenscore/greenscore'; + import * as Forms from './forms/ng_plugins/index'; if (!window.Symbol) { @@ -170,6 +172,7 @@ export function init(node) { setupKonami(); setupOutdatedBrowser(); setupWindowUtils(); + setupLocalExtensions(); ReactDOM.render(, node); } @@ -243,3 +246,7 @@ export function getExtensions() { export function getExtension(name) { return _extensions[name]; } + +function setupLocalExtensions() { + setupGreenScoreExtension(registerExtension); +} diff --git a/otoroshi/javascript/src/components/nginputs/components.js b/otoroshi/javascript/src/components/nginputs/components.js index 63e8e9e092..4c2569cf36 100644 --- a/otoroshi/javascript/src/components/nginputs/components.js +++ b/otoroshi/javascript/src/components/nginputs/components.js @@ -48,12 +48,10 @@ export class NgFormRenderer extends Component { componentDidMount() { if (this.props && this.props.rawSchema) { - const folded = - ((this.props.rawSchema.props ? this.props.rawSchema.props.collapsable : false) || - this.props.rawSchema.collapsable) && - ((this.props.rawSchema.props ? this.props.rawSchema.props.collapsed : true) || - this.props.rawSchema.collapsed); - + const props = this.props.rawSchema.props + const collapsable = (props && Object.keys(props).length > 0) ? props.collapsable : this.props.rawSchema.collapsable; + const collapsed = (props && Object.keys(props).length > 0) ? props.collapsed : this.props.rawSchema.collapsed; + const folded = collapsable && collapsed; this.setState({ folded: folded === undefined ? true : folded }); } } @@ -182,6 +180,7 @@ export class NgFormRenderer extends Component { title = isFunction(titleVar) ? titleVar(this.props.value) : titleVar.replace(/_/g, ' '); } catch (e) { // console.log(e) + title = titleVar; } const noTitle = rawSchemaProps.noTitle || rawSchema.noTitle; diff --git a/otoroshi/javascript/src/components/nginputs/form.js b/otoroshi/javascript/src/components/nginputs/form.js index bc646b300d..52984a7db7 100644 --- a/otoroshi/javascript/src/components/nginputs/form.js +++ b/otoroshi/javascript/src/components/nginputs/form.js @@ -574,7 +574,7 @@ export class NgForm extends Component { rawSchema={{ label, collapsable: config.readOnly ? false : collapsable === undefined ? true : collapsable, - collapsed: config.readOnly ? false : collapsed === undefined ? false : true, + collapsed: config.readOnly ? false : collapsed === undefined ? false : collapsed, showSummary: summaryFields, summaryFields, }} diff --git a/otoroshi/javascript/src/extensions/greenscore/greenscore.js b/otoroshi/javascript/src/extensions/greenscore/greenscore.js new file mode 100644 index 0000000000..b21ed60cca --- /dev/null +++ b/otoroshi/javascript/src/extensions/greenscore/greenscore.js @@ -0,0 +1,254 @@ +import React, { Component } from 'react'; +import { + NgBooleanRenderer +} from '../../components/nginputs' +import { Table } from '../../components/inputs/Table'; +import * as BackOfficeServices from '../../services/BackOfficeServices'; +import { v4 as uuid } from 'uuid'; + +export const MAX_GREEN_SCORE_NOTE = 6000; +const GREEN_SCORE_GRADES = { + "#2ecc71": rank => rank >= MAX_GREEN_SCORE_NOTE, + "#27ae60": rank => rank < MAX_GREEN_SCORE_NOTE && rank >= 3000, + "#f1c40f": rank => rank < 3000 && rank >= 2000, + "#d35400": rank => rank < 2000 && rank >= 1000, + "#c0392b": rank => rank < 1000 +} + +export function calculateGreenScore(routeRules) { + const { sections } = routeRules; + + const score = sections.reduce((acc, item) => { + return acc + item.rules.reduce((acc, rule) => { + return acc += (rule.enabled ? MAX_GREEN_SCORE_NOTE * (rule.section_weight / 100) * (rule.weight / 100) : 0) + }, 0) + }, 0); + + const rankIdx = Object.entries(GREEN_SCORE_GRADES).findIndex(grade => grade[1](score)) + + return { + score, + rank: rankIdx === -1 ? "Not evaluated" : Object.keys(GREEN_SCORE_GRADES)[rankIdx], + letter: String.fromCharCode(65 + rankIdx) + } +} + +export function GreenScoreForm(props) { + const rootObject = props.rootValue?.green_score_rules; + const sections = rootObject?.sections || []; + + const onChange = (checked, currentSectionIdx, currentRuleIdx) => { + props.rootOnChange({ + ...props.rootValue, + green_score_rules: { + ...props.rootValue.green_score_rules, + sections: sections.map((section, sectionIdx) => { + if (currentSectionIdx !== sectionIdx) + return section + + return { + ...section, + rules: section.rules.map((rule, ruleIdx) => { + if (ruleIdx !== currentRuleIdx) + return rule; + + + console.log('changed') + return { + ...rule, + enabled: checked + } + }) + } + }) + } + }) + } + + return
+ {sections.map(({ id, rules }, currentSectionIdx) => { + return
+

{id}

+ {rules.map(({ id, description, enabled, advice }, currentRuleIdx) => { + return
{ + e.stopPropagation(); + onChange(!enabled, currentSectionIdx, currentRuleIdx) + }}> +
+

{description}

+

{advice}

+
+
+ onChange(checked, currentSectionIdx, currentRuleIdx)} + schema={{}} + ngOptions={{ + spread: true + }} + /> +
+
+ })} +
+ })} +
+} + +class GreenScoreConfigsPage extends Component { + + formSchema = { + _loc: { + type: 'location', + props: {}, + }, + id: { type: 'string', disabled: true, props: { label: 'Id', placeholder: '---' } }, + name: { + type: 'string', + props: { label: 'Name', placeholder: 'My Awesome Green Score' }, + }, + description: { + type: 'string', + props: { label: 'Description', placeholder: 'Description of the Green Score config' }, + }, + metadata: { + type: 'object', + props: { label: 'Metadata' }, + }, + tags: { + type: 'array', + props: { label: 'Tags' }, + }, + routes: { + type: 'array', + props: { label: 'Routes' } + }, + // TODO: display the score + config: { + // TODO: use a custom form with all flags + type: 'jsonobjectcode', + props: { + label: 'raw config.' + } + } + }; + + columns = [ + { + title: 'Name', + filterId: 'name', + content: (item) => item.name, + }, + { title: 'Description', filterId: 'description', content: (item) => item.description }, + ]; + + formFlow = ['_loc', 'id', 'name', 'description', 'tags', 'metadata', 'routes', 'config']; + + componentDidMount() { + this.props.setTitle(`All Green Score configs.`); + } + + client = BackOfficeServices.apisClient('green-score.extensions.otoroshi.io', 'v1', 'green-scores'); + + render() { + return ( + ({ + id: 'green-score-config_' + uuid(), + name: 'My Green Score', + description: 'An awesome Green Score', + tags: [], + metadata: {}, + routes: [], + config: { + + } + })} + itemName="Green Score config" + formSchema={this.formSchema} + formFlow={this.formFlow} + columns={this.columns} + stayAfterSave={true} + fetchItems={(paginationState) => this.client.findAll()} + updateItem={this.client.update} + deleteItem={this.client.delete} + createItem={this.client.create} + navigateTo={(item) => { + window.location = `/bo/dashboard/extensions/green-score/green-score-configs/edit/${item.id}` + }} + itemUrl={(item) => `/bo/dashboard/extensions/green-score/green-score-configs/edit/${item.id}`} + showActions={true} + showLink={true} + rowNavigation={true} + extractKey={(item) => item.id} + export={true} + kubernetesKind={"GreenScore"} + /> + ); + } +} + +const GreenScoreExtensionId = "otoroshi.extensions.GreenScore" +const GreenScoreExtension = (ctx) => { + return { + id: GreenScoreExtensionId, + sidebarItems: [ + // TODO: add here if we want icon in sidebar + ], + creationItems: [], + dangerZoneParts: [], + features: [ + { + title: 'Green Score configs.', + description: 'All your Green Score configs.', + img: 'private-apps', // TODO: change image + link: '/extensions/green-score/green-score-configs', + display: () => true, + icon: () => 'fa-cubes', // TODO: change icon + }, + ], + searchItems: [ + { + action: () => { + window.location.href = `/bo/dashboard/green-score/green-score-configs` + }, + env: , + label: 'Green Score configs.', + value: 'green-score-configs', + } + ], + routes: [ + // TODO: add more route here if needed + { + path: '/extensions/green-score/green-score-configs/:taction/:titem', + component: (props) => { + return + } + }, + { + path: '/extensions/green-score/green-score-configs/:taction', + component: (props) => { + return + } + }, + { + path: '/extensions/green-score/green-score-configs', + component: (props) => { + return + } + } + ], + } +} + +export function setupGreenScoreExtension(registerExtensionThunk) { + registerExtensionThunk(GreenScoreExtensionId, GreenScoreExtension); +} \ No newline at end of file diff --git a/otoroshi/javascript/src/style/layout/_sidebar.scss b/otoroshi/javascript/src/style/layout/_sidebar.scss index 4b76f287db..b07c48e1db 100644 --- a/otoroshi/javascript/src/style/layout/_sidebar.scss +++ b/otoroshi/javascript/src/style/layout/_sidebar.scss @@ -15,8 +15,8 @@ scrollbar-width: none; a{ - color: var(--color_level1); - font-size: 14px; + color: var(--color_level3); + font-size: 16px; // text-transform: uppercase; height: 32px; &.active { diff --git a/otoroshi/public/javascripts/extensions/coraza-extension.js b/otoroshi/public/javascripts/extensions/coraza-extension.js index 2b5ecd20a1..81040c73c4 100644 --- a/otoroshi/public/javascripts/extensions/coraza-extension.js +++ b/otoroshi/public/javascripts/extensions/coraza-extension.js @@ -69,51 +69,51 @@ render() { return ( - React.createElement(Table, { - parentProps: this.props, - selfUrl: "extensions/coraza-waf/coraza-configs", - defaultTitle: "All Coraza WAF configs.", - defaultValue: () => ({ - id: 'coraza-waf-config_' + uuid(), - name: 'My WAF', - description: 'An awesome WAF', - tags: [], - metadata: {}, - inspect_body: true, - config: { - "directives_map": { - "default": [ - "Include @recommended-conf", - "Include @crs-setup-conf", - "Include @owasp_crs/*.conf", - "SecRuleEngine DetectionOnly" - ] - }, - "default_directives": "default", - "metric_labels": {}, - "per_authority_directives": {} - } - }), - itemName: "Coraza WAF config", - formSchema: this.formSchema, - formFlow: this.formFlow, - columns: this.columns, - stayAfterSave: true, - fetchItems: (paginationState) => this.client.findAll(), - updateItem: this.client.update, - deleteItem: this.client.delete, - createItem: this.client.create, - navigateTo: (item) => { - window.location = `/bo/dashboard/extensions/coraza-waf/coraza-configs/edit/${item.id}` - }, - itemUrl: (item) => `/bo/dashboard/extensions/coraza-waf/coraza-configs/edit/${item.id}`, - showActions: true, - showLink: true, - rowNavigation: true, - extractKey: (item) => item.id, - export: true, - kubernetesKind: "CorazaConfig" - }, null) + React.createElement(Table, { + parentProps: this.props, + selfUrl: "extensions/coraza-waf/coraza-configs", + defaultTitle: "All Coraza WAF configs.", + defaultValue: () => ({ + id: 'coraza-waf-config_' + uuid(), + name: 'My WAF', + description: 'An awesome WAF', + tags: [], + metadata: {}, + inspect_body: true, + config: { + "directives_map": { + "default": [ + "Include @recommended-conf", + "Include @crs-setup-conf", + "Include @owasp_crs/*.conf", + "SecRuleEngine DetectionOnly" + ] + }, + "default_directives": "default", + "metric_labels": {}, + "per_authority_directives": {} + } + }), + itemName: "Coraza WAF config", + formSchema: this.formSchema, + formFlow: this.formFlow, + columns: this.columns, + stayAfterSave: true, + fetchItems: (paginationState) => this.client.findAll(), + updateItem: this.client.update, + deleteItem: this.client.delete, + createItem: this.client.create, + navigateTo: (item) => { + window.location = `/bo/dashboard/extensions/coraza-waf/coraza-configs/edit/${item.id}` + }, + itemUrl: (item) => `/bo/dashboard/extensions/coraza-waf/coraza-configs/edit/${item.id}`, + showActions: true, + showLink: true, + rowNavigation: true, + extractKey: (item) => item.id, + export: true, + kubernetesKind: "CorazaConfig" + }, null) ); } } diff --git a/otoroshi/public/openapi.json b/otoroshi/public/openapi.json index fb5d3c74df..bb95a00d86 100644 --- a/otoroshi/public/openapi.json +++ b/otoroshi/public/openapi.json @@ -3,7 +3,7 @@ "info" : { "title" : "Otoroshi Admin API", "description" : "Admin API of the Otoroshi reverse proxy", - "version" : "16.6.0-dev", + "version" : "16.7.0-dev", "contact" : { "name" : "Otoroshi Team", "email" : "oss@maif.fr"