diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8da947 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target +*.iml +.idea +out diff --git a/AGPL_FILE_HEADER b/AGPL_FILE_HEADER new file mode 100755 index 0000000..76296e4 --- /dev/null +++ b/AGPL_FILE_HEADER @@ -0,0 +1,17 @@ +Copyright 2011-2016 Green Energy Corp. + +Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more +contributor license agreements. See the NOTICE file distributed with this +work for additional information regarding copyright ownership. Green Energy +Corp licenses this file to you under the GNU Affero General Public License +Version 3.0 (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + +http://www.gnu.org/licenses/agpl.html + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations under +the License. + diff --git a/APACHE_FILE_HEADER b/APACHE_FILE_HEADER new file mode 100755 index 0000000..eedcedf --- /dev/null +++ b/APACHE_FILE_HEADER @@ -0,0 +1,16 @@ +Copyright 2011-2016 Green Energy Corp. + +Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more +contributor license agreements. See the NOTICE file distributed with this +work for additional information regarding copyright ownership. Green Energy +Corp licenses this file to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations under +the License. \ No newline at end of file diff --git a/CodeFormat.xml b/CodeFormat.xml new file mode 100755 index 0000000..b750e93 --- /dev/null +++ b/CodeFormat.xml @@ -0,0 +1,279 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..daaf964 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +GreenBus Platform +-------------------- + +Prerequisites: + +- Maven 3 +- Postgresql +- Qpid Broker (for integration tests) +- Google Protocol Buffers compiler (version 2.5.0) + +### Setting up Postgresql + +The Postgres install must be initialized with the databases and users. The relevant configuration is contained in +the init_postgres.sql. + + sudo su postgres -c psql < init_postgres.sql + +### Setting up Qpid + +Qpid needs have auth settings that match the configuration in `io.greenbus.msg.amqp.cfg`. For development purposes, +the Qpid auth can be disabled entirely by setting `auth=no` in the broker configuration and removing references to the +ACL submodule. + +### Building with Maven + +Build: + + mvn clean install + +### Dependency Projects + +The GreenBus platform project is based on [GreenBus Messaging](https://github.com/gec/greenbus-msg). \ No newline at end of file diff --git a/app-framework/pom.xml b/app-framework/pom.xml new file mode 100755 index 0000000..01ea3c2 --- /dev/null +++ b/app-framework/pom.xml @@ -0,0 +1,91 @@ + + 4.0.0 + + + io.greenbus + greenbus-scala-base + 3.0.0 + ../scala-base + + + greenbus-app-framework + jar + + + + AGPLv3 + http://www.gnu.org/licenses/agpl-3.0.txt + + + + + + + + com.mycila.maven-license-plugin + maven-license-plugin + +
../APACHE_FILE_HEADER
+
+
+ + maven-assembly-plugin + + + jar-with-dependencies + + + + + make-assembly + package + + single + + + + +
+
+ + + + io.greenbus + greenbus-client + 3.0.0 + + + io.greenbus + greenbus-util + 3.0.0 + + + io.greenbus.msg + greenbus-msg-qpid + 1.0.0 + + + com.typesafe.play + play-json_2.10 + 2.3.9 + + + com.typesafe.akka + akka-actor_2.10 + 2.2.0 + + + com.typesafe.akka + akka-slf4j_2.10 + 2.2.0 + + + commons-io + commons-io + 2.4 + + + + +
diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/AmqpConnectionConfig.scala b/app-framework/src/main/scala/io/greenbus/app/actor/AmqpConnectionConfig.scala new file mode 100644 index 0000000..75da1b4 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/AmqpConnectionConfig.scala @@ -0,0 +1,39 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor + +case class AmqpConnectionConfig(amqpConfigFileList: IndexedSeq[String], + failureLimit: Int, + retryDelayMs: Long, + connectionTimeoutMs: Long) + +object AmqpConnectionConfig { + + def default(path: String): AmqpConnectionConfig = { + AmqpConnectionConfig(Vector(path), failureLimit = 1, retryDelayMs = 5000, connectionTimeoutMs = 10000) + } + + def default(paths: Seq[String]): AmqpConnectionConfig = { + AmqpConnectionConfig(paths.toVector, failureLimit = 1, retryDelayMs = 5000, connectionTimeoutMs = 10000) + } + + def load(paths: Seq[String], failureLimit: Int, retryDelayMs: Long, connectionTimeoutMs: Long): AmqpConnectionConfig = { + AmqpConnectionConfig(paths.toVector, failureLimit, retryDelayMs, connectionTimeoutMs) + } +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/AmqpConnectionManager.scala b/app-framework/src/main/scala/io/greenbus/app/actor/AmqpConnectionManager.scala new file mode 100644 index 0000000..b5cdcdd --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/AmqpConnectionManager.scala @@ -0,0 +1,179 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor + +import akka.actor._ +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.app.actor.util.NestedStateMachine +import io.greenbus.client.ServiceConnection +import io.greenbus.client.exception.UnauthorizedException +import io.greenbus.msg.SessionUnusableException +import io.greenbus.msg.amqp.AmqpSettings +import io.greenbus.msg.qpid.QpidBroker +import scala.concurrent.duration._ + +object FailoverConnectionManager { + + sealed trait State + case class Down(configOffset: Int, failures: Int, sequence: Int) extends State + case class Running(configOffset: Int, connection: ServiceConnection, child: ActorRef, sequence: Int) extends State + + case object AttemptConnect + case class LostBrokerConnection(expected: Boolean, sequence: Int) + + def props(processName: String, + amqpConfigFileList: IndexedSeq[String], + failureLimit: Int, + retryDelayMs: Long, + connectionTimeoutMs: Long, + factory: ServiceConnection => Props): Props = { + Props(classOf[FailoverConnectionManager], processName, amqpConfigFileList, failureLimit, retryDelayMs, connectionTimeoutMs, factory) + } +} + +class FailoverConnectionManager( + processName: String, + amqpConfigFileList: IndexedSeq[String], + failureLimit: Int, + retryDelayMs: Long, + connectionTimeoutMs: Long, + factory: ServiceConnection => Props) extends NestedStateMachine with MessageScheduling with Logging { + import FailoverConnectionManager._ + + protected type StateType = State + protected def start: StateType = Down(0, 0, 0) + + self ! AttemptConnect + + protected def machine = { + + case state @ Down(offset, failures, sequence) => { + + case AttemptConnect => { + + val configToAttempt = amqpConfigFileList(offset) + logger.info(s"Process $processName attempting connection using config: $configToAttempt") + logger.debug(s"Process $processName attempting connection, offset: $offset, failures: $failures, list: $amqpConfigFileList") + + try { + val connection = connectQpid(configToAttempt) + + connection.addConnectionListener { expected => + self ! LostBrokerConnection(expected, sequence) + } + + val child = context.actorOf(factory(connection)) + + Running(offset, connection, child, sequence) + + } catch { + case ex: Throwable => + + logger.error(s"Couldn't initialize connection for $processName: " + ex.getMessage) + + scheduleMsg(retryDelayMs, AttemptConnect) + if (failures >= failureLimit) { + val nextOffset = if (offset >= (amqpConfigFileList.size - 1)) 0 else offset + 1 + Down(nextOffset, 0, sequence) + } else { + Down(offset, failures + 1, sequence) + } + + } + } + case LostBrokerConnection(expected, connSeq) => { + if (!expected) { + logger.warn(s"Saw unexpected lost connection event while $processName was already down") + } + state + } + } + + case state @ Running(configOffset, connection, child, sequence) => { + + case LostBrokerConnection(expected, connSeq) => { + if (connSeq == sequence) { + + try { + connection.disconnect() + } catch { + case ex: Throwable => + } + child ! PoisonPill + + logger.info(s"Process $processName lost connection") + + scheduleMsg(retryDelayMs, AttemptConnect) + + Down(configOffset, 0, sequence + 1) + + } else { + state + } + } + + case RestartConnection(reason) => { + logger.warn(s"Restarting $processName due to $reason") + + try { + connection.disconnect() + } catch { + case ex: Throwable => + } + child ! PoisonPill + + scheduleMsg(retryDelayMs, AttemptConnect) + + Down(configOffset, 0, sequence + 1) + } + } + } + + private def connectQpid(path: String): ServiceConnection = { + val config = AmqpSettings.load(path) + val connection = ServiceConnection.connect(config, QpidBroker, connectionTimeoutMs) + logger.info(s"Connected to broker: $config") + connection + } + + override protected def onShutdown(state: State): Unit = { + state match { + case Running(configOffset, connection, child, sequence) => + logger.info(s"Disconnecting on shutdown for $processName") + connection.disconnect() + case _ => + } + } + + override def supervisorStrategy: SupervisorStrategy = { + import SupervisorStrategy._ + OneForOneStrategy(maxNrOfRetries = 1, withinTimeRange = 3.seconds) { + case _: SessionUnusableException => + self ! RestartConnection("session error") + Stop + case _: UnauthorizedException => + self ! RestartConnection("auth failure") + Stop + case ex: Throwable => + self ! RestartConnection("unknown error: " + ex) + Stop + } + } + +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/ConnectedApplicationManager.scala b/app-framework/src/main/scala/io/greenbus/app/actor/ConnectedApplicationManager.scala new file mode 100644 index 0000000..bc95c35 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/ConnectedApplicationManager.scala @@ -0,0 +1,210 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor + +import io.greenbus.client.ServiceConnection +import io.greenbus.msg.{ SessionUnusableException, Session } +import akka.actor._ +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.amqp.{ AmqpServiceOperations, AmqpSettings } +import io.greenbus.msg.qpid.QpidBroker +import io.greenbus.client.exception.{ ServiceException, UnauthorizedException } +import scala.concurrent.duration._ +import akka.actor.OneForOneStrategy +import io.greenbus.util.UserSettings + +case class RestartConnection(reason: String) + +object ConnectedApplicationManager { + + sealed trait State + case object Down extends State + case object Connected extends State + case object Running extends State + + sealed trait Data + case object NoData extends Data + case class ConnEstablished(conn: ServiceConnection) extends Data + case class SessionEstablished(child: ActorRef, session: Session, conn: ServiceConnection) extends Data + + case object AttemptConnect + case object AttemptLogin + case class LoginSuccess(session: Session) + case class LoginFailure(ex: Throwable) + case class LostBrokerConnection(expected: Boolean) + //case class ConnectionRestart(reason: String) + + def props(processName: String, amqpConfigPath: String, userConfigPath: String, factory: (Session, AmqpServiceOperations) => Props): Props = + Props(classOf[ConnectedApplicationManager], processName, amqpConfigPath, userConfigPath, factory) +} + +import ConnectedApplicationManager._ + +class ConnectedApplicationManager(processName: String, amqpConfigPath: String, userConfigPath: String, factory: (Session, AmqpServiceOperations) => Props) extends Actor with FSM[State, Data] with Logging { + + import context.dispatcher + + private def connectQpid(): ServiceConnection = { + val config = AmqpSettings.load(amqpConfigPath) + val connection = ServiceConnection.connect(config, QpidBroker, 5000) + logger.info(s"Connected to broker: $config") + connection + } + + private def userSettings(): UserSettings = { + UserSettings.load(userConfigPath) + } + + override def supervisorStrategy: SupervisorStrategy = { + import SupervisorStrategy._ + OneForOneStrategy(maxNrOfRetries = 1, withinTimeRange = 3.seconds) { + case _: SessionUnusableException => + self ! RestartConnection("session error") + Stop + case _: UnauthorizedException => + self ! RestartConnection("auth failure") + Stop + case ex: Throwable => + self ! RestartConnection("unknown error: " + ex) + Stop + } + } + + private def attemptLogin(conn: ServiceConnection) { + val userConfig = userSettings() + val future = conn.login(userConfig.user, userConfig.password) + future.onSuccess { + case session: Session => self ! LoginSuccess(session) + } + future.onFailure { + case ex: Throwable => self ! LoginFailure(ex) + } + } + + startWith(Down, NoData) + + when(Down) { + + case Event(AttemptConnect, NoData) => { + try { + val conn = connectQpid() + + conn.addConnectionListener { expected => + self ! LostBrokerConnection(expected) + } + + attemptLogin(conn) + + goto(Connected) using ConnEstablished(conn) + + } catch { + case ex: Throwable => { + logger.error(s"Couldn't initialize $processName: " + ex.getMessage) + scheduleMsg(3000, AttemptConnect) + stay using NoData + } + } + } + + case Event(LostBrokerConnection(expected), NoData) => { + if (!expected) { + logger.warn(s"Saw unexpected lost connection event while $processName was already down") + } + stay using NoData + } + } + + when(Connected) { + + case Event(AttemptLogin, data @ ConnEstablished(conn)) => { + attemptLogin(conn) + stay using data + } + + case Event(LoginSuccess(session), ConnEstablished(conn)) => { + logger.info(s"$processName logged into services") + + val child = context.actorOf(factory(session, conn.serviceOperations)) + + goto(Running) using SessionEstablished(child, session, conn) + } + + case Event(LoginFailure(ex), data @ ConnEstablished(conn)) => { + ex match { + case ex: ServiceException => + logger.warn(s"$processName login failed: " + ex.getMessage) + scheduleMsg(3000, AttemptLogin) + stay using data + case ex: Throwable => + logger.warn(s"$processName login error: " + ex.getMessage) + conn.disconnect() + scheduleMsg(3000, AttemptConnect) + goto(Down) using NoData + } + } + + case Event(LostBrokerConnection(expected), ConnEstablished(conn)) => { + conn.disconnect() + onLostConnection + } + } + + when(Running) { + + case Event(RestartConnection(reason), SessionEstablished(child, _, conn)) => + logger.warn(s"Restarting $processName due to $reason") + child ! PoisonPill + onUnrecoverable(conn) + + case Event(LostBrokerConnection(expected), SessionEstablished(child, _, _)) => + child ! PoisonPill + onLostConnection + } + + def onUnrecoverable(conn: ServiceConnection) = { + conn.disconnect() + scheduleMsg(3000, AttemptConnect) + goto(Down) using NoData + } + + private def onLostConnection = { + logger.warn(s"$processName lost connection") + scheduleMsg(3000, AttemptConnect) + goto(Down) using NoData + } + + override def preStart() { + self ! AttemptConnect + } + + onTermination { + case StopEvent(_, _, ConnEstablished(conn)) => conn.disconnect() + case StopEvent(_, _, SessionEstablished(_, _, conn)) => conn.disconnect() + } + + private def scheduleMsg(timeMs: Long, msg: AnyRef) { + import context.dispatcher + context.system.scheduler.scheduleOnce( + Duration(timeMs, MILLISECONDS), + self, + msg) + } + + initialize() +} \ No newline at end of file diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/EndpointCollectionManager.scala b/app-framework/src/main/scala/io/greenbus/app/actor/EndpointCollectionManager.scala new file mode 100644 index 0000000..4f3cebc --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/EndpointCollectionManager.scala @@ -0,0 +1,164 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor + +import akka.actor._ +import akka.actor.Actor +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.{ SessionUnusableException, SubscriptionBinding, Subscription, Session } +import io.greenbus.msg.amqp.AmqpServiceOperations +import io.greenbus.client.service.proto.Model.{ EndpointNotification, Endpoint } +import io.greenbus.client.exception.UnauthorizedException +import io.greenbus.client.service.proto.Model.ModelUUID +import io.greenbus.client.proto.Envelope.SubscriptionEventType +import java.io.IOException + +class OutOfEndpointCollectionException(end: Endpoint, msg: String) extends Exception(msg) { + def endpoint: Endpoint = end +} + +object EndpointCollectionManager { + + case class EndpointsResolved(endpoints: Seq[Endpoint]) // Unlike added it just means it was there when we came up + case class EndpointAdded(endpoint: Endpoint) + case class EndpointModified(endpoint: Endpoint) + case class EndpointRemoved(endpoint: Endpoint) + case class EndpointOutOfCollection(endpoint: Endpoint) // Unlike removed, just means we're not responsible for it anymore + + private case object LookupEndpoints + private case class EndpointsResult(poll: Seq[Endpoint], subscription: Subscription[EndpointNotification]) + private case class RequestFailure(ex: Throwable) + + def props(strategy: EndpointCollectionStrategy, session: Session, serviceOps: AmqpServiceOperations, endpointObserver: Option[ActorRef], factory: (Endpoint, CollectionMembership, Session, AmqpServiceOperations) => Props): Props = + Props(classOf[EndpointCollectionManager], strategy, session, serviceOps, endpointObserver, factory) + +} + +class EndpointCollectionManager(strategy: EndpointCollectionStrategy, session: Session, serviceOps: AmqpServiceOperations, endpointObserver: Option[ActorRef], factory: (Endpoint, CollectionMembership, Session, AmqpServiceOperations) => Props) extends Actor with Logging { + import EndpointCollectionManager._ + + private var binding = Option.empty[SubscriptionBinding] + private var streams = Map.empty[ModelUUID, ActorRef] + + self ! LookupEndpoints + + def receive = { + + case LookupEndpoints => { + + import context.dispatcher + val configFut = strategy.configuration(session) + + configFut.onSuccess { case (poll, subscription) => self ! EndpointsResult(poll, subscription) } + configFut.onFailure { case ex => self ! RequestFailure(ex) } + } + + case EndpointsResult(results, subscription) => { + + binding = Some(subscription) + val whitelistOpt = strategy.nameWhitelist + + whitelistOpt match { + case None => subscription.start { event => self ! event } + case Some(whitelist) => + subscription.start { event => + if (whitelist.contains(event.getValue.getName)) { + self ! event + } + } + } + + endpointObserver.foreach(ref => ref ! EndpointsResolved(results)) + + val initial = results.filter(_.getDisabled == false).map { endpoint => + (endpoint.getUuid, launchStream(endpoint)) + } + + streams = initial.toMap + + logger.info("Endpoint collection management initialized") + } + + case RequestFailure(ex) => throw ex + + case event: EndpointNotification => { + val endpoint = event.getValue + val uuid = endpoint.getUuid + val name = endpoint.getName + + endpointObserver.foreach { obsRef => + val msg = event.getEventType match { + case SubscriptionEventType.ADDED => EndpointAdded(endpoint) + case SubscriptionEventType.MODIFIED => EndpointModified(endpoint) + case SubscriptionEventType.REMOVED => EndpointRemoved(endpoint) + } + + obsRef ! msg + } + + (event.getEventType, endpoint.getDisabled) match { + case (SubscriptionEventType.ADDED, false) => + if (!streams.contains(uuid)) { + addStream(endpoint) + } else { + logger.warn(s"Saw add event on existing endpoint stream: $name (${uuid.getValue})") + } + case (SubscriptionEventType.ADDED, true) => + case (SubscriptionEventType.MODIFIED, false) => + if (!streams.contains(uuid)) { + addStream(endpoint) + } + case (SubscriptionEventType.MODIFIED, true) => removeStream(uuid) + case (SubscriptionEventType.REMOVED, _) => removeStream(uuid) + } + } + + } + + private def addStream(endpoint: Endpoint) { + streams = streams + ((endpoint.getUuid, launchStream(endpoint))) + } + private def removeStream(uuid: ModelUUID) { + logger.debug("Removing endpoint " + uuid.getValue) + streams.get(uuid).foreach(ref => ref ! PoisonPill) + streams -= uuid + } + + private def launchStream(endpoint: Endpoint): ActorRef = { + logger.debug("Launching endpoint " + endpoint.getName) + context.actorOf(factory(endpoint, strategy.membership, session.spawn(), serviceOps)) + } + + override def supervisorStrategy: SupervisorStrategy = { + import SupervisorStrategy._ + OneForOneStrategy() { + case out: OutOfEndpointCollectionException => { + logger.info("Endpoint marked itself as out of collection: " + out.endpoint.getName) + endpointObserver.foreach(_ ! EndpointOutOfCollection(out.endpoint)) + removeStream(out.endpoint.getUuid) + Resume + } + case _: UnauthorizedException => Escalate + case _: SessionUnusableException => Escalate + case _: IOException => Escalate + case _: Throwable => Escalate + } + } +} + diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/EndpointCollectionStrategy.scala b/app-framework/src/main/scala/io/greenbus/app/actor/EndpointCollectionStrategy.scala new file mode 100644 index 0000000..08a3cc7 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/EndpointCollectionStrategy.scala @@ -0,0 +1,118 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor + +import io.greenbus.client.service.ModelService +import io.greenbus.client.service.proto.Model.{ Endpoint, EndpointNotification } +import io.greenbus.client.service.proto.ModelRequests.{ EntityKeySet, EndpointQuery, EndpointSubscriptionQuery, EntityPagingParams } +import io.greenbus.msg.{ Session, Subscription } + +import scala.collection.JavaConversions._ +import scala.concurrent.{ ExecutionContext, Future } + +sealed trait CollectionMembership +case object AnyAndAllMembership extends CollectionMembership +case object NamedMembership extends CollectionMembership +case class ProtocolMembership(validProtocols: Set[String]) extends CollectionMembership + +trait EndpointCollectionStrategy { + def membership: CollectionMembership + + def nameWhitelist: Option[Set[String]] + + def configuration(session: Session)(implicit context: ExecutionContext): Future[(Seq[Endpoint], Subscription[EndpointNotification])] +} + +class AllEndpointsStrategy(whitelistOpt: Option[Set[String]] = None) extends EndpointCollectionStrategy { + + def membership = AnyAndAllMembership + + def nameWhitelist: Option[Set[String]] = whitelistOpt + + def configuration(session: Session)(implicit context: ExecutionContext): Future[(Seq[Endpoint], Subscription[EndpointNotification])] = { + val modelClient = ModelService.client(session) + + val subFut = modelClient.subscribeToEndpoints(EndpointSubscriptionQuery.newBuilder().build()) + + subFut.flatMap { + case (emptyCurrent, sub) => + val currFut = whitelistOpt match { + case None => + val query = EndpointQuery.newBuilder() + .setPagingParams(EntityPagingParams.newBuilder() + .setPageSize(Int.MaxValue)) // TODO: page it if we can't trust the strategy choice to be sane? + .build() + + modelClient.endpointQuery(query) + + case Some(whitelist) => + modelClient.getEndpoints(EntityKeySet.newBuilder().addAllNames(whitelist).build()) + } + + currFut.onFailure { case ex => sub.cancel() } + + currFut.map { ends => + (ends, sub) + } + } + } +} + +class ProtocolsEndpointStrategy(validProtocols: Set[String], whitelistOpt: Option[Set[String]] = None) extends EndpointCollectionStrategy { + + def membership = ProtocolMembership(validProtocols) + + def nameWhitelist: Option[Set[String]] = None + + def configuration(session: Session)(implicit context: ExecutionContext): Future[(Seq[Endpoint], Subscription[EndpointNotification])] = { + val modelClient = ModelService.client(session) + + val subQuery = EndpointSubscriptionQuery.newBuilder() + .addAllProtocols(validProtocols) + .build() + + val subFut = modelClient.subscribeToEndpoints(subQuery) + + subFut.flatMap { + case (emptyCurrent, sub) => + + val currFut = whitelistOpt match { + case None => + val query = EndpointQuery.newBuilder() + .addAllProtocols(validProtocols) + .setPagingParams(EntityPagingParams.newBuilder() + .setPageSize(Int.MaxValue)) // TODO: page it if we can't trust the strategy choice to be sane? + .build() + + modelClient.endpointQuery(query) + + case Some(whitelist) => + modelClient.getEndpoints(EntityKeySet.newBuilder().addAllNames(whitelist).build()).map { results => + results.filter(r => validProtocols.contains(r.getProtocol)) + } + } + + currFut.onFailure { case ex => sub.cancel() } + + currFut.map { ends => + (ends, sub) + } + } + } +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/EndpointMonitor.scala b/app-framework/src/main/scala/io/greenbus/app/actor/EndpointMonitor.scala new file mode 100644 index 0000000..d6209ed --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/EndpointMonitor.scala @@ -0,0 +1,127 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor + +import akka.actor._ +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.amqp.AmqpServiceOperations +import io.greenbus.msg.{ Session, SessionUnusableException, Subscription, SubscriptionBinding } +import io.greenbus.client.exception.UnauthorizedException +import io.greenbus.client.service.ModelService +import io.greenbus.client.service.proto.Model._ +import io.greenbus.client.service.proto.ModelRequests.EndpointSubscriptionQuery + +import scala.concurrent.{ ExecutionContext, Future } + +object EndpointMonitor { + + class EndpointConfigException(msg: String) extends Exception(msg) + + def queryEndpoint(session: Session, endpoint: Endpoint)(implicit context: ExecutionContext): Future[(Seq[Endpoint], Subscription[EndpointNotification])] = { + val modelClient = ModelService.client(session) + + val endSubQuery = EndpointSubscriptionQuery.newBuilder().addUuids(endpoint.getUuid).build() + + modelClient.subscribeToEndpoints(endSubQuery) + } + + case object MakeRequests + case class EndpointConfig(endpoints: Seq[Endpoint], endpointSub: Subscription[EndpointNotification]) + case class RequestFailure(ex: Throwable) + + def props(endpoint: Endpoint, membership: CollectionMembership, session: Session, serviceOps: AmqpServiceOperations, factory: (Endpoint, Session, AmqpServiceOperations) => Props): Props = + Props(classOf[EndpointMonitor], endpoint, membership, session, serviceOps, factory) + +} + +class EndpointMonitor(endpoint: Endpoint, membership: CollectionMembership, session: Session, serviceOps: AmqpServiceOperations, factory: (Endpoint, Session, AmqpServiceOperations) => Props) extends Actor with Logging { + import io.greenbus.app.actor.EndpointMonitor._ + + private var endpointBinding = Option.empty[SubscriptionBinding] + + private var child = Option.empty[ActorRef] + + self ! MakeRequests + + def receive = { + + case MakeRequests => { + import context.dispatcher + + val dataFut = queryEndpoint(session, endpoint) + dataFut.onSuccess { case config => self ! EndpointConfig(config._1, config._2) } + dataFut.onFailure { case ex => self ! RequestFailure(ex) } + } + + case config: EndpointConfig => { + import config._ + + endpointBinding = Some(endpointSub) + + endpointSub.start { self ! _ } + + logger.info(s"Endpoint management initialized for ${endpoint.getName}") + + child = Some(context.actorOf(factory(endpoint, session, serviceOps))) + } + + case endEvent: EndpointNotification => { + import io.greenbus.client.proto.Envelope.SubscriptionEventType._ + + // We need to help the collection manager know if our endpoint has fallen outside our set + // If the collection manager is subscribing by name/protocol, a change to those parameters may not be + // reflected in its subscription + endEvent.getEventType match { + case ADDED => logger.warn(s"Saw added in endpoint manager for ${endpoint.getName}; endpoint already existed") + case REMOVED => // not our responsibility (collection manager will handle) + case MODIFIED => + val changed = endEvent.getValue + membership match { + case AnyAndAllMembership => + case NamedMembership => + if (endpoint.getName != changed.getName) { + throw new OutOfEndpointCollectionException(endpoint, "name changed") + } + case ProtocolMembership(valid) => + if (!valid.contains(changed.getProtocol)) { + throw new OutOfEndpointCollectionException(endpoint, "protocol changed") + } + } + } + } + + case RequestFailure(ex) => + logger.warn(s"Error making requests for endpoint manager for ${endpoint.getName}: ${ex.getMessage}") + throw ex + } + + override def postStop() { + endpointBinding.foreach(_.cancel()) + } + + override def supervisorStrategy: SupervisorStrategy = { + import akka.actor.SupervisorStrategy._ + OneForOneStrategy() { + case _: UnauthorizedException => Escalate + case _: SessionUnusableException => Escalate + case _: Throwable => Escalate + } + } + +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/MessageScheduling.scala b/app-framework/src/main/scala/io/greenbus/app/actor/MessageScheduling.scala new file mode 100644 index 0000000..eb466ab --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/MessageScheduling.scala @@ -0,0 +1,33 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor + +import akka.actor.Actor +import scala.concurrent.duration._ + +trait MessageScheduling extends Actor { + + protected def scheduleMsg(timeMs: Long, msg: AnyRef) { + import context.dispatcher + context.system.scheduler.scheduleOnce( + Duration(timeMs, MILLISECONDS), + self, + msg) + } +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/SessionLoginManager.scala b/app-framework/src/main/scala/io/greenbus/app/actor/SessionLoginManager.scala new file mode 100644 index 0000000..5dfb908 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/SessionLoginManager.scala @@ -0,0 +1,105 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor + +import akka.actor._ +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.client.ServiceConnection +import io.greenbus.client.exception.ServiceException +import io.greenbus.msg.Session +import io.greenbus.msg.amqp.AmqpServiceOperations +import io.greenbus.util.UserSettings + +import scala.concurrent.duration._ + +object SessionLoginManager { + + sealed trait State + case object Unestablished extends State + case class Running(child: ActorRef) + + case object AttemptLogin + case class LoginSuccess(session: Session) + case class LoginFailure(ex: Throwable) + + def props(processName: String, + userConfigPath: String, + connection: ServiceConnection, + loginRetryMs: Long, + factory: (Session, AmqpServiceOperations) => Props): Props = { + Props(classOf[SessionLoginManager], processName, userConfigPath, connection, loginRetryMs, factory) + } +} + +class SessionLoginManager( + processName: String, + userConfigPath: String, + connection: ServiceConnection, + loginRetryMs: Long, + factory: (Session, AmqpServiceOperations) => Props) + extends Actor with MessageScheduling with Logging { + import SessionLoginManager._ + import context.dispatcher + + private var childOpt = Option.empty[ActorRef] + + self ! AttemptLogin + + def receive = { + case AttemptLogin => { + attemptLogin(connection) + } + case LoginSuccess(session) => { + logger.info(s"$processName logged into services") + + val child = context.actorOf(factory(session, connection.serviceOperations)) + childOpt = Some(child) + } + case LoginFailure(ex) => { + ex match { + case ex: ServiceException => + logger.warn(s"$processName login failed: " + ex.getMessage) + scheduleMsg(loginRetryMs, AttemptLogin) + } + } + } + + private def attemptLogin(conn: ServiceConnection) { + val userConfig = userSettings() + val future = conn.login(userConfig.user, userConfig.password) + future.onSuccess { + case session: Session => self ! LoginSuccess(session) + } + future.onFailure { + case ex: Throwable => self ! LoginFailure(ex) + } + } + + private def userSettings(): UserSettings = { + UserSettings.load(userConfigPath) + } + + override def supervisorStrategy: SupervisorStrategy = { + import SupervisorStrategy._ + OneForOneStrategy(maxNrOfRetries = 1, withinTimeRange = 3.seconds) { + case ex: Throwable => + Escalate + } + } +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/frontend/ActorRefCommandAcceptor.scala b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/ActorRefCommandAcceptor.scala new file mode 100644 index 0000000..8775b9f --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/ActorRefCommandAcceptor.scala @@ -0,0 +1,52 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend + +import akka.actor._ +import akka.pattern.{ AskTimeoutException, ask } +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.app.actor.frontend.ActorProtocolManager.CommandIssued +import io.greenbus.client.service.proto.Commands.{ CommandStatus, CommandResult, CommandRequest } +import io.greenbus.client.service.proto.Model.Endpoint + +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration._ + +class ActorRefCommandAcceptor(ref: ActorRef, endpoint: Endpoint) extends ProtocolCommandAcceptor with Logging { + + def issue(commandName: String, request: CommandRequest): Future[CommandResult] = { + val askFut = ref.ask(CommandIssued(commandName, request))(4000.milliseconds) + + askFut.flatMap { + case cmdFut: Future[_] => cmdFut.asInstanceOf[Future[CommandResult]] + case all => + logger.error("Endpoint " + endpoint.getName + " received unknown response from protocol master actor: " + all) + Future.successful(CommandResult.newBuilder().setStatus(CommandStatus.UNDEFINED).build()) + }.recover { + case at: AskTimeoutException => + logger.warn("Endpoint " + endpoint.getName + " could not issue command to protocol master actor") + CommandResult.newBuilder().setStatus(CommandStatus.TIMEOUT).build() + + case ex => + logger.warn("Endpoint " + endpoint.getName + " had error communicating with protocol master actor: " + ex) + CommandResult.newBuilder().setStatus(CommandStatus.UNDEFINED).build() + } + } +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/frontend/BusDrivenProtocolEndpoint.scala b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/BusDrivenProtocolEndpoint.scala new file mode 100644 index 0000000..f29500e --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/BusDrivenProtocolEndpoint.scala @@ -0,0 +1,193 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend + +import akka.actor.{ PoisonPill, ActorRef, Props } +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.{ Session, SessionUnusableException } +import io.greenbus.app.actor.MessageScheduling +import io.greenbus.app.actor.frontend.BusDrivenProtocolEndpoint.{ ProtocolFactory, ProxyFactory, RegisterFunc } +import io.greenbus.app.actor.frontend.ProcessingProxy.LinkLapsed +import io.greenbus.app.actor.util.NestedStateMachine +import io.greenbus.client.exception.{ LockedException, UnauthorizedException } +import io.greenbus.client.service.FrontEndService +import io.greenbus.client.service.proto.FrontEnd.FrontEndRegistration +import io.greenbus.client.service.proto.FrontEndRequests.FrontEndRegistrationTemplate +import io.greenbus.client.service.proto.Model.Endpoint + +import scala.concurrent.{ ExecutionContext, Future } + +object BusDrivenProtocolEndpoint { + + type RegisterFunc = (Session, Endpoint, Boolean) => Future[FrontEndRegistration] + + type ProxyFactory = (ActorRef, Endpoint) => Props + type ProtocolFactory = (Endpoint, Session, MeasurementsPublished => Unit, StackStatusUpdated => Unit) => Props + + protected sealed trait State + protected case object BusUp extends State + protected case object RegistrationPending extends State + protected case class Running(proxy: ActorRef, protocol: ActorRef, inputAddress: String) extends State + protected case class RunningUnregistered(proxy: ActorRef, protocol: ActorRef) extends State + protected case class RunningRegistrationPending(proxy: ActorRef, protocol: ActorRef) extends State + + private case class RegistrationSuccess(conns: FrontEndRegistration) + private case class RegistrationFailure(ex: Throwable) + + private case object DoRegistrationRetry + + def register(session: Session, endpoint: Endpoint, holdingLock: Boolean, lockingEnabled: Boolean, nodeId: String)(implicit context: ExecutionContext): Future[FrontEndRegistration] = { + + val client = FrontEndService.client(session) + + val registration = FrontEndRegistrationTemplate.newBuilder() + .setEndpointUuid(endpoint.getUuid) + .setHoldingLock(holdingLock && lockingEnabled) + .setFepNodeId(nodeId) + .build() + + client.putFrontEndRegistration(registration, endpoint.getUuid.getValue) + } + + def props( + endpoint: Endpoint, + session: Session, + regFunc: RegisterFunc, + proxyFactory: ProxyFactory, + protocolFactory: ProtocolFactory, + registrationRetryMs: Long): Props = { + Props(classOf[BusDrivenProtocolEndpoint], endpoint, session, regFunc, proxyFactory, protocolFactory, registrationRetryMs) + } +} + +class BusDrivenProtocolEndpoint( + endpoint: Endpoint, + session: Session, + regFunc: RegisterFunc, + proxyFactory: ProxyFactory, + protocolFactory: ProtocolFactory, + registrationRetryMs: Long) + extends NestedStateMachine with MessageScheduling with Logging { + + import io.greenbus.app.actor.frontend.BusDrivenProtocolEndpoint._ + + protected type StateType = State + protected def start: StateType = RegistrationPending + override protected def instanceId: Option[String] = Some(endpoint.getName) + + doRegistration() + + protected def machine = { + case BusUp => { + case DoRegistrationRetry => { + doRegistration() + RegistrationPending + } + } + + case RegistrationPending => { + + case RegistrationSuccess(result) => { + + val proxy = context.actorOf(proxyFactory(self, endpoint)) + proxy ! ProcessingProxy.LinkUp(session, result.getInputAddress) + + val protocol = context.actorOf(protocolFactory(endpoint, session, proxy ! _, proxy ! _)) + + Running(proxy, protocol, result.getInputAddress) + } + + case RegistrationFailure(ex) => { + ex match { + case ex: SessionUnusableException => throw ex + case ex: UnauthorizedException => throw ex + /*case ex: LockedException => + logger.warn(s"Endpoint ${endpoint.getName} registration failed") + scheduleMsg(registrationRetryMs, DoRegistrationRetry) + BusUp*/ + case ex: Throwable => + logger.warn(s"Endpoint ${endpoint.getName} registration failed") + scheduleMsg(registrationRetryMs, DoRegistrationRetry) + BusUp + } + } + } + + case state: Running => { + + case ServiceSessionDead => { + throw new SessionUnusableException("Proxy indicated session was dead") + } + + case LinkLapsed => { + doRegistration() + RunningRegistrationPending(state.proxy, state.protocol) + } + } + + case state: RunningUnregistered => { + + case ServiceSessionDead => { + throw new SessionUnusableException("Proxy indicated session was dead") + } + + case DoRegistrationRetry => { + doRegistration() + RunningRegistrationPending(state.proxy, state.protocol) + } + } + + case state: RunningRegistrationPending => { + + case ServiceSessionDead => { + throw new SessionUnusableException("Proxy indicated session was dead") + } + + case RegistrationSuccess(result) => { + state.proxy ! ProcessingProxy.LinkUp(session, result.getInputAddress) + Running(state.proxy, state.protocol, result.getInputAddress) + } + + case RegistrationFailure(ex) => { + ex match { + case ex: SessionUnusableException => throw ex + case ex: UnauthorizedException => throw ex + case ex: LockedException => + logger.warn(s"Endpoint ${endpoint.getName} registration returned locked, shutting down protocol") + state.protocol ! PoisonPill + state.proxy ! PoisonPill + scheduleMsg(registrationRetryMs, DoRegistrationRetry) + BusUp + case ex: Throwable => + logger.warn(s"Endpoint ${endpoint.getName} registration failed") + scheduleMsg(registrationRetryMs, DoRegistrationRetry) + RunningUnregistered(state.proxy, state.protocol) + } + } + } + } + + private def doRegistration(): Unit = { + import context.dispatcher + val fut = regFunc(session, endpoint, true) + + fut.onSuccess { case result => self ! RegistrationSuccess(result) } + fut.onFailure { case ex => self ! RegistrationFailure(ex) } + } +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/frontend/BusDrivenWithCommandsProtocolEndpoint.scala b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/BusDrivenWithCommandsProtocolEndpoint.scala new file mode 100644 index 0000000..a9326d0 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/BusDrivenWithCommandsProtocolEndpoint.scala @@ -0,0 +1,216 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend + +import akka.actor.{ ActorRef, PoisonPill, Props } +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.amqp.AmqpServiceOperations +import io.greenbus.msg.service.ServiceHandlerSubscription +import io.greenbus.msg.{ Session, SessionUnusableException } +import io.greenbus.app.actor.MessageScheduling +import io.greenbus.app.actor.frontend.CommandProxy.CommandSubscriptionRetrieved +import io.greenbus.app.actor.frontend.ProcessingProxy.LinkLapsed +import io.greenbus.app.actor.frontend.SimpleCommandProxy.ProtocolInitialized +import io.greenbus.app.actor.util.NestedStateMachine +import io.greenbus.client.exception.{ LockedException, UnauthorizedException } +import io.greenbus.client.service.FrontEndService +import io.greenbus.client.service.proto.FrontEnd.FrontEndRegistration +import io.greenbus.client.service.proto.FrontEndRequests.FrontEndRegistrationTemplate +import io.greenbus.client.service.proto.Model.Endpoint + +import scala.concurrent.{ ExecutionContext, Future } + +object BusDrivenWithCommandsProtocolEndpoint { + + type RegisterFunc = (Session, AmqpServiceOperations, Endpoint, Boolean) => (Future[FrontEndRegistration], ServiceHandlerSubscription) + + type ProxyFactory = (ActorRef, Endpoint) => Props + type ProtocolFactory = (Endpoint, Session, MeasurementsPublished => Unit, StackStatusUpdated => Unit) => Props + + protected sealed trait State + protected case object BusUp extends State + protected case class RegistrationPending(cmdSub: ServiceHandlerSubscription) extends State + protected case class Running(proxy: ActorRef, commandActor: ActorRef, protocol: ActorRef, inputAddress: String) extends State + protected case class RunningUnregistered(proxy: ActorRef, commandActor: ActorRef, protocol: ActorRef) extends State + protected case class RunningRegistrationPending(proxy: ActorRef, commandActor: ActorRef, cmdSub: ServiceHandlerSubscription, protocol: ActorRef) extends State + + private case class RegistrationSuccess(conns: FrontEndRegistration) + private case class RegistrationFailure(ex: Throwable) + + private case object DoRegistrationRetry + + def register(session: Session, serviceOps: AmqpServiceOperations, endpoint: Endpoint, holdingLock: Boolean, lockingEnabled: Boolean, nodeId: String)(implicit context: ExecutionContext): (Future[FrontEndRegistration], ServiceHandlerSubscription) = { + val serviceHandlerSub = serviceOps.routedServiceBinding() + val cmdAddress = serviceHandlerSub.getId() + + try { + val client = FrontEndService.client(session) + + val registration = FrontEndRegistrationTemplate.newBuilder() + .setEndpointUuid(endpoint.getUuid) + .setCommandAddress(cmdAddress) + .setHoldingLock(holdingLock && lockingEnabled) + .setFepNodeId(nodeId) + .build() + + val regFut = client.putFrontEndRegistration(registration, endpoint.getUuid.getValue) + + (regFut, serviceHandlerSub) + + } catch { + case ex: Throwable => + serviceHandlerSub.cancel() + throw ex + } + } + + def props( + endpoint: Endpoint, + session: Session, + serviceOps: AmqpServiceOperations, + regFunc: RegisterFunc, + proxyFactory: ProxyFactory, + commandActorFactory: Endpoint => Props, + protocolFactory: ProtocolFactory, + registrationRetryMs: Long): Props = { + Props(classOf[BusDrivenWithCommandsProtocolEndpoint], endpoint, session, serviceOps, regFunc, proxyFactory, commandActorFactory, protocolFactory, registrationRetryMs) + } +} + +import io.greenbus.app.actor.frontend.BusDrivenWithCommandsProtocolEndpoint._ +class BusDrivenWithCommandsProtocolEndpoint( + endpoint: Endpoint, + session: Session, + serviceOps: AmqpServiceOperations, + regFunc: RegisterFunc, + proxyFactory: ProxyFactory, + commandActorFactory: Endpoint => Props, + protocolFactory: ProtocolFactory, + registrationRetryMs: Long) + extends NestedStateMachine with MessageScheduling with Logging { + + protected type StateType = State + protected def start: StateType = RegistrationPending(doRegistration()) + override protected def instanceId: Option[String] = Some(endpoint.getName) + + protected def machine = { + case BusUp => { + case DoRegistrationRetry => { + RegistrationPending(doRegistration()) + } + } + + case RegistrationPending(cmdSub) => { + + case RegistrationSuccess(result) => { + + val proxy = context.actorOf(proxyFactory(self, endpoint)) + proxy ! ProcessingProxy.LinkUp(session, result.getInputAddress) + + val protocol = context.actorOf(protocolFactory(endpoint, session, proxy ! _, proxy ! _)) + + val commandProxy = context.actorOf(commandActorFactory(endpoint)) + + commandProxy ! ProtocolInitialized(protocol) + commandProxy ! CommandSubscriptionRetrieved(cmdSub) + + Running(proxy, commandProxy, protocol, result.getInputAddress) + } + + case RegistrationFailure(ex) => { + ex match { + case ex: SessionUnusableException => throw ex + case ex: UnauthorizedException => throw ex + case ex: Throwable => + logger.warn(s"Endpoint ${endpoint.getName} registration failed") + logger.debug(s"Endpoint ${endpoint.getName} registration failed: " + ex.getMessage) + scheduleMsg(registrationRetryMs, DoRegistrationRetry) + BusUp + } + } + } + + case state: Running => { + + case ServiceSessionDead => { + throw new SessionUnusableException("Proxy indicated session was dead") + } + + case LinkLapsed => { + val cmdSub = doRegistration() + RunningRegistrationPending(state.proxy, state.commandActor, cmdSub, state.protocol) + } + } + + case state: RunningUnregistered => { + + case ServiceSessionDead => { + throw new SessionUnusableException("Proxy indicated session was dead") + } + + case DoRegistrationRetry => { + val cmdSub = doRegistration() + RunningRegistrationPending(state.proxy, state.commandActor, cmdSub, state.protocol) + } + } + + case state: RunningRegistrationPending => { + + case ServiceSessionDead => { + state.cmdSub.cancel() + throw new SessionUnusableException("Proxy indicated session was dead") + } + + case RegistrationSuccess(result) => { + state.proxy ! ProcessingProxy.LinkUp(session, result.getInputAddress) + state.commandActor ! CommandSubscriptionRetrieved(state.cmdSub) + Running(state.proxy, state.commandActor, state.protocol, result.getInputAddress) + } + + case RegistrationFailure(ex) => { + state.cmdSub.cancel() + ex match { + case ex: SessionUnusableException => throw ex + case ex: UnauthorizedException => throw ex + case ex: LockedException => + logger.warn(s"Endpoint ${endpoint.getName} registration returned locked, shutting down protocol") + state.protocol ! PoisonPill + state.proxy ! PoisonPill + state.commandActor ! PoisonPill + scheduleMsg(registrationRetryMs, DoRegistrationRetry) + BusUp + case ex: Throwable => + logger.warn(s"Endpoint ${endpoint.getName} registration failed") + scheduleMsg(registrationRetryMs, DoRegistrationRetry) + RunningUnregistered(state.proxy, state.commandActor, state.protocol) + } + } + } + } + + private def doRegistration(): ServiceHandlerSubscription = { + import context.dispatcher + val (fut, cmdSub) = regFunc(session, serviceOps, endpoint, true) + + fut.onSuccess { case result => self ! RegistrationSuccess(result) } + fut.onFailure { case ex => self ! RegistrationFailure(ex) } + + cmdSub + } +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/frontend/CommandProxy.scala b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/CommandProxy.scala new file mode 100644 index 0000000..eb36844 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/CommandProxy.scala @@ -0,0 +1,95 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend + +import akka.actor.{ Props, Actor } +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.SubscriptionBinding +import io.greenbus.msg.service.ServiceHandlerSubscription +import io.greenbus.client.service.proto.Commands.{ CommandStatus, CommandResult, CommandRequest } +import io.greenbus.client.service.proto.Model.{ Endpoint, ModelUUID, Command } + +import scala.concurrent.Future + +object CommandProxy { + + case class CommandsUpdated(commands: Seq[Command]) + + case class CommandSubscriptionRetrieved(commandSub: ServiceHandlerSubscription) + + case class ProtocolInitialized(acceptor: ProtocolCommandAcceptor) + case object ProtocolUninitialized + + private case class CommandIssued(req: CommandRequest, response: CommandResult => Unit) + + def props(endpoint: Endpoint): Props = Props(classOf[CommandProxy], endpoint) +} + +class CommandProxy(endpoint: Endpoint) extends Actor with Logging { + import CommandProxy._ + import context.dispatcher + + private var commandSub = Option.empty[SubscriptionBinding] + private var commandAcceptor = Option.empty[ProtocolCommandAcceptor] + private var commandMap = Map.empty[ModelUUID, String] + + def receive = { + case CommandIssued(req, responseHandler) => { + commandAcceptor match { + case None => + logger.debug(s"Endpoint ${endpoint.getName} saw command request while protocol uninitialized: " + req.getCommandUuid.getValue) + responseHandler(CommandResult.newBuilder().setStatus(CommandStatus.TIMEOUT).build()) + case Some(acceptor) => + commandMap.get(req.getCommandUuid) match { + case None => logger.warn(s"Saw command request with missing command for ${endpoint.getName}: " + req.getCommandUuid.getValue) + case Some(name) => + logger.debug(s"Endpoint ${endpoint.getName} handling command request: " + name) + val fut = try { + acceptor.issue(name, req) + } catch { + case ex: Throwable => + logger.warn(s"Immediate command failure for endpoint ${endpoint.getName}: " + ex) + Future.failed(ex) + } + fut.onSuccess { case result => responseHandler(result) } + fut.onFailure { + case ex: Throwable => + logger.error(s"Command acceptor for endpoint ${endpoint.getName} failed: " + ex) + responseHandler(CommandResult.newBuilder().setStatus(CommandStatus.UNDEFINED).build()) + } + } + } + } + + case CommandsUpdated(updated) => { + commandMap = updated.map(c => (c.getUuid, c.getName)).toMap + } + + case CommandSubscriptionRetrieved(sub) => { + commandSub.foreach(_.cancel()) + commandSub = Some(sub) + val cmdDelegate = new DelegatingCommandHandler((req, resultAccept) => self ! CommandIssued(req, resultAccept)) + sub.start(cmdDelegate) + } + + case ProtocolInitialized(acceptor) => commandAcceptor = Some(acceptor) + + case ProtocolUninitialized => commandAcceptor = None + } +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/frontend/DelegatingCommandHandler.scala b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/DelegatingCommandHandler.scala new file mode 100644 index 0000000..6314129 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/DelegatingCommandHandler.scala @@ -0,0 +1,83 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend + +import io.greenbus.client.service.proto.Commands.{ CommandResult, CommandRequest } +import io.greenbus.msg.service.ServiceHandler +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.client.proto.Envelope.{ ServiceResponse, ServiceRequest } +import io.greenbus.client.exception.{ ServiceException, BadRequestException } +import io.greenbus.client.service.proto.CommandRequests.{ PostCommandRequestResponse, PostCommandRequestRequest } +import io.greenbus.client.proto.Envelope +import com.google.protobuf.InvalidProtocolBufferException + +class DelegatingCommandHandler(handler: (CommandRequest, CommandResult => Unit) => Unit) extends ServiceHandler with Logging { + + def handleMessage(msg: Array[Byte], responseHandler: (Array[Byte]) => Unit) { + + try { + val requestEnvelope = ServiceRequest.parseFrom(msg) + + if (!requestEnvelope.hasPayload) { + throw new BadRequestException("Must include request payload") + } + val payload = requestEnvelope.getPayload.toByteArray + + val request = PostCommandRequestRequest.parseFrom(payload) + if (!request.hasRequest) { + throw new BadRequestException("Must include command request") + } + + val cmdReq = request.getRequest + + if (!cmdReq.hasCommandUuid) { + throw new BadRequestException("Must include command id") + } + + def handleResult(result: CommandResult) { + val response = PostCommandRequestResponse.newBuilder().setResult(result).build() + + val respEnvelope = ServiceResponse.newBuilder() + .setStatus(Envelope.Status.OK) + .setPayload(response.toByteString) + .build() + + responseHandler(respEnvelope.toByteArray) + } + + handler(cmdReq, handleResult) + + } catch { + case ex: InvalidProtocolBufferException => + responseHandler(buildErrorEnvelope(Envelope.Status.BAD_REQUEST, "Could not parse request").toByteArray) + case rse: ServiceException => + responseHandler(buildErrorEnvelope(rse.getStatus, rse.getMessage).toByteArray) + case ex: Throwable => + logger.error("Error handling command request: " + ex) + responseHandler(buildErrorEnvelope(Envelope.Status.INTERNAL_ERROR, "Error handling command request").toByteArray) + } + } + + private def buildErrorEnvelope(status: Envelope.Status, message: String): ServiceResponse = { + ServiceResponse.newBuilder() + .setStatus(status) + .setErrorMessage(message) + .build() + } +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FepConfigLoader.scala b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FepConfigLoader.scala new file mode 100644 index 0000000..01ebd3e --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FepConfigLoader.scala @@ -0,0 +1,96 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend + +import java.io.FileInputStream +import java.util.UUID + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.app.actor.frontend.json.{ JsonFrontendConfiguration, JsonFrontendRegistrationConfig } +import io.greenbus.app.actor.json.JsonAmqpConfig +import io.greenbus.app.actor.{ AmqpConnectionConfig, EndpointCollectionStrategy, ProtocolsEndpointStrategy } +import org.apache.commons.io.IOUtils + +object FepConfigLoader extends Logging { + + def loadConfig(jsonPath: String, defaultAmqpConfigPath: String, defaultUserConfigPath: String, protocols: Set[String]): FrontendProcessConfig = { + + loadJsonConfig(jsonPath) match { + case Some(jsonConfig) => { + + val amqpConfig = jsonConfig.amqpConfig + .map(JsonAmqpConfig.read(_, Seq(defaultAmqpConfigPath))) + .getOrElse(AmqpConnectionConfig.default(defaultAmqpConfigPath)) + + val userConfigPath = jsonConfig.userConfigPath.getOrElse(defaultUserConfigPath) + + val nodeId = jsonConfig.nodeId.getOrElse(UUID.randomUUID().toString) + + val collStrat = jsonConfig.endpointWhitelist match { + case None => new ProtocolsEndpointStrategy(protocols) + case Some(list) => new ProtocolsEndpointStrategy(protocols, Some(list.toSet)) + } + + val regConfig = jsonConfig.registrationConfig.map(JsonFrontendRegistrationConfig.read).getOrElse(FrontendRegistrationConfig.defaults) + + FrontendProcessConfig(amqpConfig, userConfigPath, collStrat, nodeId, regConfig) + + } + case None => { + + FrontendProcessConfig( + AmqpConnectionConfig.default(defaultAmqpConfigPath), + defaultUserConfigPath, + new ProtocolsEndpointStrategy(protocols), + UUID.randomUUID().toString, + FrontendRegistrationConfig.defaults) + } + } + + } + + def loadJsonConfig(path: String): Option[JsonFrontendConfiguration] = { + + try { + val bytes = IOUtils.toByteArray(new FileInputStream(path)) + JsonFrontendConfiguration.load(bytes) + } catch { + case ex: Throwable => + logger.error("Could not load json configuration: " + path + ", reason: " + ex.getMessage) + None + } + + } + + def loadEndpointCollectionStrategy(protocols: Set[String], baseDir: String, property: String): EndpointCollectionStrategy = { + + val endpointCollectionPathOpt = Option(System.getProperty(property)) + + endpointCollectionPathOpt match { + case None => new ProtocolsEndpointStrategy(protocols) + case Some(endpointCollectionPath) => + val bytes = IOUtils.toByteArray(new FileInputStream(endpointCollectionPath)) + val jsonConfig = JsonFrontendConfiguration.load(bytes).getOrElse { + throw new IllegalArgumentException(s"Could not parse endpoint collection configuration file $endpointCollectionPath") + } + + new ProtocolsEndpointStrategy(protocols, jsonConfig.endpointWhitelist.map(_.toSet)) + } + } +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FepEndpointCollectionManager.scala b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FepEndpointCollectionManager.scala new file mode 100644 index 0000000..c83738a --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FepEndpointCollectionManager.scala @@ -0,0 +1,149 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend + +import java.io.IOException + +import akka.actor._ +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.app.actor.{ CollectionMembership, EndpointCollectionStrategy, OutOfEndpointCollectionException } +import io.greenbus.client.exception.UnauthorizedException +import io.greenbus.client.proto.Envelope.SubscriptionEventType +import io.greenbus.client.service.proto.Model.{ Endpoint, EndpointNotification, ModelUUID } +import io.greenbus.msg.amqp.AmqpServiceOperations +import io.greenbus.msg.{ Session, SessionUnusableException, Subscription, SubscriptionBinding } + +object FepEndpointCollectionManager { + + case class EndpointsResolved(session: Session, serviceOps: AmqpServiceOperations, endpoints: Seq[Endpoint]) // Unlike added it just means it was there when we came up + case class EndpointAdded(endpoint: Endpoint) + case class EndpointModified(endpoint: Endpoint) + case class EndpointRemoved(endpoint: Endpoint) + case class EndpointOutOfCollection(endpoint: Endpoint) // Unlike removed, just means we're not responsible for it anymore + + private case object LookupEndpoints + private case class EndpointsResult(poll: Seq[Endpoint], subscription: Subscription[EndpointNotification]) + private case class RequestFailure(ex: Throwable) + + def props(strategy: EndpointCollectionStrategy, session: Session, serviceOps: AmqpServiceOperations, endpointObserver: Option[ActorRef], factory: (Endpoint, CollectionMembership, Session, AmqpServiceOperations) => Props): Props = + Props(classOf[FepEndpointCollectionManager], strategy, session, serviceOps, endpointObserver, factory) + +} + +class FepEndpointCollectionManager(strategy: EndpointCollectionStrategy, session: Session, serviceOps: AmqpServiceOperations, endpointObserver: Option[ActorRef], factory: (Endpoint, CollectionMembership, Session, AmqpServiceOperations) => Props) extends Actor with Logging { + import FepEndpointCollectionManager._ + + private var binding = Option.empty[SubscriptionBinding] + private var streams = Map.empty[ModelUUID, ActorRef] + + self ! LookupEndpoints + + def receive = { + + case LookupEndpoints => { + + import context.dispatcher + val configFut = strategy.configuration(session) + + configFut.onSuccess { case (poll, subscription) => self ! EndpointsResult(poll, subscription) } + configFut.onFailure { case ex => self ! RequestFailure(ex) } + } + + case EndpointsResult(results, subscription) => { + + binding = Some(subscription) + subscription.start { event => self ! event } + + endpointObserver.foreach(ref => ref ! EndpointsResolved(session, serviceOps, results)) + + val initial = results.filter(_.getDisabled == false).map { endpoint => + (endpoint.getUuid, launchStream(endpoint)) + } + + streams = initial.toMap + + logger.info("Endpoint collection management initialized") + } + + case RequestFailure(ex) => throw ex + + case event: EndpointNotification => { + val endpoint = event.getValue + val uuid = endpoint.getUuid + val name = endpoint.getName + + endpointObserver.foreach { obsRef => + val msg = event.getEventType match { + case SubscriptionEventType.ADDED => EndpointAdded(endpoint) + case SubscriptionEventType.MODIFIED => EndpointModified(endpoint) + case SubscriptionEventType.REMOVED => EndpointRemoved(endpoint) + } + + obsRef ! msg + } + + (event.getEventType, endpoint.getDisabled) match { + case (SubscriptionEventType.ADDED, false) => + if (!streams.contains(uuid)) { + addStream(endpoint) + } else { + logger.warn(s"Saw add event on existing endpoint stream: $name (${uuid.getValue})") + } + case (SubscriptionEventType.ADDED, true) => + case (SubscriptionEventType.MODIFIED, false) => + if (!streams.contains(uuid)) { + addStream(endpoint) + } + case (SubscriptionEventType.MODIFIED, true) => removeStream(uuid) + case (SubscriptionEventType.REMOVED, _) => removeStream(uuid) + } + } + + } + + private def addStream(endpoint: Endpoint) { + streams = streams + ((endpoint.getUuid, launchStream(endpoint))) + } + private def removeStream(uuid: ModelUUID) { + logger.debug("Removing endpoint " + uuid.getValue) + streams.get(uuid).foreach(ref => ref ! PoisonPill) + streams -= uuid + } + + private def launchStream(endpoint: Endpoint): ActorRef = { + logger.debug("Launching endpoint " + endpoint.getName) + context.actorOf(factory(endpoint, strategy.membership, session.spawn(), serviceOps)) + } + + override def supervisorStrategy: SupervisorStrategy = { + import SupervisorStrategy._ + OneForOneStrategy() { + case out: OutOfEndpointCollectionException => { + logger.info("Endpoint marked itself as out of collection: " + out.endpoint.getName) + endpointObserver.foreach(_ ! EndpointOutOfCollection(out.endpoint)) + removeStream(out.endpoint.getUuid) + Resume + } + case _: UnauthorizedException => Escalate + case _: SessionUnusableException => Escalate + case _: IOException => Escalate + case _: Throwable => Escalate + } + } +} \ No newline at end of file diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FepEndpointMonitor.scala b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FepEndpointMonitor.scala new file mode 100644 index 0000000..11a8220 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FepEndpointMonitor.scala @@ -0,0 +1,123 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend + +import akka.actor._ +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.app.actor._ +import io.greenbus.client.exception.UnauthorizedException +import io.greenbus.client.service.ModelService +import io.greenbus.client.service.proto.Model.{ Endpoint, EndpointNotification } +import io.greenbus.client.service.proto.ModelRequests.EndpointSubscriptionQuery +import io.greenbus.msg.{ Session, SessionUnusableException, Subscription, SubscriptionBinding } + +import scala.concurrent.{ ExecutionContext, Future } + +object FepEndpointMonitor { + + class EndpointConfigException(msg: String) extends Exception(msg) + + def queryEndpoint(session: Session, endpoint: Endpoint)(implicit context: ExecutionContext): Future[(Seq[Endpoint], Subscription[EndpointNotification])] = { + val modelClient = ModelService.client(session) + + val endSubQuery = EndpointSubscriptionQuery.newBuilder().addUuids(endpoint.getUuid).build() + + modelClient.subscribeToEndpoints(endSubQuery) + } + + case object MakeRequests + case class EndpointConfig(endpoints: Seq[Endpoint], endpointSub: Subscription[EndpointNotification]) + case class RequestFailure(ex: Throwable) + + def props(endpoint: Endpoint, membership: CollectionMembership, session: Session): Props = + Props(classOf[FepEndpointMonitor], endpoint, membership, session) + +} + +class FepEndpointMonitor(endpoint: Endpoint, membership: CollectionMembership, session: Session) extends Actor with Logging { + import FepEndpointMonitor._ + + private var endpointBinding = Option.empty[SubscriptionBinding] + + self ! MakeRequests + + def receive = { + + case MakeRequests => { + import context.dispatcher + + val dataFut = queryEndpoint(session, endpoint) + dataFut.onSuccess { case config => self ! EndpointConfig(config._1, config._2) } + dataFut.onFailure { case ex => self ! RequestFailure(ex) } + } + + case config: EndpointConfig => { + import config._ + + endpointBinding = Some(endpointSub) + + endpointSub.start { self ! _ } + + logger.info(s"Endpoint collection monitoring initialized for ${endpoint.getName}") + } + + case endEvent: EndpointNotification => { + import io.greenbus.client.proto.Envelope.SubscriptionEventType._ + + // We need to help the collection manager know if our endpoint has fallen outside our set + // If the collection manager is subscribing by name/protocol, a change to those parameters may not be + // reflected in its subscription + endEvent.getEventType match { + case ADDED => logger.warn(s"Saw added in endpoint manager for ${endpoint.getName}; endpoint already existed") + case REMOVED => // not our responsibility (collection manager will handle) + case MODIFIED => + val changed = endEvent.getValue + membership match { + case AnyAndAllMembership => + case NamedMembership => + if (endpoint.getName != changed.getName) { + throw new OutOfEndpointCollectionException(endpoint, "name changed") + } + case ProtocolMembership(valid) => + if (!valid.contains(changed.getProtocol)) { + throw new OutOfEndpointCollectionException(endpoint, "protocol changed") + } + } + } + } + + case RequestFailure(ex) => + logger.warn(s"Error making requests for endpoint manager for ${endpoint.getName}: ${ex.getMessage}") + throw ex + } + + override def postStop() { + endpointBinding.foreach(_.cancel()) + } + + override def supervisorStrategy: SupervisorStrategy = { + import akka.actor.SupervisorStrategy._ + OneForOneStrategy() { + case _: UnauthorizedException => Escalate + case _: SessionUnusableException => Escalate + case _: Throwable => Escalate + } + } + +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FrontEndSetManager.scala b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FrontEndSetManager.scala new file mode 100644 index 0000000..4d4f28d --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FrontEndSetManager.scala @@ -0,0 +1,155 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend + +import akka.actor._ +import akka.pattern.gracefulStop +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.app.actor.RestartConnection +import io.greenbus.msg.Session +import io.greenbus.msg.amqp.AmqpServiceOperations +import io.greenbus.app.actor.frontend.FepEndpointCollectionManager._ +import io.greenbus.app.actor.frontend.FrontEndSetManager._ +import io.greenbus.client.service.proto.Model.{ Endpoint, ModelUUID } + +import scala.concurrent.Future +import scala.concurrent.duration._ + +object FrontEndSetManager { + + case object BusConnectionStop + case object BusConnectionStart + + type ConnMgrFactory = (ActorRef) => Props + type ChildFactory[ProtocolConfig] = (Endpoint, ActorRef, MasterProtocol[ProtocolConfig]) => Props + type ProtocolMgrFactory[ProtocolConfig] = (ActorContext) => MasterProtocol[ProtocolConfig] + + def props[ProtocolConfig](connMgrFactory: ConnMgrFactory, childFactory: ChildFactory[ProtocolConfig], protFactory: ProtocolMgrFactory[ProtocolConfig]): Props = { + Props(classOf[FrontEndSetManager[ProtocolConfig]], connMgrFactory, childFactory, protFactory) + } +} +class FrontEndSetManager[ProtocolConfig](connMgrFactory: ConnMgrFactory, childFactory: ChildFactory[ProtocolConfig], protFactory: ProtocolMgrFactory[ProtocolConfig]) extends Actor with Logging { + + private val protocolMgr = protFactory(context) + + private var connMgr: Option[ActorRef] = Some(context.actorOf(connMgrFactory(self))) + + private var endpointMap = Map.empty[ModelUUID, ActorRef] + + private var linkOpt = Option.empty[(Session, AmqpServiceOperations)] + + def receive = { + + case BusConnectionStop => { + sender ! connMgr.map(ref => gracefulStop(ref, 5000.milliseconds)).getOrElse(Future.successful(true)) + connMgr = None + } + + case BusConnectionStart => { + if (connMgr.isEmpty) { + connMgr = Some(context.actorOf(connMgrFactory(self))) + } + } + + case ServiceSessionDead => { + linkOpt = None + connMgr.foreach { _ ! RestartConnection("session dead") } + } + + case EndpointsResolved(session, serviceOps, endpoints) => { + + linkOpt = Some((session, serviceOps)) + + // Check if any have been removed or disabled + val resolvedMap = endpoints.map(e => (e.getUuid, e)).toMap + + val missingUuids = endpointMap.keySet.filterNot(resolvedMap.contains) + + if (missingUuids.nonEmpty) { + logger.info("When resolved, the following endpoints were missing and are being stopped: " + missingUuids.flatMap(resolvedMap.get).map(_.getName).mkString(", ")) + missingUuids.flatMap(endpointMap.get).foreach(_ ! PoisonPill) + endpointMap = endpointMap -- missingUuids + } + + val disabledSet = endpoints.filter(_.getDisabled).map(_.getUuid).toSet + val nowDisabled = disabledSet.filter(endpointMap.contains) + + if (nowDisabled.nonEmpty) { + logger.info("When resolved, the following endpoints were missing and are being stopped: " + missingUuids.flatMap(resolvedMap.get).map(_.getName).mkString(", ")) + nowDisabled.flatMap(endpointMap.get).foreach(_ ! PoisonPill) + endpointMap = endpointMap -- nowDisabled + } + + val notDisabledEndpoints = endpoints.filterNot(_.getDisabled) + val notStarted = notDisabledEndpoints.filterNot(e => endpointMap.contains(e.getUuid)) + + val created = notStarted.map(end => (end.getUuid, context.actorOf(childFactory(end, self, protocolMgr)))) + + endpointMap = endpointMap ++ created + + endpointMap.values.foreach { _ ! FrontendProtocolEndpoint.Connected(session, serviceOps) } + } + + case EndpointAdded(endpoint) => { + if (!endpoint.getDisabled && !endpointMap.contains(endpoint.getUuid)) { + addEndpoint(endpoint) + } + } + + case EndpointModified(endpoint) => { + (endpoint.getDisabled, endpointMap.get(endpoint.getUuid)) match { + case (true, Some(ref)) => + logger.info("Endpoint " + endpoint.getName + " disabled, shutting down protocol master") + ref ! PoisonPill + endpointMap -= endpoint.getUuid + case (false, None) => + logger.info("Endpoint " + endpoint.getName + " not disabled, starting protocol master") + addEndpoint(endpoint) + case _ => + } + } + + case EndpointRemoved(endpoint) => { + endpointMap.get(endpoint.getUuid).foreach { ref => + logger.info("Endpoint " + endpoint.getName + " removed, shutting down protocol master") + ref ! PoisonPill + endpointMap -= endpoint.getUuid + } + } + + case EndpointOutOfCollection(endpoint) => { + endpointMap.get(endpoint.getUuid).foreach { ref => + logger.info("Endpoint " + endpoint.getName + " removed from collection, shutting down protocol master") + ref ! PoisonPill + endpointMap -= endpoint.getUuid + } + } + } + + override def postStop(): Unit = { + protocolMgr.shutdown() + } + + private def addEndpoint(endpoint: Endpoint): Unit = { + val actor = context.actorOf(childFactory(endpoint, self, protocolMgr)) + endpointMap = endpointMap.updated(endpoint.getUuid, actor) + linkOpt.foreach { case (sess, ops) => actor ! FrontendProtocolEndpoint.Connected(sess, ops) } + } + +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FrontendConfigurer.scala b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FrontendConfigurer.scala new file mode 100644 index 0000000..5d841c6 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FrontendConfigurer.scala @@ -0,0 +1,339 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend + +import akka.actor.{ Props, ActorRef, Actor } +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.{ SessionUnusableException, Subscription, Session } +import io.greenbus.app.actor.MessageScheduling +import io.greenbus.client.exception.UnauthorizedException +import io.greenbus.client.proto.Envelope.SubscriptionEventType._ +import io.greenbus.client.service.ModelService +import io.greenbus.client.service.proto.Model._ +import io.greenbus.client.service.proto.ModelRequests._ + +import scala.concurrent.{ ExecutionContext, Future } +import scala.collection.JavaConversions._ + +case class FrontendConfiguration( + points: Seq[Point], + commands: Seq[Command], + configs: Seq[EntityKeyValue], + modelEdgeSub: Subscription[EntityEdgeNotification], + configSub: Subscription[EntityKeyValueNotification]) { + + def cancelAll(): Unit = { + modelEdgeSub.cancel() + configSub.cancel() + } +} + +object FrontendConfigurer { + + private def fetchPoints(session: Session, endpoint: Endpoint)(implicit context: ExecutionContext): Future[Seq[Point]] = { + val modelClient = ModelService.client(session) + fetchFrontEndType(session, endpoint, "Point", modelClient.getPoints) + } + + private def fetchCommands(session: Session, endpoint: Endpoint)(implicit context: ExecutionContext): Future[Seq[Command]] = { + val modelClient = ModelService.client(session) + fetchFrontEndType(session, endpoint, "Command", modelClient.getCommands) + } + + private def frontEndTypePage[A](session: Session, endpoint: Endpoint, results: Seq[A], lastUuid: Option[ModelUUID], typ: String, get: EntityKeySet => Future[Seq[A]])(implicit context: ExecutionContext): Future[Seq[A]] = { + val modelClient = ModelService.client(session) + + val pageSize = 300 + + val objRelationBuilder = EntityRelationshipFlatQuery.newBuilder() + .addStartUuids(endpoint.getUuid) + .setDescendantOf(true) + .setRelationship("source") + .addEndTypes(typ) + + val pageParamsB = EntityPagingParams.newBuilder() + .setPageSize(pageSize) + + lastUuid.foreach(pageParamsB.setLastUuid) + + val entFut = modelClient.relationshipFlatQuery(objRelationBuilder.setPagingParams(pageParamsB).build()) + + entFut.flatMap { ents => + if (ents.isEmpty) { + Future.successful(results) + } else { + val entityKeySet = EntityKeySet.newBuilder().addAllUuids(ents.map(_.getUuid)).build() + + get(entityKeySet).flatMap { objs => + if (ents.size < pageSize) { + Future.successful(results ++ objs) + } else { + frontEndTypePage(session, endpoint, results ++ objs, Some(ents.last.getUuid), typ, get) + } + } + } + } + + } + + private def fetchFrontEndType[A](session: Session, endpoint: Endpoint, typ: String, get: EntityKeySet => Future[Seq[A]])(implicit context: ExecutionContext): Future[Seq[A]] = { + frontEndTypePage(session, endpoint, Seq(), None, typ, get) + } + + private def fetchConfig(session: Session, endpoint: Endpoint, keys: Seq[String])(implicit context: ExecutionContext): Future[Seq[EntityKeyValue]] = { + val modelClient = ModelService.client(session) + + val keyPairs = keys.map { key => EntityKeyPair.newBuilder().setUuid(endpoint.getUuid).setKey(key).build() } + + if (keyPairs.nonEmpty) { + modelClient.getEntityKeyValues(keyPairs) + } else { + Future.successful(Seq()) + } + } + + private def fetchConfigSub(session: Session, endpoint: Endpoint, keys: Seq[String])(implicit context: ExecutionContext): Future[Subscription[EntityKeyValueNotification]] = { + val modelClient = ModelService.client(session) + + val keyPairs = keys.map { k => EntityKeyPair.newBuilder().setUuid(endpoint.getUuid).setKey(k).build() } + + val subQuery = EntityKeyValueSubscriptionQuery.newBuilder() + .addAllKeyPairs(keyPairs) + .build() + + modelClient.subscribeToEntityKeyValues(subQuery).map(_._2) + } + + private def fetchEndpointEdgesSub(session: Session, endpoint: Endpoint)(implicit context: ExecutionContext): Future[Subscription[EntityEdgeNotification]] = { + val modelClient = ModelService.client(session) + + val edgeQuery = EntityEdgeSubscriptionQuery.newBuilder() + .addFilters(EntityEdgeFilter.newBuilder() + .setParentUuid(endpoint.getUuid) + .setRelationship("source") + .setDistance(1) + .build()) + .build() + + modelClient.subscribeToEdges(edgeQuery).map(_._2) + } + + private def fetchSubs(session: Session, endpoint: Endpoint, keys: Seq[String])(implicit context: ExecutionContext): Future[(Subscription[EntityEdgeNotification], Subscription[EntityKeyValueNotification])] = { + + val entEdgeSubFut = fetchEndpointEdgesSub(session, endpoint) + + entEdgeSubFut.flatMap { entEdgeSub => + val configSubFut = fetchConfigSub(session, endpoint, keys) + configSubFut.onFailure { case _ => entEdgeSub.cancel() } + + configSubFut.map(configSub => (entEdgeSub, configSub)) + } + } + + def frontendConfiguration(session: Session, endpoint: Endpoint, keys: Seq[String])(implicit context: ExecutionContext): Future[FrontendConfiguration] = { + val subsFut = fetchSubs(session, endpoint, keys) + + val staticFut = fetchPoints(session, endpoint) zip fetchCommands(session, endpoint) zip fetchConfig(session, endpoint, keys) + + staticFut.onFailure { case _ => subsFut.foreach { case (modelSub, configSub) => modelSub.cancel(); configSub.cancel() } } + + (staticFut zip subsFut).map { + case (((points, commands), configs), (modelSub, configSub)) => + FrontendConfiguration(points, commands, configs, modelSub, configSub) + } + } + +} + +object FrontendConfigUpdater { + + case class ConfigFilesUpdated(configKeyValues: Seq[EntityKeyValue]) + case class PointsUpdated(points: Seq[Point]) + case class CommandsUpdated(commands: Seq[Command]) + + private case class ObjectAdded(uuid: ModelUUID) + private case class ObjectRemoved(uuid: ModelUUID) + + private case class ConfigAdded(config: EntityKeyValue) + private case class ConfigModified(config: EntityKeyValue) + private case class ConfigRemoved(uuid: ModelUUID, key: String) + + private case class PointRetrieved(point: Point) + private case class CommandRetrieved(command: Command) + + private case class ConfigRetrieveError(uuid: ModelUUID, ex: Throwable) + private case class PointCommandRetrieveError(uuid: ModelUUID, ex: Throwable) + + private case class ObjectAddedRetry(uuid: ModelUUID) + private case class ConfigEdgeAddedRetry(uuid: ModelUUID) + + def props( + subject: ActorRef, + endpoint: Endpoint, + session: Session, + config: FrontendConfiguration, + requestRetryMs: Long): Props = { + + Props(classOf[FrontendConfigUpdater], subject, endpoint, session, config, requestRetryMs) + } +} + +class FrontendConfigUpdater( + subject: ActorRef, + endpoint: Endpoint, + session: Session, + config: FrontendConfiguration, + requestRetryMs: Long) extends Actor with MessageScheduling with Logging { + import FrontendConfigUpdater._ + import context.dispatcher + + private var pointMap = config.points.map(p => (p.getUuid, p)).toMap + private var commandMap = config.commands.map(c => (c.getUuid, c)).toMap + private var configMap: Map[(ModelUUID, String), EntityKeyValue] = config.configs.map(cf => ((cf.getUuid, cf.getKey), cf)).toMap + + private var outstandingSourceEnts = Set.empty[ModelUUID] + + config.modelEdgeSub.start { edgeNotification => + val objUuid = edgeNotification.getValue.getChild + edgeNotification.getEventType match { + case MODIFIED => + case ADDED => self ! ObjectAdded(objUuid) + case REMOVED => self ! ObjectRemoved(objUuid) + } + } + + config.configSub.start { configNotification => + configNotification.getEventType match { + case ADDED => self ! ConfigAdded(configNotification.getValue) + case MODIFIED => self ! ConfigModified(configNotification.getValue) + case REMOVED => self ! ConfigRemoved(configNotification.getValue.getUuid, configNotification.getValue.getKey) + } + } + + override def postStop(): Unit = { + config.modelEdgeSub.cancel() + config.configSub.cancel() + } + + def receive = { + case ConfigAdded(kvp) => + logger.debug("Saw config add for " + kvp.getKey + " on endpoint " + endpoint.getName) + configMap += ((kvp.getUuid, kvp.getKey) -> kvp) + subject ! ConfigFilesUpdated(configMap.values.toSeq) + + case ConfigModified(kvp) => + logger.debug("Saw config modified for " + kvp.getKey + " on endpoint " + endpoint.getName) + configMap += ((kvp.getUuid, kvp.getKey) -> kvp) + subject ! ConfigFilesUpdated(configMap.values.toSeq) + + case ConfigRemoved(uuid, key) => + logger.debug("Saw config remove for " + uuid.getValue + " on endpoint " + endpoint.getName) + configMap -= ((uuid, key)) + subject ! ConfigFilesUpdated(configMap.values.toSeq) + + case ObjectAdded(objUuid: ModelUUID) => + logger.debug("Saw object add for " + objUuid.getValue + " on endpoint " + endpoint.getName) + outstandingSourceEnts += objUuid + retrieveEndpointAssociatedObject(objUuid) + + case ObjectRemoved(objUuid: ModelUUID) => + logger.debug("Saw object remove for " + objUuid.getValue + " on endpoint " + endpoint.getName) + outstandingSourceEnts -= objUuid + if (pointMap.contains(objUuid)) { + pointMap -= objUuid + subject ! PointsUpdated(pointMap.values.toSeq) + } else if (commandMap.contains(objUuid)) { + commandMap -= objUuid + subject ! CommandsUpdated(commandMap.values.toSeq) + } else { + logger.warn("Removed event for " + objUuid.getValue + " did not correspond to obj owned by endpoint " + endpoint.getName) + } + + case PointRetrieved(point: Point) => + logger.debug("Retrieved point for " + point.getName + " on endpoint " + endpoint.getName) + if (outstandingSourceEnts.contains(point.getUuid)) { + pointMap += (point.getUuid -> point) + subject ! PointsUpdated(pointMap.values.toSeq) + } + + case CommandRetrieved(cmd: Command) => + logger.debug("Retrieved command for " + cmd.getName + " on endpoint " + endpoint.getName) + if (outstandingSourceEnts.contains(cmd.getUuid)) { + commandMap += (cmd.getUuid -> cmd) + subject ! CommandsUpdated(commandMap.values.toSeq) + } + + case PointCommandRetrieveError(uuid, ex) => + logger.warn("Point/command lookup error in endpoint " + endpoint.getName) + ex match { + case ex: SessionUnusableException => throw ex + case ex: UnauthorizedException => throw ex + case ex: Throwable => scheduleMsg(requestRetryMs, ObjectAddedRetry(uuid)) + } + + case ObjectAddedRetry(uuid) => + if (outstandingSourceEnts.contains(uuid)) { + retrieveEndpointAssociatedObject(uuid) + } + } + + private def retrieveEndpointAssociatedObject(uuid: ModelUUID) { + val lookupFut = lookupFrontEndEntity(uuid).flatMap { + case None => + logger.warn("Saw endpoint owned object, no entity found") + Future.successful(None) + case Some(ent) => + if (ent.getTypesList.exists(_ == "Point")) { + lookupPoint(uuid) + } else if (ent.getTypesList.exists(_ == "Command")) { + lookupCommand(uuid) + } else { + logger.warn("Saw endpoint owned object that was not a point or command") + Future.successful(None) + } + } + + lookupFut.onSuccess { + case None => + case Some(p: Point) => self ! PointRetrieved(p) + case Some(c: Command) => self ! CommandRetrieved(c) + } + + lookupFut.onFailure { + case ex: Throwable => self ! PointCommandRetrieveError(uuid, ex) + } + } + + private def lookupFrontEndEntity(uuid: ModelUUID): Future[Option[Entity]] = { + val modelClient = ModelService.client(session) + val keySet = EntityKeySet.newBuilder().addUuids(uuid).build() + modelClient.get(keySet).map(_.headOption) + } + + private def lookupPoint(uuid: ModelUUID): Future[Option[Point]] = { + val modelClient = ModelService.client(session) + val keySet = EntityKeySet.newBuilder().addUuids(uuid).build() + modelClient.getPoints(keySet).map(_.headOption) + } + private def lookupCommand(uuid: ModelUUID): Future[Option[Command]] = { + val modelClient = ModelService.client(session) + val keySet = EntityKeySet.newBuilder().addUuids(uuid).build() + modelClient.getCommands(keySet).map(_.headOption) + } +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FrontendFactory.scala b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FrontendFactory.scala new file mode 100644 index 0000000..8764307 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FrontendFactory.scala @@ -0,0 +1,101 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend + +import akka.actor.{ ActorContext, ActorRef, Props } +import io.greenbus.app.actor._ +import io.greenbus.client.ServiceConnection +import io.greenbus.client.service.proto.Model.Endpoint +import io.greenbus.msg.Session +import io.greenbus.msg.amqp.AmqpServiceOperations + +import scala.concurrent.ExecutionContext.Implicits.global + +object FrontendFactory { + + def create[ProtocolConfig]( + amqpConfig: AmqpConnectionConfig, + userConfigPath: String, + endpointStrategy: EndpointCollectionStrategy, + protocolMgrFactory: ActorContext => MasterProtocol[ProtocolConfig], + configurer: ProtocolConfigurer[ProtocolConfig], + configKeys: Seq[String], + connectionRepresentsLock: Boolean, + nodeId: String, + registrationConfig: FrontendRegistrationConfig) = { + + def connMgr(observer: ActorRef) = { + + def endMon(endpoint: Endpoint, coll: CollectionMembership, session: Session, ops: AmqpServiceOperations) = { + FepEndpointMonitor.props(endpoint, coll, session) + } + + def sessionMgr(conn: ServiceConnection): Props = { + SessionLoginManager.props("Endpoint Session Manager", userConfigPath, conn, + registrationConfig.loginRetryMs, + factory = FepEndpointCollectionManager.props(endpointStrategy, _, _, Some(observer), endMon)) + } + + FailoverConnectionManager.props("Endpoint Connection Bridge", + amqpConfig.amqpConfigFileList, + failureLimit = amqpConfig.failureLimit, + retryDelayMs = amqpConfig.retryDelayMs, + connectionTimeoutMs = amqpConfig.connectionTimeoutMs, + sessionMgr) + } + + def child(endpoint: Endpoint, manager: ActorRef, masterProtocol: MasterProtocol[ProtocolConfig]) = { + + def proxyFactory(subject: ActorRef, endpoint: Endpoint) = { + ProcessingProxy.props( + subject, + endpoint, + registrationConfig.statusHeartbeatPeriodMs, + registrationConfig.lapsedTimeMs, + registrationConfig.statusRetryPeriodMs, + registrationConfig.measRetryPeriodMs, + registrationConfig.measQueueLimit) + } + + def updaterFactory( + subject: ActorRef, + endpoint: Endpoint, + session: Session, + config: FrontendConfiguration) = { + FrontendConfigUpdater.props(subject, endpoint, session, config, registrationConfig.configRequestRetryMs) + } + + FrontendProtocolEndpoint.props( + endpoint, + manager, + masterProtocol, + configurer, + registrationConfig.registrationRetryMs, + registrationConfig.releaseTimeoutMs, + proxyFactory, + CommandProxy.props, + updaterFactory, + FrontendProtocolEndpoint.register(_, _, _, _, connectionRepresentsLock, nodeId), + FrontendConfigurer.frontendConfiguration(_, _, configKeys)) + } + + FrontEndSetManager.props(connMgr, child, protocolMgrFactory) + + } +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FrontendProcessConfig.scala b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FrontendProcessConfig.scala new file mode 100644 index 0000000..47026e9 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FrontendProcessConfig.scala @@ -0,0 +1,74 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend + +import io.greenbus.app.actor.{ EndpointCollectionStrategy, AmqpConnectionConfig } + +case class FrontendRegistrationConfig( + loginRetryMs: Long, + registrationRetryMs: Long, + releaseTimeoutMs: Long, + statusHeartbeatPeriodMs: Long, + lapsedTimeMs: Long, + statusRetryPeriodMs: Long, + measRetryPeriodMs: Int, + measQueueLimit: Int, + configRequestRetryMs: Int) + +object FrontendRegistrationConfig { + + def defaults = FrontendRegistrationConfig( + loginRetryMs = 5000, + registrationRetryMs = 5000, + releaseTimeoutMs = 20000, + statusHeartbeatPeriodMs = 5000, + lapsedTimeMs = 11000, + statusRetryPeriodMs = 2000, + measRetryPeriodMs = 2000, + measQueueLimit = 1000, + configRequestRetryMs = 5000) + + def build(loginRetryMs: Long, + registrationRetryMs: Long, + releaseTimeoutMs: Long, + statusHeartbeatPeriodMs: Long, + lapsedTimeMs: Long, + statusRetryPeriodMs: Long, + measRetryPeriodMs: Int, + measQueueLimit: Int, + configRequestRetryMs: Int): FrontendRegistrationConfig = { + FrontendRegistrationConfig( + loginRetryMs, + registrationRetryMs, + releaseTimeoutMs, + statusHeartbeatPeriodMs, + lapsedTimeMs, + statusRetryPeriodMs, + measRetryPeriodMs, + measQueueLimit, + configRequestRetryMs) + } +} + +case class FrontendProcessConfig( + amqpConfig: AmqpConnectionConfig, + userConfigPath: String, + collectionStrategy: EndpointCollectionStrategy, + nodeId: String, + registrationConfig: FrontendRegistrationConfig) diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FrontendProtocolEndpoint.scala b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FrontendProtocolEndpoint.scala new file mode 100644 index 0000000..de003a7 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/FrontendProtocolEndpoint.scala @@ -0,0 +1,656 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend + +import akka.actor.{ ActorRef, PoisonPill, Props } +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.amqp.AmqpServiceOperations +import io.greenbus.msg.service.ServiceHandlerSubscription +import io.greenbus.msg.{ Session, SessionUnusableException } +import io.greenbus.app.actor.MessageScheduling +import io.greenbus.app.actor.frontend.CommandProxy.ProtocolInitialized +import io.greenbus.app.actor.frontend.FrontendConfigUpdater.{ CommandsUpdated, ConfigFilesUpdated, PointsUpdated } +import io.greenbus.app.actor.frontend.FrontendProtocolEndpoint._ +import io.greenbus.app.actor.frontend.ProcessingProxy.LinkLapsed +import io.greenbus.app.actor.util.NestedStateMachine +import io.greenbus.client.exception.{ LockedException, UnauthorizedException } +import io.greenbus.client.service.FrontEndService +import io.greenbus.client.service.proto.Commands.{ CommandRequest, CommandResult } +import io.greenbus.client.service.proto.FrontEnd.{ FrontEndConnectionStatus, FrontEndRegistration } +import io.greenbus.client.service.proto.FrontEndRequests.FrontEndRegistrationTemplate +import io.greenbus.client.service.proto.Model.{ Endpoint, EntityKeyValue } + +import scala.concurrent.{ ExecutionContext, Future } + +case object ServiceSessionDead + +object FrontendProtocolEndpoint { + + type ConfigUpdaterFactory = (ActorRef, Endpoint, Session, FrontendConfiguration) => Props + type ProxyFactory = (ActorRef, Endpoint) => Props + type CommandActorFactory = Endpoint => Props + + type RegisterFunc = (Session, AmqpServiceOperations, Endpoint, Boolean) => (Future[FrontEndRegistration], ServiceHandlerSubscription) + type ConfigureFunc = (Session, Endpoint) => Future[FrontendConfiguration] + + protected sealed trait BusState + protected trait BindingHoldingBusState extends BusState { + val commandSub: ServiceHandlerSubscription + def cleanup() { commandSub.cancel() } + } + protected case class ServicesDown(linkPendingSince: Option[Long]) extends BusState + protected case class ServicesUp(session: Session, serviceOps: AmqpServiceOperations, linkPendingSince: Option[Long]) extends BusState + protected case class RegistrationPending(session: Session, serviceOps: AmqpServiceOperations, commandSub: ServiceHandlerSubscription, linkPendingSince: Option[Long]) extends BindingHoldingBusState + protected case class Registered(session: Session, serviceOps: AmqpServiceOperations, commandSub: ServiceHandlerSubscription, inputAddress: String) extends BindingHoldingBusState + protected case class ConfigPending(session: Session, serviceOps: AmqpServiceOperations, commandSub: ServiceHandlerSubscription, inputAddress: String, configFut: Future[FrontendConfiguration]) extends BindingHoldingBusState + protected case class Configured(session: Session, serviceOps: AmqpServiceOperations, commandSub: ServiceHandlerSubscription, inputAddress: String, updater: ActorRef) extends BindingHoldingBusState + + protected sealed trait ProtocolState + protected case object ProtocolUninit extends ProtocolState + + protected sealed trait ProtocolActiveState extends ProtocolState { + val configs: Seq[EntityKeyValue] + val holdingLock: Boolean = false + } + protected case class ProtocolPending(configs: Seq[EntityKeyValue]) extends ProtocolActiveState + protected case class ProtocolError(configs: Seq[EntityKeyValue]) extends ProtocolActiveState + protected case class ProtocolUp(configs: Seq[EntityKeyValue]) extends ProtocolActiveState { + override val holdingLock = true + } + protected case class ProtocolDown(configs: Seq[EntityKeyValue]) extends ProtocolActiveState + + case class Connected(session: Session, serviceOps: AmqpServiceOperations) + private case class RegistrationSuccess(conns: FrontEndRegistration) + private case class RegistrationFailure(ex: Throwable) + private case class ConfigurationSuccess(config: FrontendConfiguration) + private case class ConfigurationFailure(ex: Throwable) + + private sealed trait Retry + private case object DoRegistrationRetry extends Retry + private case object DoConfigRetry extends Retry + private case class DoReleaseTimeoutCheck(lossTime: Long) extends Retry + + private case class CommandIssued(req: CommandRequest, response: CommandResult => Unit) + + def props[ProtocolConfig]( + endpoint: Endpoint, + manager: ActorRef, + protocolMgr: MasterProtocol[ProtocolConfig], + configurer: ProtocolConfigurer[ProtocolConfig], + registrationRetryMs: Long, + releaseTimeoutMs: Long, + proxyFactory: ProxyFactory, + commandActorFactory: CommandActorFactory, + configUpdaterFactory: ConfigUpdaterFactory, + registerFunc: RegisterFunc, + configureFunc: ConfigureFunc): Props = { + + Props(classOf[FrontendProtocolEndpoint[ProtocolConfig]], endpoint, manager, protocolMgr, configurer, registrationRetryMs, releaseTimeoutMs, proxyFactory, commandActorFactory, configUpdaterFactory, registerFunc, configureFunc) + } + + def register(session: Session, serviceOps: AmqpServiceOperations, endpoint: Endpoint, holdingLock: Boolean, lockingEnabled: Boolean, nodeId: String)(implicit context: ExecutionContext): (Future[FrontEndRegistration], ServiceHandlerSubscription) = { + val serviceHandlerSub = serviceOps.routedServiceBinding() + val cmdAddress = serviceHandlerSub.getId() + + try { + val client = FrontEndService.client(session) + + val registration = FrontEndRegistrationTemplate.newBuilder() + .setEndpointUuid(endpoint.getUuid) + .setCommandAddress(cmdAddress) + .setHoldingLock(holdingLock && lockingEnabled) + .setFepNodeId(nodeId) + .build() + + val regFut = client.putFrontEndRegistration(registration, endpoint.getUuid.getValue) + + (regFut, serviceHandlerSub) + + } catch { + case ex: Throwable => + serviceHandlerSub.cancel() + throw ex + } + } + + def configsEqual(l: Seq[EntityKeyValue], r: Seq[EntityKeyValue]): Boolean = { + l.map(_.hashCode()).sorted == r.map(_.hashCode()).sorted + } +} + +class FrontendProtocolEndpoint[ProtocolConfig]( + endpoint: Endpoint, + manager: ActorRef, + protocolMgr: MasterProtocol[ProtocolConfig], + configurer: ProtocolConfigurer[ProtocolConfig], + registrationRetryMs: Long, + releaseTimeoutMs: Long, + proxyFactory: ProxyFactory, + commandActorFactory: CommandActorFactory, + configUpdaterFactory: ConfigUpdaterFactory, + registerFunc: RegisterFunc, + configureFunc: ConfigureFunc) extends NestedStateMachine with MessageScheduling with Logging { + import context.dispatcher + import io.greenbus.app.actor.frontend.FrontendProtocolEndpoint._ + + private val proxy = context.actorOf(proxyFactory(self, endpoint)) + private val commandActor = context.actorOf(commandActorFactory(endpoint)) + + protected type StateType = (BusState, ProtocolState) + protected def start: StateType = (ServicesDown(None), ProtocolUninit) + override protected def instanceId: Option[String] = Some(endpoint.getName) + + protected def machine = { + + case state @ (busState: ServicesDown, ProtocolUninit) => { + + case Connected(session, serviceOps) => { + (doRegistration(session, serviceOps, holdingLock = false, busState.linkPendingSince), ProtocolUninit) + } + } + + case state @ (busState: ServicesUp, ProtocolUninit) => { + + case ServiceSessionDead => { + manager ! ServiceSessionDead + (ServicesDown(busState.linkPendingSince), ProtocolUninit) + } + case Connected(session, serviceOps) => { + (doRegistration(session, serviceOps, holdingLock = false, busState.linkPendingSince), ProtocolUninit) + } + case DoRegistrationRetry => { + (doRegistration(busState.session, busState.serviceOps, holdingLock = false, busState.linkPendingSince), ProtocolUninit) + } + } + + case state @ (busState: RegistrationPending, ProtocolUninit) => { + + case ServiceSessionDead => { + manager ! ServiceSessionDead + busState.commandSub.cancel() + (ServicesDown(busState.linkPendingSince), ProtocolUninit) + } + case Connected(session, serviceOps) => { + busState.commandSub.cancel() + (doRegistration(session, serviceOps, holdingLock = false, busState.linkPendingSince), ProtocolUninit) + } + case RegistrationFailure(ex) => { + + def sessionDead(): StateType = { + manager ! ServiceSessionDead + busState.commandSub.cancel() + (ServicesDown(busState.linkPendingSince), ProtocolUninit) + } + + def retry(): StateType = { + scheduleMsg(registrationRetryMs, DoRegistrationRetry) + busState.commandSub.cancel() + (ServicesUp(busState.session, busState.serviceOps, busState.linkPendingSince), ProtocolUninit) + } + + ex match { + case ex: SessionUnusableException => sessionDead() + case ex: UnauthorizedException => sessionDead() + case ex: LockedException => retry() + case ex: Throwable => retry() + } + } + case RegistrationSuccess(registration) => { + + proxy ! ProcessingProxy.LinkUp(busState.session, registration.getInputAddress) + + commandActor ! CommandProxy.CommandSubscriptionRetrieved(busState.commandSub) + + (doConfig(busState.session, busState.serviceOps, busState.commandSub, registration.getInputAddress), ProtocolUninit) + } + } + + case state @ (busState: Registered, ProtocolUninit) => { + + case ServiceSessionDead => { + busState.commandSub.cancel() + sessionDeadGoDown(ProtocolUninit) + } + case Connected(session, serviceOps) => { + busState.commandSub.cancel() + doRegistrationAndSwitch(session, serviceOps, holdingLock = false, ProtocolUninit) + } + case LinkLapsed => { + busState.commandSub.cancel() + doRegistrationAndSwitch(busState.session, busState.serviceOps, holdingLock = false, ProtocolUninit) + } + case DoConfigRetry => { + (doConfig(busState.session, busState.serviceOps, busState.commandSub, busState.inputAddress), ProtocolUninit) + } + } + + case state @ (busState: ConfigPending, ProtocolUninit) => { + + case ServiceSessionDead => { + busState.commandSub.cancel() + sessionDeadGoDown(ProtocolUninit) + } + case Connected(session, serviceOps) => { + busState.commandSub.cancel() + doRegistrationAndSwitch(session, serviceOps, holdingLock = false, ProtocolUninit) + } + case LinkLapsed => { + busState.commandSub.cancel() + doRegistrationAndSwitch(busState.session, busState.serviceOps, holdingLock = false, ProtocolUninit) + } + case ConfigurationFailure(ex) => { + logger.debug(s"Endpoint ${endpoint.getName} saw configuration failure: $ex") + + def sessionDead(): StateType = { + busState.commandSub.cancel() + sessionDeadGoDown(ProtocolUninit) + } + + def retry(): StateType = { + scheduleMsg(registrationRetryMs, DoConfigRetry) + (Registered(busState.session, busState.serviceOps, busState.commandSub, busState.inputAddress), ProtocolUninit) + } + + ex match { + case ex: SessionUnusableException => sessionDead() + case ex: UnauthorizedException => sessionDead() + case ex: Throwable => retry() + } + } + case ConfigurationSuccess(config) => { + + proxy ! ProcessingProxy.PointMapUpdated(config.points.map(p => (p.getName, p.getUuid)).toMap) + commandActor ! CommandProxy.CommandsUpdated(config.commands) + + val configUpdaterChild = context.actorOf(configUpdaterFactory(self, endpoint, busState.session, config)) + + val resultBusState = Configured(busState.session, busState.serviceOps, busState.commandSub, busState.inputAddress, configUpdaterChild) + + configurer.evaluate(endpoint, config.configs) match { + case None => + logger.warn("Configuration couldn't be read for endpoint " + endpoint.getName) + (resultBusState, ProtocolUninit) + case Some(protocolConfig) => { + + try { + val cmdAcceptor = protocolMgr.add(endpoint, protocolConfig, proxy ! _, proxy ! _) + commandActor ! ProtocolInitialized(cmdAcceptor) + (resultBusState, ProtocolPending(config.configs)) + } catch { + case ex: Throwable => + logger.error(s"Error adding protocol master for endpoint ${endpoint.getName}: " + ex) + (resultBusState, ProtocolUninit) + } + } + } + } + } + + case state @ (busState: Configured, ProtocolUninit) => { + + case ServiceSessionDead => { + busState.commandSub.cancel() + busState.updater ! PoisonPill + sessionDeadGoDown(ProtocolUninit) + } + case Connected(session, serviceOps) => { + busState.updater ! PoisonPill + busState.commandSub.cancel() + doRegistrationAndSwitch(session, serviceOps, holdingLock = false, ProtocolUninit) + } + case LinkLapsed => { + busState.updater ! PoisonPill + busState.commandSub.cancel() + doRegistrationAndSwitch(busState.session, busState.serviceOps, holdingLock = false, ProtocolUninit) + } + case ConfigFilesUpdated(configFiles) => { + + configurer.evaluate(endpoint, configFiles) match { + case None => + logger.warn("Configuration couldn't be read for endpoint " + endpoint.getName) + state + case Some(protocolConfig) => { + try { + val cmdAcceptor = protocolMgr.add(endpoint, protocolConfig, proxy ! _, proxy ! _) + commandActor ! ProtocolInitialized(cmdAcceptor) + (busState, ProtocolPending(configFiles)) + } catch { + case ex: Throwable => + logger.error(s"Error adding protocol master for endpoint ${endpoint.getName}: " + ex) + state + } + } + } + } + case PointsUpdated(points) => { + proxy ! ProcessingProxy.PointMapUpdated(points.map(p => (p.getName, p.getUuid)).toMap) + state + } + case CommandsUpdated(commands) => { + commandActor ! CommandProxy.CommandsUpdated(commands) + state + } + } + + case state @ (busState: Configured, protocolState: ProtocolActiveState) => { + + case ServiceSessionDead => { + busState.commandSub.cancel() + busState.updater ! PoisonPill + sessionDeadGoDown(protocolState) + } + case Connected(session, serviceOps) => { + busState.updater ! PoisonPill + busState.commandSub.cancel() + doRegistrationAndSwitch(session, serviceOps, holdingLock = protocolState.holdingLock, protocolState) + } + case LinkLapsed => { + busState.updater ! PoisonPill + busState.commandSub.cancel() + doRegistrationAndSwitch(busState.session, busState.serviceOps, holdingLock = protocolState.holdingLock, protocolState) + } + case update @ StackStatusUpdated(status) => { + handleProtocolStateUpdate(update, protocolState.configs, busState) + } + case ConfigFilesUpdated(configFiles) => { + val optStateChange = handleProtocolUpConfigUpdate(protocolState.configs, configFiles) + (busState, optStateChange.getOrElse(protocolState)) + } + case PointsUpdated(points) => { + proxy ! ProcessingProxy.PointMapUpdated(points.map(p => (p.getName, p.getUuid)).toMap) + state + } + case CommandsUpdated(commands) => { + commandActor ! CommandProxy.CommandsUpdated(commands) + state + } + } + + case state @ (busState: ServicesDown, protocolState: ProtocolActiveState) => { + + case Connected(session, serviceOps) => { + (doRegistration(session, serviceOps, holdingLock = protocolState.holdingLock, busState.linkPendingSince), protocolState) + } + case update @ StackStatusUpdated(status) => { + handleProtocolStateUpdate(update, protocolState.configs, busState) + } + case DoReleaseTimeoutCheck(lossTime) => { + if (busState.linkPendingSince == Some(lossTime)) { + logger.info(s"Endpoint ${endpoint.getName} releasing protocol because release timeout expired") + commandActor ! CommandProxy.ProtocolUninitialized + protocolMgr.remove(endpoint.getUuid) + (busState, ProtocolUninit) + } else { + state + } + } + } + + case state @ (busState: ServicesUp, protocolState: ProtocolActiveState) => { + + case ServiceSessionDead => { + manager ! ServiceSessionDead + (ServicesDown(busState.linkPendingSince), protocolState) + } + case Connected(session, serviceOps) => { + (doRegistration(session, serviceOps, holdingLock = protocolState.holdingLock, busState.linkPendingSince), protocolState) + } + case DoRegistrationRetry => { + (doRegistration(busState.session, busState.serviceOps, holdingLock = protocolState.holdingLock, busState.linkPendingSince), protocolState) + } + case update @ StackStatusUpdated(status) => { + handleProtocolStateUpdate(update, protocolState.configs, busState) + } + case DoReleaseTimeoutCheck(lossTime) => { + if (busState.linkPendingSince == Some(lossTime)) { + logger.info(s"Endpoint ${endpoint.getName} releasing protocol because release timeout expired") + commandActor ! CommandProxy.ProtocolUninitialized + protocolMgr.remove(endpoint.getUuid) + (busState, ProtocolUninit) + } else { + state + } + } + } + + case state @ (busState: RegistrationPending, protocolState: ProtocolActiveState) => { + + case ServiceSessionDead => { + manager ! ServiceSessionDead + (ServicesDown(busState.linkPendingSince), protocolState) + } + case Connected(session, serviceOps) => { + (doRegistration(session, serviceOps, holdingLock = protocolState.holdingLock, busState.linkPendingSince), protocolState) + } + case update @ StackStatusUpdated(status) => { + handleProtocolStateUpdate(update, protocolState.configs, busState) + } + case RegistrationFailure(ex) => { + + def sessionDead(): StateType = { + manager ! ServiceSessionDead + busState.commandSub.cancel() + (ServicesDown(busState.linkPendingSince), protocolState) + } + + def retry(): StateType = { + scheduleMsg(registrationRetryMs, DoRegistrationRetry) + busState.commandSub.cancel() + (ServicesUp(busState.session, busState.serviceOps, busState.linkPendingSince), protocolState) + } + + ex match { + case ex: SessionUnusableException => sessionDead() + case ex: UnauthorizedException => sessionDead() + case ex: LockedException => + logger.info(s"Endpoint ${endpoint.getName} releasing protocol because registration returned locked") + busState.commandSub.cancel() + commandActor ! CommandProxy.ProtocolUninitialized + protocolMgr.remove(endpoint.getUuid) + scheduleMsg(registrationRetryMs, DoRegistrationRetry) + (ServicesUp(busState.session, busState.serviceOps, busState.linkPendingSince), ProtocolUninit) + case ex: Throwable => retry() + } + } + case RegistrationSuccess(registration) => { + + proxy ! ProcessingProxy.LinkUp(busState.session, registration.getInputAddress) + + commandActor ! CommandProxy.CommandSubscriptionRetrieved(busState.commandSub) + + (doConfig(busState.session, busState.serviceOps, busState.commandSub, registration.getInputAddress), protocolState) + } + case DoReleaseTimeoutCheck(lossTime) => { + if (busState.linkPendingSince == Some(lossTime)) { + logger.info(s"Endpoint ${endpoint.getName} releasing protocol because release timeout expired") + commandActor ! CommandProxy.ProtocolUninitialized + protocolMgr.remove(endpoint.getUuid) + (busState, ProtocolUninit) + } else { + state + } + } + } + + case state @ (busState: Registered, protocolState: ProtocolActiveState) => { + + case ServiceSessionDead => { + busState.commandSub.cancel() + sessionDeadGoDown(protocolState) + } + case Connected(session, serviceOps) => { + busState.commandSub.cancel() + doRegistrationAndSwitch(session, serviceOps, holdingLock = protocolState.holdingLock, protocolState) + } + case LinkLapsed => { + busState.commandSub.cancel() + doRegistrationAndSwitch(busState.session, busState.serviceOps, holdingLock = protocolState.holdingLock, protocolState) + } + case update @ StackStatusUpdated(status) => { + handleProtocolStateUpdate(update, protocolState.configs, busState) + } + case DoConfigRetry => { + (doConfig(busState.session, busState.serviceOps, busState.commandSub, busState.inputAddress), protocolState) + } + } + + case state @ (busState: ConfigPending, protocolState: ProtocolActiveState) => { + + case ServiceSessionDead => { + busState.commandSub.cancel() + sessionDeadGoDown(protocolState) + } + case Connected(session, serviceOps) => { + busState.commandSub.cancel() + doRegistrationAndSwitch(session, serviceOps, holdingLock = protocolState.holdingLock, protocolState) + } + case LinkLapsed => { + busState.commandSub.cancel() + doRegistrationAndSwitch(busState.session, busState.serviceOps, holdingLock = protocolState.holdingLock, protocolState) + } + case update @ StackStatusUpdated(status) => { + handleProtocolStateUpdate(update, protocolState.configs, busState) + } + case DoConfigRetry => { + (doConfig(busState.session, busState.serviceOps, busState.commandSub, busState.inputAddress), protocolState) + } + case ConfigurationFailure(ex) => { + logger.debug(s"Endpoint ${endpoint.getName} saw configuration failure: $ex") + + def sessionDead(): StateType = { + busState.commandSub.cancel() + sessionDeadGoDown(protocolState) + } + + def retry(): StateType = { + scheduleMsg(registrationRetryMs, DoConfigRetry) + (Registered(busState.session, busState.serviceOps, busState.commandSub, busState.inputAddress), protocolState) + } + + ex match { + case ex: SessionUnusableException => sessionDead() + case ex: UnauthorizedException => sessionDead() + case ex: Throwable => retry() + } + } + case ConfigurationSuccess(config) => { + logger.debug(s"Endpoint ${endpoint.getName} reconfigured while protocol running") + val configUpdaterChild = context.actorOf(configUpdaterFactory(self, endpoint, busState.session, config)) + proxy ! ProcessingProxy.PointMapUpdated(config.points.map(p => (p.getName, p.getUuid)).toMap) + commandActor ! CommandProxy.CommandsUpdated(config.commands) + + val resultBusState = Configured(busState.session, busState.serviceOps, busState.commandSub, busState.inputAddress, configUpdaterChild) + val optStateChange = handleProtocolUpConfigUpdate(protocolState.configs, config.configs) + (resultBusState, optStateChange.getOrElse(protocolState)) + } + } + } + + override protected def onShutdown(state: (BusState, ProtocolState)): Unit = { + logger.debug("Endpoint " + endpoint.getName + " saw shutdown in state " + NestedStateMachine.stateToString(state)) + val (busState, protocolState) = state + protocolState match { + case _: ProtocolActiveState => + logger.info("Endpoint " + endpoint.getName + " is removing protocol due to shutdown") + protocolMgr.remove(endpoint.getUuid) + case _ => + } + busState match { + case s: BindingHoldingBusState => s.cleanup() + case _ => + } + } + + private def sessionDeadGoDown(protocolState: ProtocolState) = { + val now = System.currentTimeMillis() + scheduleMsg(releaseTimeoutMs, DoReleaseTimeoutCheck(now)) + manager ! ServiceSessionDead + (ServicesDown(Some(now)), protocolState) + } + + private def doRegistrationAndSwitch(session: Session, serviceOps: AmqpServiceOperations, holdingLock: Boolean, protocolState: ProtocolState) = { + val now = System.currentTimeMillis() + scheduleMsg(releaseTimeoutMs, DoReleaseTimeoutCheck(now)) + (doRegistration(session, serviceOps, holdingLock = holdingLock, Some(System.currentTimeMillis())), protocolState) + } + + override protected def unhandled(obj: Any, state: (BusState, ProtocolState)): Unit = { + obj match { + case r: Retry => + case x => logger.warn("Saw unhandled event " + obj.getClass.getSimpleName + " in state " + NestedStateMachine.stateToString(state)) + } + } + + private def handleProtocolStateUpdate(update: StackStatusUpdated, configs: Seq[EntityKeyValue], busState: BusState) = { + proxy ! update + import io.greenbus.client.service.proto.FrontEnd.FrontEndConnectionStatus.Status._ + update.state match { + case COMMS_UP => (busState, ProtocolUp(configs)) + case COMMS_DOWN => (busState, ProtocolDown(configs)) + case UNKNOWN => (busState, ProtocolDown(configs)) + case ERROR => (busState, ProtocolError(configs)) + } + } + + private def handleProtocolUpConfigUpdate(oldConfigs: Seq[EntityKeyValue], latestConfigs: Seq[EntityKeyValue]): Option[ProtocolState] = { + if (!configsEqual(oldConfigs, latestConfigs)) { + configurer.evaluate(endpoint, latestConfigs) match { + case None => + logger.info("Updated configuration couldn't be used " + endpoint.getName + " is removing protocol") + protocolMgr.remove(endpoint.getUuid) + commandActor ! CommandProxy.ProtocolUninitialized + proxy ! StackStatusUpdated(FrontEndConnectionStatus.Status.COMMS_DOWN) + Some(ProtocolUninit) + case Some(protocolConfig) => { + logger.info("Updated configuration, " + endpoint.getName + " is re-initializing protocol") + try { + commandActor ! CommandProxy.ProtocolUninitialized + protocolMgr.remove(endpoint.getUuid) + val cmdAcceptor = protocolMgr.add(endpoint, protocolConfig, proxy ! _, proxy ! _) + commandActor ! CommandProxy.ProtocolInitialized(cmdAcceptor) + Some(ProtocolPending(latestConfigs)) + } catch { + case ex: Throwable => + logger.error(s"Error adding protocol master for endpoint ${endpoint.getName}: " + ex) + Some(ProtocolUninit) + } + } + } + } else { + logger.debug(s"Endpoint ${endpoint.getName} saw byte-equivalent config update") + None + } + } + + private def doConfig(session: Session, serviceOps: AmqpServiceOperations, commandSub: ServiceHandlerSubscription, inputAddress: String): ConfigPending = { + val configFut = configureFunc(session, endpoint) + configFut.onSuccess { case resp => self ! ConfigurationSuccess(resp) } + configFut.onFailure { case ex => self ! ConfigurationFailure(ex) } + ConfigPending(session, serviceOps, commandSub, inputAddress, configFut) + } + + private def doRegistration(session: Session, serviceOps: AmqpServiceOperations, holdingLock: Boolean, linkPendingSince: Option[Long]): RegistrationPending = { + val (fut, sub) = registerFunc(session, serviceOps, endpoint, holdingLock) + + fut.onSuccess { case conn => self ! RegistrationSuccess(conn) } + fut.onFailure { case ex => self ! RegistrationFailure(ex) } + + RegistrationPending(session, serviceOps, sub, linkPendingSince) + } +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/frontend/MasterProtocol.scala b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/MasterProtocol.scala new file mode 100644 index 0000000..81ff79a --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/MasterProtocol.scala @@ -0,0 +1,104 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend + +import akka.actor._ +import akka.pattern.{ AskTimeoutException, ask } +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.app.actor.util.TraceMessage +import io.greenbus.client.service.proto.Commands.{ CommandRequest, CommandResult, CommandStatus } +import io.greenbus.client.service.proto.FrontEnd.FrontEndConnectionStatus +import io.greenbus.client.service.proto.Measurements.Measurement +import io.greenbus.client.service.proto.Model.{ Endpoint, EntityKeyValue, ModelUUID } + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.concurrent.duration._ + +trait ProtocolConfigurer[ProtocolConfig] { + def evaluate(endpoint: Endpoint, keyValues: Seq[EntityKeyValue]): Option[ProtocolConfig] + def equivalent(latest: ProtocolConfig, previous: ProtocolConfig): Boolean +} + +case class MeasurementsPublished(wallTime: Long, idMeasurements: Seq[(ModelUUID, Measurement)], namedMeasurements: Seq[(String, Measurement)]) extends TraceMessage + +case class StackStatusUpdated(state: FrontEndConnectionStatus.Status) + +trait MasterProtocol[ProtocolConfig] { + + def add(endpoint: Endpoint, protocolConfig: ProtocolConfig, publish: MeasurementsPublished => Unit, statusUpdate: StackStatusUpdated => Unit): ProtocolCommandAcceptor + + def remove(endpointUuid: ModelUUID) + + def shutdown() + +} + +object ActorProtocolManager { + + case class CommandIssued(commandName: String, request: CommandRequest) + + type ActorProtocolFactory[ProtocolConfig] = (ModelUUID, String, ProtocolConfig, MeasurementsPublished => Unit, StackStatusUpdated => Unit) => Props +} + +class ActorProtocolManager[ProtocolConfig](context: ActorContext, factory: ActorProtocolManager.ActorProtocolFactory[ProtocolConfig]) extends MasterProtocol[ProtocolConfig] with Logging { + import io.greenbus.app.actor.frontend.ActorProtocolManager._ + + private var actors = Map.empty[ModelUUID, ActorRef] + + def add(endpoint: Endpoint, protocolConfig: ProtocolConfig, publish: (MeasurementsPublished) => Unit, statusUpdate: (StackStatusUpdated) => Unit): ProtocolCommandAcceptor = { + actors.get(endpoint.getUuid).foreach(_ ! PoisonPill) + val ref = context.actorOf(factory(endpoint.getUuid, endpoint.getName, protocolConfig, publish, statusUpdate)) + actors += (endpoint.getUuid -> ref) + + new ProtocolCommandAcceptor { + + def issue(commandName: String, request: CommandRequest): Future[CommandResult] = { + val askFut = ref.ask(CommandIssued(commandName, request))(4000.milliseconds) + + askFut.flatMap { + case cmdFut: Future[_] => cmdFut.asInstanceOf[Future[CommandResult]] + case all => + logger.error("Endpoint " + endpoint.getName + " received unknown response from protocol master actor: " + all) + Future.successful(CommandResult.newBuilder().setStatus(CommandStatus.UNDEFINED).build()) + }.recover { + case at: AskTimeoutException => + logger.warn("Endpoint " + endpoint.getName + " could not issue command to protocol master actor") + CommandResult.newBuilder().setStatus(CommandStatus.TIMEOUT).build() + + case ex => + logger.warn("Endpoint " + endpoint.getName + " had error communicating with protocol master actor: " + ex) + CommandResult.newBuilder().setStatus(CommandStatus.UNDEFINED).build() + } + } + } + } + + def remove(endpointUuid: ModelUUID) { + actors.get(endpointUuid).foreach { ref => + ref ! PoisonPill + actors -= endpointUuid + } + } + + def shutdown() { + actors.foreach { case (_, ref) => ref ! PoisonPill } + } + +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/frontend/ProcessingProxy.scala b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/ProcessingProxy.scala new file mode 100644 index 0000000..1192ba5 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/ProcessingProxy.scala @@ -0,0 +1,474 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend + +import akka.actor.{ Actor, ActorRef, Props } +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.app.actor.MessageScheduling +import io.greenbus.app.actor.frontend.ProcessingProxy.DoStackStatusHeartbeat +import io.greenbus.app.actor.util.{ NestedStateMachine, TraceMessage } +import io.greenbus.client.exception.UnauthorizedException +import io.greenbus.client.service.proto.FrontEnd.FrontEndConnectionStatus +import io.greenbus.client.service.proto.FrontEndRequests.FrontEndStatusUpdate +import io.greenbus.client.service.proto.Measurements.{ NamedMeasurementValue, Measurement, MeasurementBatch, PointMeasurementValue } +import io.greenbus.client.service.proto.Model.{ Endpoint, ModelUUID } +import io.greenbus.client.service.{ FrontEndService, MeasurementService } +import io.greenbus.msg.{ Session, SessionUnusableException } + +import scala.collection.JavaConversions._ +import scala.collection.immutable.VectorBuilder + +object ProcessingProxy extends Logging { + + case class LinkUp(session: Session, inputAddress: String) + case class PointMapUpdated(pointMap: Map[String, ModelUUID]) + + case object LinkLapsed + + object MeasLastValueMap { + def apply(): MeasLastValueMap = MeasLastValueMap(Map(), Map()) + } + case class MeasLastValueMap(byUuid: Map[ModelUUID, Measurement], byName: Map[String, Measurement]) { + def update(moreByUuid: Seq[(ModelUUID, Measurement)], moreByName: Seq[(String, Measurement)]): MeasLastValueMap = { + MeasLastValueMap(byUuid ++ moreByUuid, byName ++ moreByName) + } + } + + object MeasQueue { + def apply(): MeasQueue = MeasQueue(Seq(), Seq()) + } + case class MeasQueue(byUuid: Seq[(ModelUUID, Measurement)], byName: Seq[(String, Measurement)]) { + def enqueue(moreByUuid: Seq[(ModelUUID, Measurement)], moreByName: Seq[(String, Measurement)]): MeasQueue = { + MeasQueue(byUuid ++ moreByUuid, byName ++ moreByName) + } + def enqueue(other: MeasQueue): MeasQueue = { + MeasQueue(byUuid ++ other.byUuid, byName ++ other.byName) + } + + def nonEmpty: Boolean = byUuid.nonEmpty || byName.nonEmpty + def isEmpty: Boolean = !nonEmpty + } + + protected case class LinkResources(session: Session, inputAddress: String) + protected case class StatusState(current: FrontEndConnectionStatus.Status, pending: Option[(FrontEndConnectionStatus.Status, Long)], lastStatusSuccess: Option[Long]) + protected case class MeasState( + pointMap: Map[String, ModelUUID], + lastValueMap: MeasLastValueMap, + queued: MeasQueue, + outstanding: MeasQueue, + latestPublishTime: Option[Long], + successPendingSince: Option[Long]) + + protected sealed trait State + protected case class Unlinked( + currentStatus: FrontEndConnectionStatus.Status, + lastValueMap: MeasLastValueMap, + queued: MeasQueue, + pointMap: Map[String, ModelUUID]) extends State + protected case class Linked(resources: LinkResources, statusState: StatusState, measState: MeasState) extends State + + private case class StackStatusSuccess(results: Seq[FrontEndConnectionStatus]) extends TraceMessage + private case class StackStatusFailure(ex: Throwable) + private case class StackStatusRequestExecutionFailure(ex: Throwable) + + private case class MeasPublishSuccess(success: Boolean) extends TraceMessage + private case class MeasPublishFailure(ex: Throwable) + private case class MeasRequestExecutionFailure(ex: Throwable) + + case object DoStackStatusHeartbeat extends TraceMessage + private case object DoMeasPublishRetry + + def props(subject: ActorRef, endpoint: Endpoint, statusHeartbeatPeriodMs: Long, lapsedTimeMs: Long, statusRetryPeriodMs: Long, measRetryPeriodMs: Int, measQueueLimit: Int): Props = { + Props(classOf[ProcessingProxy], subject, endpoint, statusHeartbeatPeriodMs, lapsedTimeMs, statusRetryPeriodMs, measRetryPeriodMs, measQueueLimit) + } + + def toPointMeases(tups: Seq[(ModelUUID, Measurement)]): Seq[PointMeasurementValue] = { + tups.map { case (u, m) => PointMeasurementValue.newBuilder().setPointUuid(u).setValue(m).build() } + } + def toNamedMeases(tups: Seq[(String, Measurement)]): Seq[NamedMeasurementValue] = { + tups.map { case (n, m) => NamedMeasurementValue.newBuilder().setPointName(n).setValue(m).build() } + } + + def maintainQueueLimit[A](all: Seq[(A, Measurement)], queueLimit: Int): Seq[(A, Measurement)] = { + + val b = new VectorBuilder[(A, Measurement)] + var numForKeys: Map[A, Int] = all.groupBy(_._1).mapValues(_.size) + var projectedSize = all.size + + // Drop duplicates until we get to our queue limit + // We can drop as long as there is at least one (the latest) for every key + // Relying on an assumption that there is not unbounded # of keys + all.foreach { v => + if (projectedSize > queueLimit) { + numForKeys.get(v._1) match { + case None => //illegal state + case Some(x) if x < 1 => //illegal state + case Some(1) => + b += v + case Some(num) => + numForKeys = numForKeys.updated(v._1, num - 1) + projectedSize -= 1 + } + } else { + b += v + } + } + + b.result() + } + def maintainQueueLimit(queue: MeasQueue, queueLimit: Int): MeasQueue = { + MeasQueue(maintainQueueLimit(queue.byUuid, queueLimit), maintainQueueLimit(queue.byName, queueLimit)) + } + + def addWallTime(wallTime: Long, m: Measurement): Measurement = { + if (m.hasTime) m else m.toBuilder.setTime(wallTime).build() + } + def addWallTime[A](wallTime: Long, seq: Seq[(A, Measurement)]): Seq[(A, Measurement)] = { + seq.map { case (n, m) => (n, addWallTime(wallTime, m)) } + } + def addWallTime(wallTime: Long, queue: MeasQueue): MeasQueue = { + MeasQueue(addWallTime(wallTime, queue.byUuid), addWallTime(wallTime, queue.byName)) + } + + def simplePrintMeas(m: Measurement): String = { + m.getType match { + case Measurement.Type.BOOL => + m.getBoolVal + ", " + m.getTime + case Measurement.Type.INT => + m.getIntVal + ", " + m.getTime + case Measurement.Type.DOUBLE => + m.getDoubleVal + ", " + m.getTime + case Measurement.Type.STRING => + m.getStringVal + ", " + m.getTime + case _ => "???, " + m.getTime + } + } + + def simpleIdMeas(tup: (ModelUUID, Measurement)) = tup._1.getValue + ", " + simplePrintMeas(tup._2) + + def mergeQueueAndLastValues(queue: MeasQueue, lastValues: MeasLastValueMap): MeasQueue = { + val uuidsInQueue = queue.byUuid.map(_._1).toSet + val namesInQueue = queue.byName.map(_._1).toSet + val missingUuidValues = lastValues.byUuid.filterKeys(k => !uuidsInQueue.contains(k)) + val missingNameValues = lastValues.byName.filterKeys(k => !namesInQueue.contains(k)) + + val updatedUuidValues = missingUuidValues.map(tup => (tup._1, tup._2.toBuilder.setTime(System.currentTimeMillis()).build())).toSeq + val updatedNameValues = missingNameValues.map(tup => (tup._1, tup._2.toBuilder.setTime(System.currentTimeMillis()).build())).toSeq + + queue.enqueue(updatedUuidValues, updatedNameValues) + } +} + +class ProcessingProxy(subject: ActorRef, endpoint: Endpoint, statusHeartbeatPeriodMs: Long, lapsedTimeMs: Long, statusRetryPeriodMs: Long, measRetryPeriodMs: Int, measQueueLimit: Int) extends NestedStateMachine with MessageScheduling with Logging { + import context.dispatcher + import io.greenbus.app.actor.frontend.ProcessingProxy._ + + protected type StateType = State + protected def start: StateType = Unlinked(FrontEndConnectionStatus.Status.COMMS_DOWN, MeasLastValueMap(), MeasQueue(), Map()) + override protected def instanceId: Option[String] = Some(endpoint.getName) + + private val heartbeater = context.actorOf(HeartbeatActor.props(self, statusHeartbeatPeriodMs)) + + protected def machine = { + case state: Unlinked => { + + case LinkUp(session, inputAddress) => { + self ! DoStackStatusHeartbeat + val updates = mergeQueueAndLastValues(state.queued, state.lastValueMap) + val pushOpt = if (updates.nonEmpty) { + logger.debug("Frontend bridge for endpoint " + endpoint.getName + " pushing queued measurements") + pushMeasurements(session, updates, inputAddress) + Some(System.currentTimeMillis()) + } else { + None + } + Linked(LinkResources(session, inputAddress), + StatusState(state.currentStatus, None, None), + MeasState(state.pointMap, state.lastValueMap, queued = MeasQueue(), outstanding = updates, pushOpt, pushOpt)) + } + + case MeasurementsPublished(wallTime, idMeasurements, namedMeasurements) => { + val measMap = state.lastValueMap.update(idMeasurements, namedMeasurements) + val latestBatch = MeasQueue(idMeasurements, namedMeasurements) + + logger.trace("Frontend bridge for endpoint " + endpoint.getName + " queueing measurements for when link returns: " + (idMeasurements.size + namedMeasurements.size)) + val updatedQueue = maintainQueueLimit(state.queued.enqueue(addWallTime(wallTime, latestBatch)), measQueueLimit) + state.copy(queued = updatedQueue, lastValueMap = measMap) + } + + case StackStatusUpdated(status) => { + state.copy(currentStatus = status) + } + + case PointMapUpdated(map) => { + state.copy(pointMap = map) + } + case DoStackStatusHeartbeat => { + state + } + } + + case state: Linked => { + + case PointMapUpdated(map) => { + state.copy(measState = state.measState.copy(pointMap = map)) + } + + case MeasurementsPublished(wallTime, idMeasurements, namedMeasurements) => { + val measMap = state.measState.lastValueMap.update(idMeasurements, namedMeasurements) + val latestBatch = MeasQueue(idMeasurements, namedMeasurements) + + val queued = state.measState.queued enqueue addWallTime(wallTime, latestBatch) + if (state.measState.outstanding.isEmpty) { + logger.trace("Frontend bridge for endpoint " + endpoint.getName + " pushing measurements immediately") + pushMeasurements(state.resources.session, queued, state.resources.inputAddress) + state.copy(measState = state.measState.copy(outstanding = queued, queued = MeasQueue(), lastValueMap = measMap, latestPublishTime = Some(System.currentTimeMillis()), successPendingSince = Some(System.currentTimeMillis()))) + } else { + logger.trace("Frontend bridge for endpoint " + endpoint.getName + " queueing measurements for when outstanding publish finishes") + state.copy(measState = state.measState.copy(queued = queued, lastValueMap = measMap)) + } + } + + case MeasPublishSuccess(success) => { + success match { + case false => + logger.warn("Frontend bridge for endpoint " + endpoint.getName + " had got false on publish response") + state.measState.latestPublishTime match { + case None => scheduleMsg(measRetryPeriodMs, DoMeasPublishRetry) + case Some(prev) => scheduleRelativeToPrevious(prev, measRetryPeriodMs, DoMeasPublishRetry) + } + state.copy(measState = state.measState.copy(queued = state.measState.outstanding.enqueue(state.measState.queued), outstanding = MeasQueue())) + case true => + if (state.measState.queued.nonEmpty) { + logger.trace("Frontend bridge for endpoint " + endpoint.getName + " had publish success, pushing queued measurements") + pushMeasurements(state.resources.session, state.measState.queued, state.resources.inputAddress) + state.copy(measState = state.measState.copy(outstanding = state.measState.queued, queued = MeasQueue(), latestPublishTime = Some(System.currentTimeMillis()), successPendingSince = Some(System.currentTimeMillis()))) + } else { + logger.trace("Frontend bridge for endpoint " + endpoint.getName + " had publish success") + state.copy(measState = state.measState.copy(queued = MeasQueue(), outstanding = MeasQueue(), successPendingSince = None)) + } + } + } + + case MeasPublishFailure(ex) => { + logger.warn("Frontend bridge for endpoint " + endpoint.getName + " had error publishing measurements") + val requeued = state.measState.outstanding enqueue state.measState.queued + + ex match { + case ex: SessionUnusableException => + subject ! ServiceSessionDead + Unlinked(state.statusState.current, state.measState.lastValueMap, requeued, state.measState.pointMap) + case ex: UnauthorizedException => + subject ! ServiceSessionDead + Unlinked(state.statusState.current, state.measState.lastValueMap, requeued, state.measState.pointMap) + case ex: Throwable => { + if (state.measState.successPendingSince.exists(last => System.currentTimeMillis() - last > lapsedTimeMs)) { + logger.info(s"Link for endpoint ${endpoint.getName} lapsed due to measurement publishing timeouts") + logger.trace(s"Dropping link for endpoint ${endpoint.getName} with the current measurement state: " + requeued) + subject ! LinkLapsed + Unlinked(state.statusState.current, state.measState.lastValueMap, requeued, state.measState.pointMap) + } else { + state.measState.latestPublishTime match { + case None => scheduleMsg(measRetryPeriodMs, DoMeasPublishRetry) + case Some(prev) => scheduleRelativeToPrevious(prev, measRetryPeriodMs, DoMeasPublishRetry) + } + state.copy(measState = state.measState.copy(queued = requeued, outstanding = MeasQueue())) + } + } + } + } + + case MeasRequestExecutionFailure(ex) => { + logger.warn("Frontend bridge for endpoint " + endpoint.getName + " had request execution error publishing measurements") + val requeued = state.measState.outstanding enqueue state.measState.queued + subject ! ServiceSessionDead + Unlinked(state.statusState.current, state.measState.lastValueMap, requeued, state.measState.pointMap) + } + + case DoMeasPublishRetry => { + if (state.measState.outstanding.isEmpty && state.measState.queued.nonEmpty) { + pushMeasurements(state.resources.session, state.measState.queued, state.resources.inputAddress) + state.copy(measState = state.measState.copy(outstanding = state.measState.queued, queued = MeasQueue(), latestPublishTime = Some(System.currentTimeMillis()))) + } else { + state + } + } + + case StackStatusUpdated(status) => { + logger.debug("Frontend bridge for endpoint " + endpoint.getName + " saw comms status update: " + status) + + state.statusState.pending match { + case None => + pushStatusUpdate(state.resources.session, endpoint, status, state.resources.inputAddress) + state.copy(statusState = state.statusState.copy(current = status, pending = Some(status, System.currentTimeMillis()))) + case Some(_) => + state.copy(statusState = state.statusState.copy(current = status)) + } + } + + case StackStatusSuccess(results) => { + logger.trace("Frontend bridge for endpoint " + endpoint.getName + " successfully published stack status heartbeat") + + val now = System.currentTimeMillis() + state.statusState.pending match { + case None => + logger.warn(s"Got stack status success for endpoint ${endpoint.getName} with no pending status") + state.copy(statusState = state.statusState.copy(lastStatusSuccess = Some(now))) + case Some((previousStatus, sentTime)) => { + if (state.statusState.current != previousStatus) { + pushStatusUpdate(state.resources.session, endpoint, state.statusState.current, state.resources.inputAddress) + state.copy(statusState = state.statusState.copy(pending = Some(state.statusState.current, now), lastStatusSuccess = Some(now))) + } else { + state.copy(statusState = state.statusState.copy(pending = None, lastStatusSuccess = Some(now))) + } + } + } + } + + // TODO: handle case where outstanding meas publish succeeds, thus eventually causing duplicate measurements + case StackStatusFailure(ex) => { + logger.warn("Frontend bridge for endpoint " + endpoint.getName + " had error publishing stack status heartbeat") + ex match { + case ex: SessionUnusableException => + subject ! ServiceSessionDead + Unlinked(state.statusState.current, state.measState.lastValueMap, state.measState.outstanding enqueue state.measState.queued, state.measState.pointMap) + case ex: UnauthorizedException => + subject ! ServiceSessionDead + Unlinked(state.statusState.current, state.measState.lastValueMap, state.measState.outstanding enqueue state.measState.queued, state.measState.pointMap) + case ex: Throwable => { + if (state.statusState.lastStatusSuccess.exists(last => System.currentTimeMillis() - last > lapsedTimeMs)) { + logger.info(s"Link for endpoint ${endpoint.getName} lapsed due to heartbeat publishing timeouts") + logger.trace(s"Dropping link for endpoint ${endpoint.getName} with the current measurement state: " + (state.measState.outstanding enqueue state.measState.queued)) + subject ! LinkLapsed + Unlinked(state.statusState.current, state.measState.lastValueMap, state.measState.outstanding enqueue state.measState.queued, state.measState.pointMap) + } else { + state.statusState.pending match { + case None => + logger.warn(s"Got stack status failure for endpoint ${endpoint.getName} with no pending status") + state + case Some((previousStatus, sentTime)) => { + scheduleRelativeToPrevious(sentTime, statusRetryPeriodMs, DoStackStatusHeartbeat) + state.copy(statusState = state.statusState.copy(pending = None)) + } + } + } + } + } + } + + case StackStatusRequestExecutionFailure(ex) => { + logger.warn("Frontend bridge for endpoint " + endpoint.getName + " had request execution error publishing heartbeat") + val requeued = state.measState.outstanding enqueue state.measState.queued + subject ! ServiceSessionDead + Unlinked(state.statusState.current, state.measState.lastValueMap, requeued, state.measState.pointMap) + } + + case DoStackStatusHeartbeat => { + state.statusState.pending match { + case None => + pushStatusUpdate(state.resources.session, endpoint, state.statusState.current, state.resources.inputAddress) + state.copy(statusState = state.statusState.copy(pending = Some(state.statusState.current, System.currentTimeMillis()))) + case Some(_) => state + } + } + } + } + + private def resolveMeases(namedMeasurements: Seq[(String, Measurement)], pointMap: Map[String, ModelUUID]): Seq[(ModelUUID, Measurement)] = { + namedMeasurements.flatMap { + case (n, m) => pointMap.get(n) match { + case None => + logger.warn("Unrecognized point name for measurement: " + n) + None + case Some(uuid) => Some((uuid, m)) + } + } + } + + private def scheduleRelativeToPrevious(previousTime: Long, offset: Long, msg: AnyRef): Unit = { + val shiftedFromSentTime = (previousTime + offset) - System.currentTimeMillis() + if (shiftedFromSentTime > 0) { + scheduleMsg(shiftedFromSentTime, msg) + } else { + self ! msg + } + } + + private def pushMeasurements(session: Session, queued: MeasQueue, address: String): Unit = { + + val pointMeasurements = toPointMeases(queued.byUuid) + val namedMeasurements = toNamedMeases(queued.byName) + + logger.trace("Frontend bridge for endpoint " + endpoint.getName + " saw measurement updates: " + (pointMeasurements.size + namedMeasurements.size)) + val measClient = MeasurementService.client(session) + + val batch = MeasurementBatch.newBuilder() + .addAllPointMeasurements(pointMeasurements) + .addAllNamedMeasurements(namedMeasurements) + .build() + + try { + val postFut = measClient.postMeasurements(batch, address) + postFut.onSuccess { case result => self ! MeasPublishSuccess(result) } + postFut.onFailure { case ex => self ! MeasPublishFailure(ex) } + } catch { + case ex: Throwable => + self ! MeasRequestExecutionFailure(ex) + } + } + + private def pushStatusUpdate(session: Session, endpoint: Endpoint, currentStatus: FrontEndConnectionStatus.Status, address: String): Unit = { + logger.trace(s"Frontend bridge for endpoint ${endpoint.getName} pushing status: $currentStatus") + val frontEndClient = FrontEndService.client(session) + + val update = FrontEndStatusUpdate.newBuilder() + .setEndpointUuid(endpoint.getUuid) + .setStatus(currentStatus) + .build() + + try { + val putFut = frontEndClient.putFrontEndConnectionStatuses(List(update), address) + + putFut.onSuccess { case results => self ! StackStatusSuccess(results) } + putFut.onFailure { case ex: Throwable => self ! StackStatusFailure(ex) } + } catch { + case ex: Throwable => + self ! StackStatusRequestExecutionFailure(ex) + } + } + +} + +object HeartbeatActor { + case object Heartbeat + + def props(subject: ActorRef, intervalMs: Long): Props = { + Props(classOf[HeartbeatActor], subject, intervalMs) + } +} +class HeartbeatActor(subject: ActorRef, intervalMs: Long) extends Actor with MessageScheduling with Logging { + import HeartbeatActor._ + + self ! Heartbeat + def receive = { + case Heartbeat => + subject ! DoStackStatusHeartbeat + scheduleMsg(intervalMs, Heartbeat) + } +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/frontend/ProtocolCommandAcceptor.scala b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/ProtocolCommandAcceptor.scala new file mode 100644 index 0000000..123f090 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/ProtocolCommandAcceptor.scala @@ -0,0 +1,26 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend + +import io.greenbus.client.service.proto.Commands +import scala.concurrent.Future + +trait ProtocolCommandAcceptor { + def issue(commandName: String, request: Commands.CommandRequest): Future[Commands.CommandResult] +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/frontend/SimpleCommandProxy.scala b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/SimpleCommandProxy.scala new file mode 100644 index 0000000..0656ee9 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/SimpleCommandProxy.scala @@ -0,0 +1,91 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend + +import akka.actor.{ Actor, ActorRef, Props, _ } +import akka.pattern.ask +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.SubscriptionBinding +import io.greenbus.app.actor.frontend.CommandProxy.CommandSubscriptionRetrieved +import io.greenbus.client.service.proto.Commands.{ CommandRequest, CommandResult, CommandStatus } +import io.greenbus.client.service.proto.Model.Endpoint + +import scala.concurrent.Future +import scala.concurrent.duration._ + +object SimpleCommandProxy { + + case class ProtocolInitialized(subject: ActorRef) + case object ProtocolUninitialized + + private case class ReceivedCommandIssued(req: CommandRequest, response: CommandResult => Unit) + + def props(endpoint: Endpoint): Props = Props(classOf[SimpleCommandProxy], endpoint) +} + +class SimpleCommandProxy(endpoint: Endpoint) extends Actor with Logging { + import io.greenbus.app.actor.frontend.SimpleCommandProxy._ + import context.dispatcher + + private var commandSub = Option.empty[SubscriptionBinding] + private var subjectOpt = Option.empty[ActorRef] + + def receive = { + case ReceivedCommandIssued(req, responseHandler) => { + subjectOpt match { + case None => + logger.debug(s"Endpoint ${endpoint.getName} saw command request while protocol uninitialized: " + req.getCommandUuid.getValue) + responseHandler(CommandResult.newBuilder().setStatus(CommandStatus.TIMEOUT).build()) + case Some(subject) => + logger.debug(s"Endpoint ${endpoint.getName} handling command request") + + val askFut = subject.ask(req)(5000.milliseconds) + + val resultFut = askFut.flatMap { + case fut: Future[_] => fut.asInstanceOf[Future[CommandResult]] + case all => + logger.error("Endpoint " + endpoint.getName + " received unknown response from protocol master actor: " + all) + Future.successful(CommandResult.newBuilder().setStatus(CommandStatus.UNDEFINED).build()) + } + + resultFut.onSuccess { + case result: CommandResult => + logger.debug(s"Endpoint ${endpoint.getName} saw command result: " + result.getStatus) + responseHandler(result) + } + resultFut.onFailure { + case ex: Throwable => + logger.error(s"Command acceptor for endpoint ${endpoint.getName} failed: " + ex) + responseHandler(CommandResult.newBuilder().setStatus(CommandStatus.UNDEFINED).build()) + } + } + } + + case CommandSubscriptionRetrieved(sub) => { + commandSub.foreach(_.cancel()) + commandSub = Some(sub) + val cmdDelegate = new DelegatingCommandHandler((req, resultAccept) => self ! ReceivedCommandIssued(req, resultAccept)) + sub.start(cmdDelegate) + } + + case ProtocolInitialized(subject) => subjectOpt = Some(subject) + + case ProtocolUninitialized => subjectOpt = None + } +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/frontend/json/JsonFrontendConfiguration.scala b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/json/JsonFrontendConfiguration.scala new file mode 100644 index 0000000..330a037 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/json/JsonFrontendConfiguration.scala @@ -0,0 +1,42 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend.json + +import io.greenbus.app.actor.json.JsonAmqpConfig + +object JsonFrontendConfiguration { + import play.api.libs.json._ + implicit val writer = Json.writes[JsonFrontendConfiguration] + implicit val reader = Json.reads[JsonFrontendConfiguration] + + def load(input: Array[Byte]): Option[JsonFrontendConfiguration] = { + + val jsObj = Json.parse(input) + + Json.fromJson(jsObj)(JsonFrontendConfiguration.reader).asOpt + } +} + +case class JsonFrontendConfiguration( + amqpConfig: Option[JsonAmqpConfig], + userConfigPath: Option[String], + endpointWhitelist: Option[Seq[String]], + nodeId: Option[String], + registrationConfig: Option[JsonFrontendRegistrationConfig]) + diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/frontend/json/JsonFrontendRegistrationConfig.scala b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/json/JsonFrontendRegistrationConfig.scala new file mode 100644 index 0000000..e3b5c6b --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/frontend/json/JsonFrontendRegistrationConfig.scala @@ -0,0 +1,59 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend.json + +import io.greenbus.app.actor.frontend.FrontendRegistrationConfig + +case class JsonFrontendRegistrationConfig( + loginRetryMs: Option[Long], + registrationRetryMs: Option[Long], + releaseTimeoutMs: Option[Long], + statusHeartbeatPeriodMs: Option[Long], + lapsedTimeMs: Option[Long], + statusRetryPeriodMs: Option[Long], + measRetryPeriodMs: Option[Int], + measQueueLimit: Option[Int], + configRequestRetryMs: Option[Int]) + +object JsonFrontendRegistrationConfig { + import play.api.libs.json._ + implicit val writer = Json.writes[JsonFrontendRegistrationConfig] + implicit val reader = Json.reads[JsonFrontendRegistrationConfig] + + def load(input: Array[Byte]): Option[JsonFrontendConfiguration] = { + + val jsObj = Json.parse(input) + + Json.fromJson(jsObj)(JsonFrontendConfiguration.reader).asOpt + } + + def read(json: JsonFrontendRegistrationConfig): FrontendRegistrationConfig = { + val default = FrontendRegistrationConfig.defaults + FrontendRegistrationConfig( + loginRetryMs = json.loginRetryMs.getOrElse(default.loginRetryMs), + registrationRetryMs = json.registrationRetryMs.getOrElse(default.registrationRetryMs), + releaseTimeoutMs = json.releaseTimeoutMs.getOrElse(default.releaseTimeoutMs), + statusHeartbeatPeriodMs = json.statusHeartbeatPeriodMs.getOrElse(default.statusHeartbeatPeriodMs), + lapsedTimeMs = json.lapsedTimeMs.getOrElse(default.lapsedTimeMs), + statusRetryPeriodMs = json.statusRetryPeriodMs.getOrElse(default.statusRetryPeriodMs), + measRetryPeriodMs = json.measRetryPeriodMs.getOrElse(default.measRetryPeriodMs), + measQueueLimit = json.measQueueLimit.getOrElse(default.measQueueLimit), + configRequestRetryMs = json.configRequestRetryMs.getOrElse(default.configRequestRetryMs)) + } +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/json/JsonAmqpConfig.scala b/app-framework/src/main/scala/io/greenbus/app/actor/json/JsonAmqpConfig.scala new file mode 100644 index 0000000..8858c20 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/json/JsonAmqpConfig.scala @@ -0,0 +1,50 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.json + +import io.greenbus.app.actor.AmqpConnectionConfig + +case class JsonAmqpConfig(amqpConfigFileList: Option[IndexedSeq[String]], + failureLimit: Option[Int], + retryDelayMs: Option[Long], + connectionTimeoutMs: Option[Long]) + +object JsonAmqpConfig { + import play.api.libs.json._ + implicit val writer = Json.writes[JsonAmqpConfig] + implicit val reader = Json.reads[JsonAmqpConfig] + + def load(input: Array[Byte]): Option[JsonAmqpConfig] = { + + val jsObj = Json.parse(input) + + Json.fromJson(jsObj)(JsonAmqpConfig.reader).asOpt + } + + def read(json: JsonAmqpConfig, defaultPaths: Seq[String]): AmqpConnectionConfig = { + + val default = AmqpConnectionConfig.default(defaultPaths) + + AmqpConnectionConfig( + json.amqpConfigFileList.getOrElse(default.amqpConfigFileList), + failureLimit = json.failureLimit.getOrElse(default.failureLimit), + retryDelayMs = json.retryDelayMs.getOrElse(default.retryDelayMs), + connectionTimeoutMs = json.connectionTimeoutMs.getOrElse(default.connectionTimeoutMs)) + } +} diff --git a/app-framework/src/main/scala/io/greenbus/app/actor/util/StateMachine.scala b/app-framework/src/main/scala/io/greenbus/app/actor/util/StateMachine.scala new file mode 100644 index 0000000..1dd44c1 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/app/actor/util/StateMachine.scala @@ -0,0 +1,113 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.util + +import akka.actor.Actor +import com.typesafe.scalalogging.slf4j.Logging + +trait StateMachine extends Actor { + protected type StateType + protected def start: StateType + private var current: StateType = start + + protected def machine: PartialFunction[(Any, StateType), StateType] + + protected def unhandled(obj: Any, state: StateType) + + def receive = { + case obj => + if (machine.isDefinedAt((obj, current))) { + current = machine.apply((obj, current)) + } else { + unhandled(obj, current) + } + } + +} + +trait TraceMessage + +object NestedStateMachine { + + def stateToString(state: Any): String = { + def prod(pr: Product) = pr.productIterator.map(_.getClass.getSimpleName).mkString("(", ", ", ")") + state match { + case pr: Tuple2[_, _] => prod(pr) + case pr: Tuple3[_, _, _] => prod(pr) + case pr: Tuple4[_, _, _, _] => prod(pr) + case pr: Tuple5[_, _, _, _, _] => prod(pr) + case o => o.getClass.getSimpleName + } + } +} +trait NestedStateMachine extends Actor with Logging { + import NestedStateMachine._ + + protected def instanceId: Option[String] = None + private def instancePrefix: String = instanceId.map(_ + ": ").getOrElse("") + protected type StateType + protected def start: StateType + private var current: StateType = start + + protected def machine: PartialFunction[StateType, PartialFunction[Any, StateType]] + + protected def unhandled(obj: Any, state: StateType): Unit = { + logger.warn(instancePrefix + "Saw unhandled event " + obj.getClass.getSimpleName + " in state " + stateToString(state)) + } + + final override def postStop(): Unit = { + onShutdown(current) + } + + protected def onShutdown(state: StateType): Unit = { + logger.debug(instancePrefix + s"Shut down in state ${stateToString(current)} ($instanceId)") + } + + def receive = { + case obj => + obj match { + case _: TraceMessage => if (logger.underlying.isTraceEnabled) { + logger.trace(instancePrefix + s"Event ${obj.getClass.getSimpleName} in state ${stateToString(current)}") + } + case _ => if (logger.underlying.isDebugEnabled) { + logger.debug(instancePrefix + s"Event ${obj.getClass.getSimpleName} in state ${stateToString(current)}") + } + } + if (machine.isDefinedAt(current)) { + val handler = machine.apply(current) + if (handler.isDefinedAt(obj)) { + val result = handler.apply(obj) + + if (logger.underlying.isDebugEnabled) { + val resultStr = stateToString(result) + val currentStr = stateToString(current) + if (resultStr != currentStr) { + logger.debug(instancePrefix + s"State change $currentStr to $resultStr") + } + } + current = result + } else { + unhandled(obj, current) + } + } else { + unhandled(obj, current) + } + } + +} diff --git a/app-framework/src/main/scala/io/greenbus/japi/frontend/BusDrivenFrontendProtocolManager.java b/app-framework/src/main/scala/io/greenbus/japi/frontend/BusDrivenFrontendProtocolManager.java new file mode 100644 index 0000000..511a72d --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/japi/frontend/BusDrivenFrontendProtocolManager.java @@ -0,0 +1,71 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.japi.frontend; + +import io.greenbus.app.actor.AmqpConnectionConfig; +import io.greenbus.app.actor.EndpointCollectionStrategy; +import io.greenbus.app.actor.frontend.FrontendRegistrationConfig; +import io.greenbus.japi.frontend.impl.BusDrivenFrontendProtocolManagerImpl; + +// Wrapped to keep as many Java-accessible classes in JavaDoc as possible + +/** + * Hosts a set of front-end connections. Is responsible for establishing the connection + * to the services, requesting and subscribing to the relevant parts of the system model, + * and notifying protocol implementations of Endpoints they should provide service for. + */ +public class BusDrivenFrontendProtocolManager { + + private final BusDrivenFrontendProtocolManagerImpl impl; + + /** + * @param processName Name of the process for logging purposes. + * @param amqpConfig Configuration for AMQP, with failover. + * @param userConfigPath Path to find the user configuration. + * @param registrationConfig Configuration parameters for front-end registration process. + * @param nodeId Node ID used to re-register front-end immediately on restart. Set to random UUID as a default. + * @param endpointStrategy Strategy for the subset of Endpoints the protocol implementation should provide services for. + * @param factory Factory for bus-driven protocols. + */ + public BusDrivenFrontendProtocolManager( + String processName, + AmqpConnectionConfig amqpConfig, + String userConfigPath, + FrontendRegistrationConfig registrationConfig, + String nodeId, + EndpointCollectionStrategy endpointStrategy, + BusDrivenProtocolFactory factory) { + + this.impl = new BusDrivenFrontendProtocolManagerImpl(processName, amqpConfig, userConfigPath, registrationConfig, nodeId, endpointStrategy, factory); + } + + /** + * + */ + public void start() { + impl.start(); + } + + /** + * Notifies all protocol implementations they should stop, and closes the connection to the services. + */ + public void shutdown() { + impl.shutdown(); + } +} diff --git a/app-framework/src/main/scala/io/greenbus/japi/frontend/BusDrivenProtocol.java b/app-framework/src/main/scala/io/greenbus/japi/frontend/BusDrivenProtocol.java new file mode 100644 index 0000000..39c8e1c --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/japi/frontend/BusDrivenProtocol.java @@ -0,0 +1,41 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.japi.frontend; + +import com.google.common.util.concurrent.SettableFuture; +import io.greenbus.client.service.proto.Commands; + +/** + * Represents the protocol framework library's interface to a bus-driven protocol implementation. + */ +public interface BusDrivenProtocol { + + /** + * Callback for the protocol implementation to handle command requests. + * + * @param commandRequest Command request to be handled. + * @param promise Promise of a CommandResult, used to asynchronously notify the library that the request has been handled. + */ + void handleCommandRequest(Commands.CommandRequest commandRequest, SettableFuture promise); + + /** + * Callback for the protocol implementation to clean up when connection to the bus has been lost. + */ + void onClose(); +} diff --git a/app-framework/src/main/scala/io/greenbus/japi/frontend/BusDrivenProtocolFactory.java b/app-framework/src/main/scala/io/greenbus/japi/frontend/BusDrivenProtocolFactory.java new file mode 100644 index 0000000..95ccff6 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/japi/frontend/BusDrivenProtocolFactory.java @@ -0,0 +1,38 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.japi.frontend; + +import io.greenbus.client.service.proto.Model; +import io.greenbus.msg.japi.Session; + +/** + * Factory handed to protocol registration library to instantiate protocol implementations upon registration. + */ +public interface BusDrivenProtocolFactory { + + /** + * Used to instantiate protocol implementation. + * + * @param endpoint Endpoint the protocol implementation is serving. + * @param session Logged-in session from the protocol registration library. + * @param protocolUpdater Interface for publishg measurements and updating frontend communication status. + * @return Handle for protocol management. + */ + BusDrivenProtocol initialize(Model.Endpoint endpoint, Session session, ProtocolUpdater protocolUpdater); +} diff --git a/app-framework/src/main/scala/io/greenbus/japi/frontend/EndpointCollectionStrategyFactory.java b/app-framework/src/main/scala/io/greenbus/japi/frontend/EndpointCollectionStrategyFactory.java new file mode 100644 index 0000000..6f14978 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/japi/frontend/EndpointCollectionStrategyFactory.java @@ -0,0 +1,76 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.japi.frontend; + +import io.greenbus.app.actor.AllEndpointsStrategy; +import io.greenbus.app.actor.EndpointCollectionStrategy; +import io.greenbus.app.actor.ProtocolsEndpointStrategy; +import scala.Option; +import scala.collection.JavaConversions; + +import java.util.List; + +/** + * Factory for strategies that determine what subset of Endpoints the management library retrieves + * configuration for. + */ +public class EndpointCollectionStrategyFactory { + + private EndpointCollectionStrategyFactory() { + } + + /** + * Strategy for all Endpoints in the system. + * + * @param endpointNames Names of Endpoints that are whitelisted. Null to allow all Endpoints. + * @return Endpoint collection strategy + */ + public static EndpointCollectionStrategy anyAndAll(List endpointNames) { + return new AllEndpointsStrategy(convertToOptionalSet(endpointNames)); + } + + /** + * Strategy for only Endpoints with one of a set of specified protocols. + * + * @param protocolNames Names of Endpoint protocols + * @param endpointNames Names of Endpoints that are whitelisted. Null to allow all Endpoints. + * @return Endpoint collection strategy + */ + public static EndpointCollectionStrategy protocolStrategy(List protocolNames, List endpointNames) { + scala.collection.Iterable stringIterable = JavaConversions.asScalaIterable(protocolNames); + scala.collection.immutable.Set set = stringIterable.toSet(); + return new ProtocolsEndpointStrategy(set, convertToOptionalSet(endpointNames)); + } + + + private static scala.Option> convertToOptionalSet(List objs) { + + if (objs != null) { + + scala.collection.Iterable stringIterable = JavaConversions.asScalaIterable(objs); + scala.collection.immutable.Set set = stringIterable.toSet(); + return Option.apply(set); + + } else { + + scala.collection.immutable.Set nullvar = null; + return Option.apply(nullvar); + } + } +} diff --git a/app-framework/src/main/scala/io/greenbus/japi/frontend/FrontendConfiguration.java b/app-framework/src/main/scala/io/greenbus/japi/frontend/FrontendConfiguration.java new file mode 100644 index 0000000..e9be939 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/japi/frontend/FrontendConfiguration.java @@ -0,0 +1,43 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.japi.frontend; + +import io.greenbus.app.actor.frontend.FepConfigLoader; +import io.greenbus.app.actor.frontend.FrontendProcessConfig; +import scala.collection.JavaConversions; + +import java.util.Set; + +/** + * Helper class for loading frontend process configuration from JSON, with fallbacks and default settings. + */ +public class FrontendConfiguration { + + /** + * + * @param jsonConfigPath Path of frontend JSON configuration format. + * @param defaultAmqpConfigPath Path of an AMQP configuration file if defaults are to be used. + * @param defaultUserConfigPath Path of a user configuration file if defaults are to be used. + * @param protocols Set of protocols the protocol implementation provides for. + * @return Configuration structure for protocol frontends. + */ + public static FrontendProcessConfig load(String jsonConfigPath, String defaultAmqpConfigPath, String defaultUserConfigPath, Set protocols) { + return FepConfigLoader.loadConfig(jsonConfigPath, defaultAmqpConfigPath, defaultUserConfigPath, JavaConversions.asScalaSet(protocols).toSet()); + } +} diff --git a/app-framework/src/main/scala/io/greenbus/japi/frontend/FrontendProtocolManager.java b/app-framework/src/main/scala/io/greenbus/japi/frontend/FrontendProtocolManager.java new file mode 100644 index 0000000..fbd9060 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/japi/frontend/FrontendProtocolManager.java @@ -0,0 +1,71 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.japi.frontend; + +import io.greenbus.app.actor.EndpointCollectionStrategy; +import io.greenbus.japi.frontend.impl.FrontendProtocolManagerImpl; + +import java.util.List; + +// Wrapped to keep as many Java-accessible classes in JavaDoc as possible + +/** + * Hosts a set of front-end connections. Is responsible for establishing the connection + * to the services, requesting and subscribing to the relevant parts of the system model, + * and notifying protocol implementations of Endpoints they should provide service for. + * + * @param + */ +public class FrontendProtocolManager { + + private final FrontendProtocolManagerImpl impl; + + /** + * + * @param protocol The protocol implementation. + * @param protocolConfigurer Used by the library to determine whether a valid configuration exists. + * @param endpointStrategy Strategy for the subset of Endpoints the protocol implementation should provide services for. + * @param amqpConfigPath Path to find the AMQP connection configuration. + * @param userConfigPath Path to find the user configuration. + */ + public FrontendProtocolManager( + MasterProtocol protocol, + ProtocolConfigurer protocolConfigurer, + List configKeys, + EndpointCollectionStrategy endpointStrategy, + String amqpConfigPath, + String userConfigPath) { + + this.impl = new FrontendProtocolManagerImpl(protocol, protocolConfigurer, configKeys, endpointStrategy, amqpConfigPath, userConfigPath); + } + + /** + * + */ + public void start() { + impl.start(); + } + + /** + * Notifies all protocol implementations they should stop, and closes the connection to the services. + */ + public void shutdown() { + impl.shutdown(); + } +} diff --git a/app-framework/src/main/scala/io/greenbus/japi/frontend/MasterProtocol.java b/app-framework/src/main/scala/io/greenbus/japi/frontend/MasterProtocol.java new file mode 100644 index 0000000..3d8275a --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/japi/frontend/MasterProtocol.java @@ -0,0 +1,55 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.japi.frontend; + +import io.greenbus.client.service.proto.Model; + +/** + * + * Interface for a protocol manager. + * + * The underlying library uses add() and remove() callbacks to notify the user that service for + * Endpoints is required. + * + * @param Type of the assembled configuration for the protocol. + */ +public interface MasterProtocol { + + /** + * Called by the library to notify user code that service is requested for a particular Endpoint. + * + * @param endpoint Endpoint the front-end connection is for. + * @param protocolConfig The assembled configuration for the protocol. + * @param updater An interface for the front-end to notify the library of measurement and status updates. + * @return An interface for the underlying library to notify the front-end of command requests. + */ + ProtocolCommandAcceptor add(Model.Endpoint endpoint, ProtocolConfig protocolConfig, ProtocolUpdater updater); + + /** + * Called by the library to notify user code that service should no longer be provided for a particular Endpoint. + * + * @param endpointUuid UUID of the Endpoint service should no longer be provided for. + */ + void remove(Model.ModelUUID endpointUuid); + + /** + * Called when the entire system is shutting down. All front-end connections should be closed and cleaned up. + */ + void shutdown(); +} diff --git a/app-framework/src/main/scala/io/greenbus/japi/frontend/NamedMeasurement.java b/app-framework/src/main/scala/io/greenbus/japi/frontend/NamedMeasurement.java new file mode 100644 index 0000000..5d9c202 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/japi/frontend/NamedMeasurement.java @@ -0,0 +1,52 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.japi.frontend; + +import io.greenbus.client.service.proto.Measurements; + +/** + * Structure that holds a measurement value and the name of the Point it applies to. + */ +public class NamedMeasurement { + final private String name; + final private Measurements.Measurement value; + + public NamedMeasurement(String name, Measurements.Measurement value) { + this.name = name; + this.value = value; + } + + /** + * Gets the name of the Point associated with the Measurement. + * + * @return The name of the Point. + */ + public String getName() { + return name; + } + + /** + * Gets the Measurement value. + * + * @return The Measurement value. + */ + public Measurements.Measurement getValue() { + return value; + } +} diff --git a/app-framework/src/main/scala/io/greenbus/japi/frontend/ProtocolCommandAcceptor.java b/app-framework/src/main/scala/io/greenbus/japi/frontend/ProtocolCommandAcceptor.java new file mode 100644 index 0000000..2a9fda7 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/japi/frontend/ProtocolCommandAcceptor.java @@ -0,0 +1,37 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.japi.frontend; + +import com.google.common.util.concurrent.ListenableFuture; +import io.greenbus.client.service.proto.Commands; + +/** + * Handle for the manager to notify protocol implementations of command requests. + */ +public interface ProtocolCommandAcceptor { + + /** + * Notifies the protocol implementation of command requests. + * + * @param commandName Name of the Command the request applies to. + * @param request The CommandRequest object the protocol implementation should handle. + * @return A future containing the result of handling the command request. + */ + ListenableFuture issue(String commandName, Commands.CommandRequest request); +} diff --git a/app-framework/src/main/scala/io/greenbus/japi/frontend/ProtocolConfigurer.java b/app-framework/src/main/scala/io/greenbus/japi/frontend/ProtocolConfigurer.java new file mode 100644 index 0000000..b183219 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/japi/frontend/ProtocolConfigurer.java @@ -0,0 +1,53 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.japi.frontend; + +import io.greenbus.client.service.proto.Model; + +import java.util.List; + +/** + * Used by the library to manage the configuration for an Endpoint's front-end connection. + * + * @param Type of the assembled configuration for the protocol. + */ +public interface ProtocolConfigurer { + + /** + * Provided by a protocol implementation to transform the list of ConfigFiles to the + * protocol-specific form of configuration. + * + * Should return null if the configuration is missing or can't be parsed. + * + * @param endpoint Endpoint the configuration is for. + * @param configFiles The ConfigFiles associated with the Endpoint. + * @return The extracted configuration, of null if not found. + */ + ProtocolConfig evaluate(Model.Endpoint endpoint, List configFiles); + + /** + * Provided by a protocol implementation to inform the library if two instances of the + * protocol configuration are equivalent or represent an update that requires re-initialization. + * + * @param latest The latest instance received by the library. + * @param previous The previous instance delivered to the protocol implementation. + * @return true if the front-end does not need to be re-initialized, false if it does. + */ + boolean equivalent(ProtocolConfig latest, ProtocolConfig previous); +} diff --git a/app-framework/src/main/scala/io/greenbus/japi/frontend/ProtocolUpdater.java b/app-framework/src/main/scala/io/greenbus/japi/frontend/ProtocolUpdater.java new file mode 100644 index 0000000..ca205bf --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/japi/frontend/ProtocolUpdater.java @@ -0,0 +1,45 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.japi.frontend; + +import io.greenbus.client.service.proto.FrontEnd; + +import java.util.List; + +/** + * A handle used by the protocol implementation to notify the library of measurement updates and communication + * status updates. + */ +public interface ProtocolUpdater { + + /** + * Publishes measurement updates. + * + * @param wallTime System time that will be used in measurements that do not contain their own specific time. + * @param updates List of Point name and measurement pairs that represent the updates. + */ + void publish(long wallTime, List updates); + + /** + * Publishes updates to the connection status. + * + * @param status The status the connection should be. + */ + void updateStatus(FrontEnd.FrontEndConnectionStatus.Status status); +} diff --git a/app-framework/src/main/scala/io/greenbus/japi/frontend/impl/BusDrivenFrontendProtocolManagerImpl.scala b/app-framework/src/main/scala/io/greenbus/japi/frontend/impl/BusDrivenFrontendProtocolManagerImpl.scala new file mode 100644 index 0000000..290c5fd --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/japi/frontend/impl/BusDrivenFrontendProtocolManagerImpl.scala @@ -0,0 +1,89 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.japi.frontend.impl + +import java.util.UUID + +import akka.actor.{ ActorRef, Props, ActorContext, ActorSystem } +import io.greenbus.app.actor._ +import io.greenbus.app.actor.frontend._ +import io.greenbus.client.ServiceConnection +import io.greenbus.client.service.proto.Model.Endpoint +import io.greenbus.japi.frontend.BusDrivenProtocolFactory +import io.greenbus.msg.Session +import io.greenbus.msg.amqp.AmqpServiceOperations + +class BusDrivenFrontendProtocolManagerImpl( + processName: String, + amqpConfig: AmqpConnectionConfig, + userConfigPath: String, + registrationConfig: FrontendRegistrationConfig, + nodeId: String, + endpointStrategy: EndpointCollectionStrategy, + factory: BusDrivenProtocolFactory) { + + private val system = ActorSystem(processName) + + def start(): Unit = { + + def factoryForEndpointMonitor(endpoint: Endpoint, session: Session, serviceOps: AmqpServiceOperations): Props = { + + BusDrivenWithCommandsProtocolEndpoint.props( + endpoint, + session, + serviceOps, + BusDrivenWithCommandsProtocolEndpoint.register(_, _, _, _, lockingEnabled = false, nodeId)(scala.concurrent.ExecutionContext.Implicits.global), + ProcessingProxy.props(_, _, + statusHeartbeatPeriodMs = 5000, + lapsedTimeMs = 11000, + statusRetryPeriodMs = 2000, + measRetryPeriodMs = 2000, + measQueueLimit = 1000), + SimpleCommandProxy.props, + BusDrivenProtocolHost.props(_, _, _, _, factory), + registrationRetryMs = 5000) + } + + def connMgr() = { + + def endMon(endpoint: Endpoint, coll: CollectionMembership, session: Session, ops: AmqpServiceOperations) = { + EndpointMonitor.props(endpoint, coll, session, ops, factoryForEndpointMonitor) + } + + def sessionMgr(conn: ServiceConnection): Props = { + SessionLoginManager.props("Endpoint Session Manager", userConfigPath, conn, + registrationConfig.loginRetryMs, + factory = EndpointCollectionManager.props(endpointStrategy, _, _, None, endMon)) + } + + FailoverConnectionManager.props("Endpoint Connection Bridge", + amqpConfig.amqpConfigFileList, + failureLimit = amqpConfig.failureLimit, + retryDelayMs = amqpConfig.retryDelayMs, + connectionTimeoutMs = amqpConfig.connectionTimeoutMs, + sessionMgr) + } + + val mgr = system.actorOf(connMgr()) + } + + def shutdown(): Unit = { + system.shutdown() + } +} \ No newline at end of file diff --git a/app-framework/src/main/scala/io/greenbus/japi/frontend/impl/BusDrivenProtocolHost.scala b/app-framework/src/main/scala/io/greenbus/japi/frontend/impl/BusDrivenProtocolHost.scala new file mode 100644 index 0000000..7fff628 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/japi/frontend/impl/BusDrivenProtocolHost.scala @@ -0,0 +1,85 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.japi.frontend.impl + +import akka.actor.{ Props, Actor } +import com.google.common.util.concurrent.{ FutureCallback, Futures, SettableFuture } +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.app.actor.frontend.{ StackStatusUpdated, MeasurementsPublished } +import io.greenbus.client.service.proto.Commands.{ CommandStatus, CommandResult, CommandRequest } +import io.greenbus.client.service.proto.Model.Endpoint +import io.greenbus.japi.frontend.BusDrivenProtocolFactory +import io.greenbus.msg.Session +import io.greenbus.msg.japi.impl.SessionShim + +import scala.concurrent.Future +import scala.concurrent.promise + +object BusDrivenProtocolHost { + + def props(endpoint: Endpoint, + session: Session, + publishMeasurements: MeasurementsPublished => Unit, + updateStatus: StackStatusUpdated => Unit, + factory: BusDrivenProtocolFactory): Props = { + Props(classOf[BusDrivenProtocolHost], endpoint, session, publishMeasurements, updateStatus, factory) + } +} + +class BusDrivenProtocolHost( + endpoint: Endpoint, + session: Session, + publishMeasurements: MeasurementsPublished => Unit, + updateStatus: StackStatusUpdated => Unit, + factory: BusDrivenProtocolFactory) extends Actor with Logging { + + private val protocol = factory.initialize(endpoint, new SessionShim(session), new ProtocolUpdaterImpl(publishMeasurements, updateStatus)) + + def receive = { + case cmdReq: CommandRequest => { + + val scalaPromise = promise[CommandResult] + val javaPromise: SettableFuture[CommandResult] = SettableFuture.create[CommandResult]() + + protocol.handleCommandRequest(cmdReq, javaPromise) + + Futures.addCallback(javaPromise, new FutureCallback[CommandResult] { + + override def onSuccess(v: CommandResult): Unit = { + scalaPromise.success(v) + } + + override def onFailure(throwable: Throwable): Unit = { + scalaPromise.failure(throwable) + } + }) + + sender ! scalaPromise.future + } + } + + override def postStop(): Unit = { + try { + protocol.onClose() + } catch { + case ex: Throwable => + logger.warn(s"Exception thrown for onClose for endpoint ${endpoint.getName}") + } + } +} diff --git a/app-framework/src/main/scala/io/greenbus/japi/frontend/impl/FrontendProtocolManagerImpl.scala b/app-framework/src/main/scala/io/greenbus/japi/frontend/impl/FrontendProtocolManagerImpl.scala new file mode 100644 index 0000000..522be00 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/japi/frontend/impl/FrontendProtocolManagerImpl.scala @@ -0,0 +1,55 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.japi.frontend.impl + +import java.util.UUID + +import akka.actor.{ ActorContext, ActorSystem } +import io.greenbus.app.actor._ +import io.greenbus.app.actor.frontend.{ MasterProtocol => ScalaMasterProtocol, FrontendRegistrationConfig, FrontendRegistrationConfig$, FrontendFactory } +import io.greenbus.japi.frontend.{ MasterProtocol => JavaMasterProtocol, ProtocolConfigurer => JavaProtocolConfigurer } +import scala.collection.JavaConversions._ + +class FrontendProtocolManagerImpl[ProtocolConfig]( + protocol: JavaMasterProtocol[ProtocolConfig], + protocolConfigurer: JavaProtocolConfigurer[ProtocolConfig], + configKeys: java.util.List[String], + endpointStrategy: EndpointCollectionStrategy, + amqpConfigPath: String, + userConfigPath: String) { + + private val system = ActorSystem("FrontendProtocolManager") + + def start(): Unit = { + + val configurer = new ProtocolConfigurerShim(protocolConfigurer) + + def protocolMgrFactory(context: ActorContext): ScalaMasterProtocol[ProtocolConfig] = new MasterProtocolShim[ProtocolConfig](protocol) + + system.actorOf(FrontendFactory.create( + AmqpConnectionConfig.default(amqpConfigPath), userConfigPath, endpointStrategy, protocolMgrFactory, configurer, configKeys.toSeq, + connectionRepresentsLock = false, + nodeId = UUID.randomUUID().toString, + FrontendRegistrationConfig.defaults)) + } + + def shutdown(): Unit = { + system.shutdown() + } +} diff --git a/app-framework/src/main/scala/io/greenbus/japi/frontend/impl/MasterProtocolShim.scala b/app-framework/src/main/scala/io/greenbus/japi/frontend/impl/MasterProtocolShim.scala new file mode 100644 index 0000000..6ded8c2 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/japi/frontend/impl/MasterProtocolShim.scala @@ -0,0 +1,79 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.japi.frontend.impl + +import com.google.common.util.concurrent.{ ListenableFuture, FutureCallback, Futures } +import io.greenbus.app.actor.frontend.{ ProtocolCommandAcceptor => ScalaCommandAcceptor, StackStatusUpdated, MeasurementsPublished } +import io.greenbus.client.service.proto.Commands.{ CommandResult, CommandRequest } +import io.greenbus.client.service.proto.FrontEnd.FrontEndConnectionStatus +import io.greenbus.client.service.proto.Model.Endpoint +import io.greenbus.client.service.proto.Model.ModelUUID +import io.greenbus.japi.frontend.{ NamedMeasurement, ProtocolUpdater } +import scala.collection.JavaConversions._ +import scala.concurrent.Future +import scala.concurrent.promise + +class MasterProtocolShim[ProtocolConfig](javaProtocol: io.greenbus.japi.frontend.MasterProtocol[ProtocolConfig]) + extends io.greenbus.app.actor.frontend.MasterProtocol[ProtocolConfig] { + + def add(endpoint: Endpoint, protocolConfig: ProtocolConfig, scalaPublish: (MeasurementsPublished) => Unit, statusUpdate: (StackStatusUpdated) => Unit): ScalaCommandAcceptor = { + + val updater = new ProtocolUpdater { + def updateStatus(status: FrontEndConnectionStatus.Status): Unit = { + statusUpdate(StackStatusUpdated(status)) + } + + def publish(wallTime: Long, updates: java.util.List[NamedMeasurement]): Unit = { + scalaPublish(MeasurementsPublished(wallTime, Seq(), updates.map(nm => (nm.getName, nm.getValue)))) + } + } + + val javaAcceptor = javaProtocol.add(endpoint, protocolConfig, updater) + + new ScalaCommandAcceptor { + def issue(commandName: String, request: CommandRequest): Future[CommandResult] = { + + val scalaPromise = promise[CommandResult]() + + val javaFuture: ListenableFuture[CommandResult] = javaAcceptor.issue(commandName, request) + + Futures.addCallback(javaFuture, new FutureCallback[CommandResult] { + def onSuccess(v: CommandResult): Unit = { + scalaPromise.success(v) + } + + def onFailure(ex: Throwable): Unit = { + scalaPromise.failure(ex) + } + }) + + scalaPromise.future + } + } + + } + + def remove(endpointUuid: ModelUUID): Unit = { + javaProtocol.remove(endpointUuid) + } + + def shutdown(): Unit = { + javaProtocol.shutdown() + } +} diff --git a/app-framework/src/main/scala/io/greenbus/japi/frontend/impl/ProtocolConfigurerShim.scala b/app-framework/src/main/scala/io/greenbus/japi/frontend/impl/ProtocolConfigurerShim.scala new file mode 100644 index 0000000..e1e4a90 --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/japi/frontend/impl/ProtocolConfigurerShim.scala @@ -0,0 +1,36 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.japi.frontend.impl + +import io.greenbus.app.actor.frontend.{ ProtocolConfigurer => ScalaProtocolConfigurer } +import io.greenbus.client.service.proto.Model.{ EntityKeyValue, Endpoint } +import io.greenbus.japi.frontend.{ ProtocolConfigurer => JavaProtocolConfigurer } + +import scala.collection.JavaConversions._ + +class ProtocolConfigurerShim[ProtocolConfig](javaConfigurer: JavaProtocolConfigurer[ProtocolConfig]) extends ScalaProtocolConfigurer[ProtocolConfig] { + + def evaluate(endpoint: Endpoint, configFiles: Seq[EntityKeyValue]): Option[ProtocolConfig] = { + Option(javaConfigurer.evaluate(endpoint, configFiles)) + } + + def equivalent(latest: ProtocolConfig, previous: ProtocolConfig): Boolean = { + javaConfigurer.equivalent(latest, previous) + } +} diff --git a/app-framework/src/main/scala/io/greenbus/japi/frontend/impl/ProtocolUpdaterImpl.scala b/app-framework/src/main/scala/io/greenbus/japi/frontend/impl/ProtocolUpdaterImpl.scala new file mode 100644 index 0000000..6e194db --- /dev/null +++ b/app-framework/src/main/scala/io/greenbus/japi/frontend/impl/ProtocolUpdaterImpl.scala @@ -0,0 +1,34 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.japi.frontend.impl + +import io.greenbus.app.actor.frontend.{ StackStatusUpdated, MeasurementsPublished } +import io.greenbus.client.service.proto.FrontEnd.FrontEndConnectionStatus +import io.greenbus.japi.frontend.{ NamedMeasurement, ProtocolUpdater } +import scala.collection.JavaConversions._ + +class ProtocolUpdaterImpl(scalaPublish: (MeasurementsPublished) => Unit, statusUpdate: (StackStatusUpdated) => Unit) extends ProtocolUpdater { + def updateStatus(status: FrontEndConnectionStatus.Status): Unit = { + statusUpdate(StackStatusUpdated(status)) + } + + def publish(wallTime: Long, updates: java.util.List[NamedMeasurement]): Unit = { + scalaPublish(MeasurementsPublished(wallTime, Seq(), updates.map(nm => (nm.getName, nm.getValue)))) + } +} \ No newline at end of file diff --git a/app-framework/src/test/scala/io/greenbus/app/actor/frontend/FrontendProtocolEndpointTest.scala b/app-framework/src/test/scala/io/greenbus/app/actor/frontend/FrontendProtocolEndpointTest.scala new file mode 100644 index 0000000..0d32232 --- /dev/null +++ b/app-framework/src/test/scala/io/greenbus/app/actor/frontend/FrontendProtocolEndpointTest.scala @@ -0,0 +1,651 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend + +import java.util.concurrent.atomic.AtomicReference + +import akka.actor.{ Actor, ActorRef, ActorSystem, Props } +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner +import org.scalatest.matchers.ShouldMatchers +import org.scalatest.{ BeforeAndAfterEach, FunSuite } +import io.greenbus.msg.amqp.{ AmqpAddressedMessage, AmqpServiceOperations } +import io.greenbus.msg.service.{ ServiceHandler, ServiceHandlerSubscription } +import io.greenbus.msg.{ Session, SessionUnusableException, Subscription, SubscriptionBinding } +import io.greenbus.app.actor.frontend.CommandProxy.{ CommandSubscriptionRetrieved, CommandsUpdated, ProtocolInitialized, ProtocolUninitialized } +import io.greenbus.app.actor.frontend.EventActor.SubCanceled +import io.greenbus.app.actor.frontend.FrontendProtocolEndpoint.Connected +import io.greenbus.app.actor.frontend.ProcessingProxy.{ LinkUp, PointMapUpdated } +import io.greenbus.client.exception.{ BadRequestException, LockedException, UnauthorizedException } +import io.greenbus.client.service.proto.Commands.{ CommandRequest, CommandResult } +import io.greenbus.client.service.proto.FrontEnd.{ FrontEndConnectionStatus, FrontEndRegistration } +import io.greenbus.client.service.proto.Model._ + +import scala.annotation.tailrec +import scala.concurrent.duration._ +import scala.concurrent.{ Await, Future, Promise, promise } + +case class MockSession(id: String) extends Session { + def headers: Map[String, String] = ??? + + def addHeader(key: String, value: String): Unit = ??? + + def removeHeader(key: String, value: String): Unit = ??? + + def spawn(): Session = ??? + + def clearHeaders(): Unit = ??? + + def addHeaders(headers: Seq[(String, String)]): Unit = ??? + + def subscribe(requestId: String, headers: Map[String, String], destination: Option[String], payload: Array[Byte]): Future[(Array[Byte], Subscription[Array[Byte]])] = ??? + + def request(requestId: String, headers: Map[String, String], destination: Option[String], payload: Array[Byte]): Future[Array[Byte]] = ??? +} + +case class MockServiceOps(id: String) extends AmqpServiceOperations { + override def publishEvent(exchange: String, msg: Array[Byte], key: String): Unit = ??? + + override def publishBatch(messages: Seq[AmqpAddressedMessage]): Unit = ??? + + override def bindRoutedService(handler: ServiceHandler): SubscriptionBinding = ??? + + override def competingServiceBinding(exchange: String): ServiceHandlerSubscription = ??? + + override def declareExchange(exchange: String): Unit = ??? + + override def bindQueue(queue: String, exchange: String, key: String): Unit = ??? + + override def bindCompetingService(handler: ServiceHandler, exchange: String): SubscriptionBinding = ??? + + override def simpleSubscription(): Subscription[Array[Byte]] = ??? + + override def routedServiceBinding(): ServiceHandlerSubscription = ??? +} + +case class MockServiceHandlerSubscription(id: String, obs: ActorRef) extends ServiceHandlerSubscription { + override def start(handler: ServiceHandler): Unit = ??? + + override def cancel(): Unit = obs ! SubCanceled(id) + + override def getId(): String = ??? +} + +case class MockSub[A](id: String) extends Subscription[A] { + override def start(handler: (A) => Unit): Unit = ??? + + override def cancel(): Unit = ??? + + override def getId(): String = ??? +} + +case class FakeConfig(id: String) + +class EventQueue { + val queue = new AtomicReference(Seq.empty[Any]) + private val chk = new AtomicReference(Option.empty[(Promise[Boolean], Seq[Any] => Boolean)]) + + def received(obj: Any): Unit = { + var worked = false + var appended = Seq.empty[Any] + while (!worked) { + val prev = queue.get() + appended = prev ++ Vector(obj) + worked = queue.compareAndSet(prev, appended) + } + chk.get().foreach { + case (prom, checkFun) => if (checkFun(appended)) prom.success(true) + } + } + + def listen(check: Seq[Any] => Boolean): Future[Boolean] = { + val prom = promise[Boolean] + chk.set(Some((prom, check))) + + if (check(queue.get())) prom.success(true) + + prom.future + } +} + +object EventActor { + + case class ProtAdded(end: String, config: FakeConfig) + case class ProtRemoved(end: String) + case object ProtShutdown + + case class ConfigEvaluate(end: String) + + case object ConfigStarted + case object ConfigStopped + + case class CmdIssued(cmdName: String, request: String) + + case class RegCalled(end: String, holdingLock: Boolean) + case class ConfigCalled(end: String) + + case class SubCanceled(id: String) + + def props(queue: EventQueue): Props = Props(classOf[EventActor], queue) +} +class EventActor(queue: EventQueue) extends Actor { + + def receive = { + case obj => queue.received(obj) + } +} + +import io.greenbus.app.actor.frontend.EventActor._ + +class ProtocolInterfaces(observer: ActorRef) extends MasterProtocol[FakeConfig] with ProtocolConfigurer[FakeConfig] with ProtocolCommandAcceptor { + + val measPublish = new AtomicReference(Option.empty[(MeasurementsPublished) => Unit]) + val statusUpdate = new AtomicReference(Option.empty[(StackStatusUpdated) => Unit]) + + val config = new AtomicReference(Option.empty[FakeConfig]) + + val cmdResult = new AtomicReference(Option.empty[CommandResult]) + + val regResult = new AtomicReference(Option.empty[(Future[FrontEndRegistration], ServiceHandlerSubscription)]) + val cfgResult = new AtomicReference(Option.empty[Future[FrontendConfiguration]]) + + def add(endpoint: Endpoint, protocolConfig: FakeConfig, publish: (MeasurementsPublished) => Unit, status: (StackStatusUpdated) => Unit): ProtocolCommandAcceptor = { + measPublish.set(Some(publish)) + statusUpdate.set(Some(status)) + observer ! ProtAdded(endpoint.getUuid.getValue, protocolConfig) + this + } + + def remove(endpointUuid: ModelUUID): Unit = { + observer ! ProtRemoved(endpointUuid.getValue) + } + + def shutdown(): Unit = { + observer ! ProtShutdown + } + + def evaluate(endpoint: Endpoint, configFiles: Seq[EntityKeyValue]): Option[FakeConfig] = { + observer ! ConfigEvaluate(endpoint.getUuid.getValue) + config.get() + } + + def equivalent(latest: FakeConfig, previous: FakeConfig): Boolean = { + false + } + + def issue(commandName: String, request: CommandRequest): Future[CommandResult] = { + observer ! CmdIssued(commandName, request.getCommandUuid.getValue) + Future.successful(cmdResult.get().get) + } + + def register(session: Session, serviceOps: AmqpServiceOperations, endpoint: Endpoint, holdingLock: Boolean): (Future[FrontEndRegistration], ServiceHandlerSubscription) = { + observer ! RegCalled(endpoint.getUuid.getValue, holdingLock) + regResult.get().get + } + + def configure(session: Session, endpoint: Endpoint): Future[FrontendConfiguration] = { + observer ! ConfigCalled(endpoint.getUuid.getValue) + cfgResult.get().get + } +} + +object ConfigUpdater { + def props(test: ActorRef): Props = Props(classOf[ConfigUpdater], test) +} +class ConfigUpdater(test: ActorRef) extends Actor { + + test ! ConfigStarted + + def receive = { + case x => test ! x + } + + override def postStop(): Unit = { + test ! ConfigStopped + } +} + +object EventForwarder { + def props(test: ActorRef): Props = Props(classOf[EventForwarder], test) +} +class EventForwarder(test: ActorRef) extends Actor { + + def receive = { + case x => test ! x + } +} + +class FepTestRig(system: ActorSystem) { + + val events = new EventQueue + val eventActor = system.actorOf(EventActor.props(events)) + + val mock = new ProtocolInterfaces(eventActor) + + def configFactory(ref: ActorRef, endpoint: Endpoint, session: Session, config: FrontendConfiguration) = { + ConfigUpdater.props(eventActor) + } + + def proxyFactory(ref: ActorRef, endpoint: Endpoint) = { + EventForwarder.props(eventActor) + } + + def cmdFactory(endpoint: Endpoint) = { + EventForwarder.props(eventActor) + } + + val uuid = "uuid01" + val endpoint = Endpoint.newBuilder() + .setUuid(ModelUUID.newBuilder().setValue(uuid).build()) + .setName("end01") + .addTypes("Endpoint") + .setDisabled(false) + .setProtocol("protocol01") + .build() + + val fep = system.actorOf(FrontendProtocolEndpoint.props(endpoint, eventActor, mock, mock, 50, 500, proxyFactory, cmdFactory, configFactory, mock.register, mock.configure)) + + import io.greenbus.app.actor.frontend.FrontendProtocolEndpointTest._ + def checkFut(compare: Seq[SubSeq]): Future[Boolean] = events.listen { q => /*println(q);*/ checkSeq(q, compare) } +} + +object FrontendProtocolEndpointTest { + + sealed trait SubSeq + case class Ordered(values: Seq[Any]) extends SubSeq + case class Unordered(values: Seq[Any]) extends SubSeq + + @tailrec + def unorderedEq(a: Seq[Any], b: Seq[Any]): Boolean = { + if (a.size != b.size) { + false + } else if (a.isEmpty) { + true + } else { + val (c, d) = b.span(_ != a.head) + if (d.isEmpty) { + false + } else { + unorderedEq(a.drop(1), c ++ d.drop(1)) + } + } + } + + @tailrec + def checkSeq(results: Seq[Any], compare: Seq[SubSeq]): Boolean = { + compare match { + case Seq() => results.isEmpty + case cmp => + cmp.head match { + case Ordered(values) => + results.size >= values.size && + results.take(values.size) == values && + checkSeq(results.drop(values.size), cmp.drop(1)) + case Unordered(values) => + results.size >= values.size && + unorderedEq(results.take(values.size), values) && + checkSeq(results.drop(values.size), cmp.drop(1)) + } + } + } + + def registration(uuid: String, input: String): FrontEndRegistration = FrontEndRegistration.newBuilder().setEndpointUuid(ModelUUID.newBuilder().setValue(uuid).build()).setInputAddress(input).build() +} + +@RunWith(classOf[JUnitRunner]) +class FrontendProtocolEndpointTest extends FunSuite with ShouldMatchers with BeforeAndAfterEach { + import io.greenbus.app.actor.frontend.FrontendProtocolEndpointTest._ + + private var as = Option.empty[ActorSystem] + + override protected def beforeEach(): Unit = { + as = Some(ActorSystem("test")) + } + + override protected def afterEach(): Unit = { + as.get.shutdown() + } + + def comeUpSequence(r: FepTestRig, handlerId: String, sessId: String, input: String, holdingLock: Boolean = false, unorderedPrefix: Seq[Any] = Seq.empty[Any]): Unit = { + import r._ + + events.queue.set(Seq()) + mock.regResult.set(Some((Future.successful(registration(uuid, input)), MockServiceHandlerSubscription(handlerId, eventActor)))) + mock.cfgResult.set(Some(Future.successful(FrontendConfiguration(Seq(), Seq(), Seq(), MockSub[EntityEdgeNotification]("modelEdgeSub01"), MockSub[EntityKeyValueNotification]("configSub01"))))) + + mock.config.set(Some(FakeConfig("config01"))) + + val comingUp = Seq( + Unordered(unorderedPrefix ++ Seq( + RegCalled(uuid, holdingLock))), + Unordered(Seq( + ConfigCalled(uuid), + ConfigStarted, + LinkUp(MockSession(sessId), input), + CommandSubscriptionRetrieved(MockServiceHandlerSubscription(handlerId, eventActor)), + PointMapUpdated(Map()), + CommandsUpdated(List()), + ConfigEvaluate(uuid), + ProtAdded(uuid, FakeConfig("config01")), + ProtocolInitialized(mock)))) + + fep ! Connected(MockSession(sessId), MockServiceOps("ops01")) + + Await.result(checkFut(comingUp), 5000.milliseconds) should equal(true) + } + + def serviceComeUpProtocolUp(r: FepTestRig, handlerId: String, sessId: String, input: String, holdingLock: Boolean = false): Unit = { + import r._ + + events.queue.set(Seq()) + mock.regResult.set(Some((Future.successful(registration(uuid, input)), MockServiceHandlerSubscription(handlerId, eventActor)))) + mock.cfgResult.set(Some(Future.successful(FrontendConfiguration(Seq(), Seq(), Seq(), MockSub[EntityEdgeNotification]("modelEdgeSub01"), MockSub[EntityKeyValueNotification]("configSub01"))))) + + mock.config.set(Some(FakeConfig("config01"))) + + val comingUp = Seq( + Ordered(Seq( + RegCalled(uuid, holdingLock))), + Unordered(Seq( + ConfigCalled(uuid), + ConfigStarted, + LinkUp(MockSession(sessId), input), + CommandSubscriptionRetrieved(MockServiceHandlerSubscription(handlerId, eventActor)), + PointMapUpdated(Map()), + CommandsUpdated(List())))) + + fep ! Connected(MockSession(sessId), MockServiceOps("ops01")) + + Await.result(checkFut(comingUp), 5000.milliseconds) should equal(true) + } + + test("coming up process") { + val r = new FepTestRig(as.get) + comeUpSequence(r, "handler01", "sess01", "input01") + } + + def regFailureTest(exFun: => Throwable) = { + val r = new FepTestRig(as.get) + import r._ + + mock.regResult.set(Some((Future.failed(exFun), MockServiceHandlerSubscription("sub01", eventActor)))) + + fep ! Connected(MockSession("sess01"), MockServiceOps("ops01")) + + val initialFailure = Seq( + Ordered(Seq( + RegCalled(uuid, false), + SubCanceled("sub01")))) + + Await.result(checkFut(initialFailure), 5000.milliseconds) should equal(true) + + events.queue.set(Seq()) + mock.regResult.set(Some((Future.failed(exFun), MockServiceHandlerSubscription("sub02", eventActor)))) + + val secondFailure = Seq( + Ordered(Seq( + RegCalled(uuid, false), + SubCanceled("sub02")))) + + Await.result(checkFut(secondFailure), 5000.milliseconds) should equal(true) + + comeUpSequence(r, "handler01", "sess01", "input01") + } + + test("pre-reg, locked") { + regFailureTest(new LockedException("msg01")) + } + + test("pre-reg, bad request") { + regFailureTest(new BadRequestException("msg01")) + } + + test("pre-reg, other") { + regFailureTest(new RuntimeException("msg01")) + } + + test("pre-reg, session crap") { + val r = new FepTestRig(as.get) + import r._ + + mock.regResult.set(Some((Future.failed(new SessionUnusableException("msg01")), MockServiceHandlerSubscription("sub01", eventActor)))) + + fep ! Connected(MockSession("sess01"), MockServiceOps("ops01")) + + val initialFailure = Seq( + Ordered(Seq( + RegCalled(uuid, false))), + Unordered(Seq( + SubCanceled("sub01"), + ServiceSessionDead))) + + Await.result(checkFut(initialFailure), 5000.milliseconds) should equal(true) + + events.queue.set(Seq()) + mock.regResult.set(Some((Future.failed(new UnauthorizedException("msg01")), MockServiceHandlerSubscription("sub02", eventActor)))) + + fep ! Connected(MockSession("sess02"), MockServiceOps("ops02")) + + val secondFailure = Seq( + Ordered(Seq( + RegCalled(uuid, false))), + Unordered(Seq( + SubCanceled("sub02"), + ServiceSessionDead))) + + Await.result(checkFut(secondFailure), 5000.milliseconds) should equal(true) + + comeUpSequence(r, "handler01", "sess03", "input01") + } + + test("pre-reg, fail before") { + val r = new FepTestRig(as.get) + import r._ + + val prom = promise[FrontEndRegistration] + mock.regResult.set(Some((prom.future, MockServiceHandlerSubscription("sub01", eventActor)))) + + fep ! Connected(MockSession("sess01"), MockServiceOps("ops01")) + + val comingUp = Seq( + Ordered(Seq( + RegCalled(uuid, holdingLock = false)))) + + Await.result(checkFut(comingUp), 5000.milliseconds) should equal(true) + + comeUpSequence(r, "sub02", "sess02", "input01", holdingLock = false, Seq(SubCanceled("sub01"))) + } + + /*test("pre-reg, fail before") { + val r = new FepTestRig(as.get) + import r._ + + val prom = promise[FrontEndRegistration] + mock.regResult.set(Some((prom.future, MockServiceHandlerSubscription("sub01", eventActor)))) + + fep ! Connected(MockSession("sess01"), MockServiceOps("ops01")) + + val comingUp = Seq( + Ordered(Seq( + RegCalled(uuid, holdingLock = false)))) + + Await.result(checkFut(comingUp), 5000.milliseconds) should equal(true) + + comeUpSequence(r, "sub02", "sess02", "input01", holdingLock = false, Seq(SubCanceled("sub01"))) + }*/ + + def sessionDiesWhileProtocolUp(status: FrontEndConnectionStatus.Status, holdingLock: Boolean): Unit = { + val r = new FepTestRig(as.get) + import r._ + + comeUpSequence(r, "handler01", "sess01", "input01") + + events.queue.set(Seq()) + + fep ! StackStatusUpdated(status) + + val onCommsUp = Seq( + Unordered(Seq(StackStatusUpdated(status)))) + Await.result(checkFut(onCommsUp), 5000.milliseconds) should equal(true) + + events.queue.set(Seq()) + + fep ! ServiceSessionDead + + val onSessionDead = Seq( + Unordered(Seq( + ServiceSessionDead, + SubCanceled("handler01"), + ConfigStopped))) + + Await.result(checkFut(onSessionDead), 5000.milliseconds) should equal(true) + + serviceComeUpProtocolUp(r, "handler02", "sess02", "input02", holdingLock = holdingLock) + } + + test("comms up, session lost, back up") { + sessionDiesWhileProtocolUp(FrontEndConnectionStatus.Status.COMMS_UP, holdingLock = true) + } + test("comms down, session lost, back up") { + sessionDiesWhileProtocolUp(FrontEndConnectionStatus.Status.COMMS_DOWN, holdingLock = false) + } + test("comms unknown, session lost, back up") { + sessionDiesWhileProtocolUp(FrontEndConnectionStatus.Status.UNKNOWN, holdingLock = false) + } + test("comms error, session lost, back up") { + sessionDiesWhileProtocolUp(FrontEndConnectionStatus.Status.ERROR, holdingLock = false) + } + + def sessionRefresh(status: FrontEndConnectionStatus.Status, holdingLock: Boolean): Unit = { + val r = new FepTestRig(as.get) + import r._ + + comeUpSequence(r, "handler01", "sess01", "input01") + + events.queue.set(Seq()) + + fep ! StackStatusUpdated(status) + + val onCommsUp = Seq( + Unordered(Seq(StackStatusUpdated(status)))) + Await.result(checkFut(onCommsUp), 5000.milliseconds) should equal(true) + + events.queue.set(Seq()) + mock.regResult.set(Some((Future.successful(registration(uuid, "input02")), MockServiceHandlerSubscription("handler02", eventActor)))) + mock.cfgResult.set(Some(Future.successful(FrontendConfiguration(Seq(), Seq(), Seq(), MockSub[EntityEdgeNotification]("modelEdgeSub01"), MockSub[EntityKeyValueNotification]("configSub01"))))) + + mock.config.set(Some(FakeConfig("config01"))) + + val comingUp = Seq( + Unordered(Seq( + SubCanceled("handler01"), + ConfigStopped, + RegCalled(uuid, holdingLock), + ConfigCalled(uuid), + ConfigStarted, + LinkUp(MockSession("sess02"), "input02"), + CommandSubscriptionRetrieved(MockServiceHandlerSubscription("handler02", eventActor)), + PointMapUpdated(Map()), + CommandsUpdated(List())))) + + fep ! Connected(MockSession("sess02"), MockServiceOps("ops01")) + + Await.result(checkFut(comingUp), 5000.milliseconds) should equal(true) + } + + test("comms up, session refresh, back up") { + sessionRefresh(FrontEndConnectionStatus.Status.COMMS_UP, holdingLock = true) + } + test("comms down, session refresh, back up") { + sessionRefresh(FrontEndConnectionStatus.Status.COMMS_DOWN, holdingLock = false) + } + test("comms unknown, session refresh, back up") { + sessionRefresh(FrontEndConnectionStatus.Status.UNKNOWN, holdingLock = false) + } + test("comms error, session refresh, back up") { + sessionRefresh(FrontEndConnectionStatus.Status.ERROR, holdingLock = false) + } + + test("session dead, eventual protocol remove") { + val r = new FepTestRig(as.get) + import r._ + + comeUpSequence(r, "handler01", "sess01", "input01") + + events.queue.set(Seq()) + fep ! StackStatusUpdated(FrontEndConnectionStatus.Status.COMMS_UP) + + val onCommsUp = Seq( + Unordered(Seq(StackStatusUpdated(FrontEndConnectionStatus.Status.COMMS_UP)))) + Await.result(checkFut(onCommsUp), 5000.milliseconds) should equal(true) + + events.queue.set(Seq()) + fep ! ServiceSessionDead + + val onSessionDead = Seq( + Unordered(Seq( + ServiceSessionDead, + SubCanceled("handler01"), + ConfigStopped)), + Unordered(Seq( + ProtocolUninitialized, + ProtRemoved(uuid)))) + + Await.result(checkFut(onSessionDead), 5000.milliseconds) should equal(true) + } + + test("session dead, locked, protocol remove") { + val r = new FepTestRig(as.get) + import r._ + + comeUpSequence(r, "handler01", "sess01", "input01") + + events.queue.set(Seq()) + fep ! StackStatusUpdated(FrontEndConnectionStatus.Status.COMMS_UP) + + val onCommsUp = Seq( + Unordered(Seq(StackStatusUpdated(FrontEndConnectionStatus.Status.COMMS_UP)))) + Await.result(checkFut(onCommsUp), 5000.milliseconds) should equal(true) + + events.queue.set(Seq()) + fep ! ServiceSessionDead + + val onSessionDead = Seq( + Unordered(Seq( + ServiceSessionDead, + SubCanceled("handler01"), + ConfigStopped))) + + Await.result(checkFut(onSessionDead), 5000.milliseconds) should equal(true) + + events.queue.set(Seq()) + mock.regResult.set(Some((Future.failed(new LockedException("msg01")), MockServiceHandlerSubscription("handler02", eventActor)))) + + fep ! Connected(MockSession("sess02"), MockServiceOps("ops01")) + + val onBackUp = Seq( + Unordered(Seq( + RegCalled(uuid, holdingLock = true), + SubCanceled("handler02"), + ProtocolUninitialized, + ProtRemoved(uuid)))) + + Await.result(checkFut(onBackUp), 50.milliseconds) should equal(true) + } +} diff --git a/app-framework/src/test/scala/io/greenbus/app/actor/frontend/QueueLimitTest.scala b/app-framework/src/test/scala/io/greenbus/app/actor/frontend/QueueLimitTest.scala new file mode 100644 index 0000000..30818ab --- /dev/null +++ b/app-framework/src/test/scala/io/greenbus/app/actor/frontend/QueueLimitTest.scala @@ -0,0 +1,64 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.app.actor.frontend + +import org.junit.runner.RunWith +import org.scalatest.FunSuite +import org.scalatest.junit.JUnitRunner +import org.scalatest.matchers.ShouldMatchers +import io.greenbus.client.service.proto.Measurements.Measurement + +@RunWith(classOf[JUnitRunner]) +class QueueLimitTest extends FunSuite with ShouldMatchers { + + def meas(t: Int) = Measurement.newBuilder().setType(Measurement.Type.INT).setIntVal(5).setTime(t).build() + + test("Queue limit, leave one in") { + val original = Seq( + ("nameA", meas(0)), + ("nameB", meas(1)), + ("nameC", meas(2)), + ("nameA", meas(3)), + ("nameA", meas(4)), + ("nameA", meas(5)), + ("nameA", meas(6)), + ("nameC", meas(7))) + + val result = ProcessingProxy.maintainQueueLimit(original, 3) + + result.map(_._2.getTime) should equal(Seq(1, 6, 7)) + } + + test("Queue limit, more than limit left") { + val original = Seq( + ("nameA", meas(0)), + ("nameB", meas(1)), + ("nameC", meas(2)), + ("nameD", meas(3)), + ("nameE", meas(4)), + ("nameF", meas(5)), + ("nameA", meas(6)), + ("nameC", meas(7))) + + val result = ProcessingProxy.maintainQueueLimit(original, 3) + + result.map(_._2.getTime) should equal(Seq(1, 3, 4, 5, 6, 7)) + } + +} diff --git a/calculation/pom.xml b/calculation/pom.xml new file mode 100755 index 0000000..4cf1745 --- /dev/null +++ b/calculation/pom.xml @@ -0,0 +1,69 @@ + + 4.0.0 + + + io.greenbus + greenbus-scala-base + 3.0.0 + ../scala-base + + + greenbus-calculation + jar + + + + AGPLv3 + http://www.gnu.org/licenses/agpl-3.0.txt + + + + + + + + com.mycila.maven-license-plugin + maven-license-plugin + +
../AGPL_FILE_HEADER
+
+
+
+
+ + + + io.greenbus + greenbus-client + 3.0.0 + + + io.greenbus + greenbus-app-framework + 3.0.0 + + + io.greenbus + greenbus-util + 3.0.0 + + + io.greenbus.msg + greenbus-msg-qpid + 1.0.0 + + + com.typesafe.akka + akka-actor_2.10 + 2.2.0 + + + com.typesafe.akka + akka-slf4j_2.10 + 2.2.0 + + + + +
diff --git a/calculation/src/main/scala/io/greenbus/calc/CalculationEndpointManager.scala b/calculation/src/main/scala/io/greenbus/calc/CalculationEndpointManager.scala new file mode 100644 index 0000000..d810885 --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/CalculationEndpointManager.scala @@ -0,0 +1,305 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc + +import akka.actor._ +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.{ Session, SessionUnusableException, Subscription, SubscriptionBinding } +import io.greenbus.app.actor.frontend.{ MeasurementsPublished, StackStatusUpdated } +import io.greenbus.calc.lib.eval.OperationSource +import io.greenbus.client.exception.{ BusUnavailableException, ServiceException, UnauthorizedException } +import io.greenbus.client.proto.Envelope.SubscriptionEventType +import io.greenbus.client.service.ModelService +import io.greenbus.client.service.proto.Calculations.CalculationDescriptor +import io.greenbus.client.service.proto.FrontEnd.FrontEndConnectionStatus +import io.greenbus.client.service.proto.Measurements.Measurement +import io.greenbus.client.service.proto.Model._ +import io.greenbus.client.service.proto.ModelRequests._ + +import scala.concurrent.Future +import scala.concurrent.duration._ + +object CalculationEndpointManager { + + val retrieveRetryMs = 5000 + + case class ObjectAdded(uuid: ModelUUID) + case class ObjectRemoved(uuid: ModelUUID) + + case class CalculationRetrieved(uuid: ModelUUID, calcs: Seq[CalculationDescriptor]) + case class CalculationRetrieveError(uuid: ModelUUID, ex: Throwable) + + case class CalcEvent(eventType: SubscriptionEventType, uuid: ModelUUID, calc: CalculationDescriptor) + + case class SubResult(current: Seq[(ModelUUID, CalculationDescriptor)], subscription: Subscription[EntityKeyValueNotification], edgeCurrent: Seq[EntityEdge], edgeSub: Subscription[EntityEdgeNotification]) + case class SubError(ex: Throwable) + + def props(endpoint: Endpoint, session: Session, publishMeasurements: MeasurementsPublished => Unit, updateStatus: StackStatusUpdated => Unit, opSource: OperationSource): Props = { + Props(classOf[CalculationEndpointManager], endpoint, session, publishMeasurements, updateStatus, opSource) + } + +} + +class CalculationEndpointManager(endpoint: Endpoint, session: Session, publishMeasurements: MeasurementsPublished => Unit, updateStatus: StackStatusUpdated => Unit, opSource: OperationSource) extends Actor with Logging { + import io.greenbus.calc.CalculationEndpointManager._ + + private var calcSubscription = Option.empty[SubscriptionBinding] + private var edgeSubscription = Option.empty[SubscriptionBinding] + + private var calcMap = Map.empty[ModelUUID, ActorRef] + + // a check for if removed between the time point is added and calc comes in + private var outstandingPoints = Set.empty[ModelUUID] + + private def publish(uuid: ModelUUID, m: Measurement): Unit = publishMeasurements(MeasurementsPublished(System.currentTimeMillis(), Seq((uuid, m)), Seq())) + + setupConfig() + + def receive = { + case SubResult(current, sub, currentEdges, edgeSub) => + calcSubscription = Some(sub) + edgeSubscription = Some(edgeSub) + + sub.start { calcEvent => + keyValueToUuidAndCalc(calcEvent.getValue).foreach { + case (uuid, calc) => + self ! CalcEvent(calcEvent.getEventType, uuid, calc) + } + + } + + edgeSub.start { ev => + ev.getEventType match { + case SubscriptionEventType.ADDED => self ! ObjectAdded(ev.getValue.getChild) + case SubscriptionEventType.REMOVED => self ! ObjectRemoved(ev.getValue.getChild) + case SubscriptionEventType.MODIFIED => + } + } + + logger.info(s"Calculations initialized for ${endpoint.getName}") + logger.debug(s"Calculations (${current.size}) configured for ${endpoint.getName}: " + current.map(_._1.getValue)) + updateStatus(StackStatusUpdated(FrontEndConnectionStatus.Status.COMMS_UP)) + + updateCalcs(current) + + case SubError(ex: Throwable) => + logger.warn(s"Error setting up subscription for endpoint ${endpoint.getUuid}") + throw ex + + case CalcEvent(eventType, uuid, calc) => + import io.greenbus.client.proto.Envelope.SubscriptionEventType._ + eventType match { + case ADDED => + logger.debug(s"Calculation ${uuid.getValue} added on calculation endpoint ${endpoint.getName}") + updateCalcs(Seq((uuid, calc))) + case MODIFIED => + logger.debug(s"Calculation ${uuid.getValue} modified on calculation endpoint ${endpoint.getName}") + updateCalcs(Seq((uuid, calc))) + case REMOVED => + logger.debug(s"Calculation ${uuid.getValue} removed on calculation endpoint ${endpoint.getName}") + removeCalcs(Seq(uuid)) + } + + case ObjectAdded(uuid) => + logger.debug(s"Point ${uuid.getValue} added on calculation endpoint ${endpoint.getName}") + outstandingPoints += uuid + retrieveCalc(uuid) + + case ObjectRemoved(uuid) => + logger.debug(s"Point ${uuid.getValue} removed on calculation endpoint ${endpoint.getName}") + outstandingPoints -= uuid + removeCalcs(Seq(uuid)) + + case CalculationRetrieved(uuid, calcs) => + calcs.headOption match { + case None => + logger.debug(s"Calculation config empty for ${uuid.getValue} on calculation endpoint ${endpoint.getName}") + outstandingPoints -= uuid + case Some(calc) => + logger.debug(s"Calculation retrieved for ${uuid.getValue} on calculation endpoint ${endpoint.getName}") + updateCalcs(Seq((uuid, calc))) + outstandingPoints -= uuid + } + + case CalculationRetrieveError(uuid, error) => + error match { + case ex: BusUnavailableException => scheduleMsg(retrieveRetryMs, ObjectAdded(uuid)) + case ex: UnauthorizedException => throw ex + case ex: ServiceException => + // We assume this means the configuration is bad, treat it like a point that failed to start + logger.warn(s"Failed to get calculation for ${uuid.getValue} for endpoint ${endpoint.getName}") + case ex => throw ex + } + + } + + override def postStop(): Unit = { + calcSubscription.foreach(_.cancel()) + edgeSubscription.foreach(_.cancel()) + } + + private def removeCalcs(uuids: Seq[ModelUUID]) { + uuids.flatMap(calcMap.get).foreach(_ ! PoisonPill) + calcMap --= uuids + } + + private def retrieveCalc(uuid: ModelUUID) { + import context.dispatcher + val modelClient = ModelService.client(session) + + val keyPair = EntityKeyPair.newBuilder() + .setUuid(uuid) + .setKey("calculation") + .build() + + val calcFut = modelClient.getEntityKeyValues(Seq(keyPair)).map { results => + results.flatMap(keyValueToCalc) + } + + import context.dispatcher + calcFut.onSuccess { case calcs => self ! CalculationRetrieved(uuid, calcs) } + calcFut.onFailure { case ex => self ! CalculationRetrieveError(uuid, ex) } + } + + private def updateCalcs(current: Seq[(ModelUUID, CalculationDescriptor)]) { + + removeCalcs(current.map(_._1)) + + val created = current.flatMap { + case (uuid, calc) => + try { + val actor = context.actorOf(CalculationPointManager.props(uuid, calc, session, opSource, publish)) + Some(uuid, actor) + } catch { + case ex: Throwable => + logger.warn(s"Couldn't initialize point in endpoint ${endpoint.getName}: " + ex.getMessage) + None + } + } + + calcMap = calcMap ++ created + } + + private def keyValueToCalc(kvp: EntityKeyValue): Option[CalculationDescriptor] = { + if (kvp.hasValue && kvp.getValue.hasByteArrayValue) { + try { + Some(CalculationDescriptor.parseFrom(kvp.getValue.getByteArrayValue)) + } catch { + case ex: Throwable => + logger.warn(s"Calculation set for uuid ${kvp.getUuid} couldn't parse") + None + } + } else { + None + } + } + + private def keyValueToUuidAndCalc(kvp: EntityKeyValue): Option[(ModelUUID, CalculationDescriptor)] = { + keyValueToCalc(kvp).map(c => (kvp.getUuid, c)) + } + + private def getCalcFuture(): Future[Seq[(ModelUUID, CalculationDescriptor)]] = { + import context.dispatcher + val modelClient = ModelService.client(session) + + val pageSize = Int.MaxValue // TODO: Breaks on large endpoints? + + val pointQueryTemplate = EntityRelationshipFlatQuery.newBuilder() + .addStartUuids(endpoint.getUuid) + .setRelationship("source") + .setDescendantOf(true) + .addEndTypes("Point") + .setPagingParams( + EntityPagingParams.newBuilder() + .setPageSize(pageSize)) + + val entFuture = modelClient.relationshipFlatQuery(pointQueryTemplate.build()) + + entFuture.flatMap { + case Seq() => Future.successful(Nil) + case ents: Seq[Entity] => + val keys = ents.map { e => + EntityKeyPair.newBuilder() + .setUuid(e.getUuid) + .setKey("calculation") + .build() + } + + modelClient.getEntityKeyValues(keys).map { results => + results.flatMap(keyValueToUuidAndCalc) + } + } + } + + private def setupConfig() { + import context.dispatcher + + val modelClient = ModelService.client(session) + + val edgeQuery = EntityEdgeSubscriptionQuery.newBuilder() + .addFilters(EntityEdgeFilter.newBuilder() + .setParentUuid(endpoint.getUuid) + .setRelationship("source") + .setDistance(1) + .build()) + .build() + + val edgeFut = modelClient.subscribeToEdges(edgeQuery) + + val allFut: Future[SubResult] = edgeFut.flatMap { edgeResult => + + val subFut = modelClient.subscribeToEntityKeyValues(EntityKeyValueSubscriptionQuery.newBuilder().addEndpointUuids(endpoint.getUuid).build()) + + subFut.onFailure { case _ => edgeResult._2.cancel() } + + subFut.flatMap { + case (emptyCurrent, sub) => + val currentFut = getCalcFuture() + + currentFut.onFailure { case ex => sub.cancel() } + + currentFut.map { calcs => + (calcs, sub) + } + }.map { + case (calcCurrent, sub) => SubResult(calcCurrent, sub, edgeResult._1, edgeResult._2) + } + } + + allFut.onSuccess { case result => self ! result } + allFut.onFailure { case ex => self ! SubError(ex) } + } + + override def supervisorStrategy: SupervisorStrategy = { + import akka.actor.SupervisorStrategy._ + OneForOneStrategy(maxNrOfRetries = 1, withinTimeRange = 3.seconds) { + case _: SessionUnusableException => Escalate + case _: UnauthorizedException => Escalate + case _: Throwable => Restart + } + } + + private def scheduleMsg(timeMs: Long, msg: AnyRef) { + import context.dispatcher + context.system.scheduler.scheduleOnce( + Duration(timeMs, MILLISECONDS), + self, + msg) + } +} diff --git a/calculation/src/main/scala/io/greenbus/calc/CalculationEngine.scala b/calculation/src/main/scala/io/greenbus/calc/CalculationEngine.scala new file mode 100644 index 0000000..dddd29f --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/CalculationEngine.scala @@ -0,0 +1,75 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc + +import java.util.UUID + +import akka.actor.{ ActorSystem, Props } +import com.typesafe.config.ConfigFactory +import io.greenbus.msg.Session +import io.greenbus.msg.amqp.AmqpServiceOperations +import io.greenbus.app.actor._ +import io.greenbus.app.actor.frontend.{ BusDrivenProtocolEndpoint, ProcessingProxy } +import io.greenbus.calc.lib.eval.BasicOperations +import io.greenbus.client.service.proto.Model.Endpoint + +object CalculationEngine { + + def main(args: Array[String]) { + + val opSource = BasicOperations.getSource + + val rootConfig = ConfigFactory.load() + val slf4jConfig = ConfigFactory.parseString("""akka { loggers = ["akka.event.slf4j.Slf4jLogger"] }""") + val akkaConfig = slf4jConfig.withFallback(rootConfig) + val system = ActorSystem("calculation", akkaConfig) + + val nodeId = UUID.randomUUID().toString + + val endpointStrategy = new ProtocolsEndpointStrategy(Set("calculator")) + + val baseDir = Option(System.getProperty("io.greenbus.config.base")).getOrElse("") + val amqpConfigPath = Option(System.getProperty("io.greenbus.config.amqp")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.msg.amqp.cfg") + val userConfigPath = Option(System.getProperty("io.greenbus.config.user")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.user.cfg") + + def factoryForEndpointMonitor(endpoint: Endpoint, session: Session, serviceOps: AmqpServiceOperations): Props = { + + BusDrivenProtocolEndpoint.props( + endpoint, + session, + BusDrivenProtocolEndpoint.register(_, _, _, lockingEnabled = false, nodeId)(scala.concurrent.ExecutionContext.Implicits.global), + ProcessingProxy.props(_, _, + statusHeartbeatPeriodMs = 5000, + lapsedTimeMs = 11000, + statusRetryPeriodMs = 2000, + measRetryPeriodMs = 2000, + measQueueLimit = 1000), + CalculationEndpointManager.props(_, _, _, _, opSource), + registrationRetryMs = 5000) + } + + val mgr = system.actorOf( + ConnectedApplicationManager.props( + "Calculation Engine", + amqpConfigPath, + userConfigPath, + EndpointCollectionManager.props(endpointStrategy, _, _, None, + EndpointMonitor.props(_, _, _, _, factoryForEndpointMonitor)))) + } +} diff --git a/calculation/src/main/scala/io/greenbus/calc/CalculationPointManager.scala b/calculation/src/main/scala/io/greenbus/calc/CalculationPointManager.scala new file mode 100644 index 0000000..6353f36 --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/CalculationPointManager.scala @@ -0,0 +1,276 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc + +import akka.actor.{ Actor, Props } +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.{ Session, Subscription, SubscriptionBinding } +import io.greenbus.calc.lib.eval.OperationSource +import io.greenbus.calc.lib.{ CalculationEvaluator, ErrorMeasurement, InputBucket } +import io.greenbus.client.service.MeasurementService +import io.greenbus.client.service.proto.Calculations.{ CalculationDescriptor, CalculationInput } +import io.greenbus.client.service.proto.MeasurementRequests.MeasurementHistoryQuery +import io.greenbus.client.service.proto.Measurements.{ Measurement, MeasurementNotification, PointMeasurementValue, PointMeasurementValues } +import io.greenbus.client.service.proto.Model.ModelUUID + +import scala.collection.JavaConversions._ +import scala.concurrent.Future +import scala.concurrent.duration._ + +class CalcConfigException(msg: String) extends Exception(msg) + +object CalculationPointManager extends Logging { + + case object OnPeriodUpdate + + case class RequestsCompleted(current: Seq[PointMeasurementValue], sub: Subscription[MeasurementNotification], histories: Seq[PointMeasurementValues]) + case class RequestsFailure(ex: Throwable) + + case object ConstantValue + + sealed trait TriggerStrategy + case object OnAnyUpdate extends TriggerStrategy + case class PeriodUpdate(periodMs: Long) extends TriggerStrategy + + def props(pointUuid: ModelUUID, config: CalculationDescriptor, session: Session, opSource: OperationSource, measSink: (ModelUUID, Measurement) => Unit): Props = { + Props(classOf[CalculationPointManager], pointUuid, config, session, opSource, measSink) + } + + def buildBuckets(config: Seq[CalculationInput]): Map[ModelUUID, InputBucket] = { + config.map { calc => + val point = calc.getPointUuid + val bucket = InputBucket.build(calc) + (point, bucket) + }.toMap + } + + def getTriggerStrategy(config: CalculationDescriptor): TriggerStrategy = { + + def error = new CalcConfigException("Calculation must have a valid trigger strategy") + + if (config.hasTriggering) { + val triggering = config.getTriggering + if (triggering.hasUpdateAny && triggering.getUpdateAny) { + OnAnyUpdate + } else if (triggering.hasPeriodMs) { + PeriodUpdate(triggering.getPeriodMs) + } else { + throw error + } + } else { + throw error + } + } + + def requestForInput(calc: CalculationInput): Option[MeasurementHistoryQuery] = { + import io.greenbus.util.Optional._ + + if (calc.hasSingle || calc.hasRange && calc.getRange.hasSinceLast && calc.getRange.getSinceLast) { + None + } else { + + val range = optGet(calc.hasRange, calc.getRange) getOrElse { + throw new CalcConfigException("Non-singular calc input must include range") + } + + val pointUuid = optGet(calc.hasPointUuid, calc.getPointUuid).getOrElse { + throw new CalcConfigException("Must include point uuid for calc input") + } + + val limit = optGet(range.hasLimit, range.getLimit).getOrElse(100) + + val timeFromMs = if (range.hasFromMs) Some(range.getFromMs) else None + + val b = MeasurementHistoryQuery.newBuilder() + .setPointUuid(pointUuid) + .setLatest(true) + .setLimit(limit) + + timeFromMs.foreach(offset => b.setTimeFrom(System.currentTimeMillis() + offset)) + + Some(b.build()) + } + } + +} + +class CalculationPointManager(pointUuid: ModelUUID, config: CalculationDescriptor, session: Session, opSource: OperationSource, measSink: (ModelUUID, Measurement) => Unit) extends Actor with Logging { + import io.greenbus.calc.CalculationPointManager._ + + private var inputPoints: Map[ModelUUID, InputBucket] = buildBuckets(config.getCalcInputsList.toSeq) //Map.empty[ModelUUID, InputBucket] + + private val triggerStrategy: TriggerStrategy = getTriggerStrategy(config) + + private val evaluator: CalculationEvaluator = CalculationEvaluator.build(config, opSource) + + private var binding = Option.empty[SubscriptionBinding] + + setupData() + + def receive = { + case ConstantValue => + evaluate() + + case RequestsCompleted(current, sub, histories) => + binding = Some(sub) + + initBuckets(current, histories) + + sub.start { measEvent => + self ! measEvent + } + + logger.info(s"Calculation initialized for point ${pointUuid.getValue}") + + triggerStrategy match { + case PeriodUpdate(periodMs) => scheduleMsg(periodMs, OnPeriodUpdate) + case _ => + } + + case RequestsFailure(ex) => + logger.warn(s"Failed to get data for calculation point ${pointUuid.getValue}") + throw ex + + case m: MeasurementNotification => + val point = m.getPointUuid + val meas = m.getValue + + logger.trace(s"Got measurement update for point ${point.getValue} on ${pointUuid.getValue}") + + inputPoints.get(point) match { + case None => logger.warn("Got measurement event for point without bucket: " + m.getPointName) + case Some(current) => + inputPoints = inputPoints.updated(point, current.added(meas)) + } + + triggerStrategy match { + case PeriodUpdate(_) => + case OnAnyUpdate => evaluate() + } + + case OnPeriodUpdate => + evaluate() + + triggerStrategy match { + case PeriodUpdate(periodMs) => scheduleMsg(periodMs, OnPeriodUpdate) + case other => throw new IllegalStateException("Got period update but strategy was on any update") + } + } + + private def evaluate() { + + logger.debug(s"Evaluating - ${pointUuid.getValue}") + + val snapshot = inputPoints.mapValues(_.snapshot()) + + val containerMap = snapshot.mapValues(_._1).flatMap { case (k, vOpt) => vOpt.map(v => (k, v)) } + val updatedInputs = snapshot.mapValues(_._2) + + try { + evaluator.evaluate(containerMap) match { + case None => + case Some(result) => + inputPoints = updatedInputs + measSink(pointUuid, result) + } + } catch { + case ex: Throwable => + logger.warn("Calculation error: " + ex) + ex.printStackTrace() + measSink(pointUuid, ErrorMeasurement.build()) + } + } + + private def setupData() { + import context.dispatcher + + val inputs = config.getCalcInputsList.toSeq + + if (inputs.nonEmpty) { + + val measClient = MeasurementService.client(session) + + val allPointUuids = inputs.map(_.getPointUuid) + + val subFut = measClient.getCurrentValuesAndSubscribe(allPointUuids) + + val queries = inputs.flatMap(requestForInput) + + val histsFut: Future[Seq[PointMeasurementValues]] = Future.sequence { + queries.map(measClient.getHistory) + } + + val allFut = subFut zip histsFut + + allFut.onSuccess { + case ((current, sub), hists) => self ! RequestsCompleted(current, sub, hists) + } + subFut.onFailure { + case ex => + subFut.foreach(_._2.cancel()) + self ! RequestsFailure(ex) + } + + } else { + + self ! ConstantValue + } + } + + private def initBuckets(current: Seq[PointMeasurementValue], histories: Seq[PointMeasurementValues]) { + + val uuidToHistMap: Map[ModelUUID, Seq[Measurement]] = histories.map(pvs => (pvs.getPointUuid, pvs.getValueList.toSeq)).toMap + + val mapUpdates = current.flatMap { pv => + val point = pv.getPointUuid + val m = pv.getValue + + val histOpt = uuidToHistMap.get(point) + + val measSeq = histOpt.map { hist => + if (hist.last.getTime < m.getTime) { + hist :+ m + } else { + hist + } + } getOrElse { + Seq(m) + } + + inputPoints.get(point).map { bucket => + (point, bucket.added(measSeq)) + } + } + + inputPoints = inputPoints ++ mapUpdates + } + + override def postStop() { + logger.debug("Canceling subscription binding for " + pointUuid.getValue) + binding.foreach(_.cancel()) + } + + private def scheduleMsg(timeMs: Long, msg: AnyRef) { + import context.dispatcher + context.system.scheduler.scheduleOnce( + Duration(timeMs, MILLISECONDS), + self, + msg) + } +} \ No newline at end of file diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/CalculationEvaluator.scala b/calculation/src/main/scala/io/greenbus/calc/lib/CalculationEvaluator.scala new file mode 100755 index 0000000..50c93fe --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/CalculationEvaluator.scala @@ -0,0 +1,129 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib + +import io.greenbus.calc.CalcConfigException +import io.greenbus.calc.lib.eval.{ ValueRange, _ } +import io.greenbus.client.service.proto.Calculations.CalculationDescriptor +import io.greenbus.client.service.proto.Measurements.Measurement +import io.greenbus.client.service.proto.Model.ModelUUID + +import scala.collection.JavaConversions._ + +object CalculationEvaluator { + + def containerToOpValue(mc: MeasContainer): OperationValue = { + mc match { + case SingleMeas(m) => MeasurementConverter.convertMeasurement(m) + case MeasRange(seq) => ValueRange(seq map MeasurementConverter.convertMeasurement) + } + } + + def containerToSeq(mc: MeasContainer): Seq[Measurement] = { + mc match { + case SingleMeas(m) => Seq(m) + case MeasRange(seq) => seq + } + } + + def build(calc: CalculationDescriptor, operations: OperationSource): CalculationEvaluator = { + + val expr = if (calc.hasFormula) { + OperationParser.parseFormula(calc.getFormula) + } else { + throw new CalcConfigException("Need formula in calculation config") + } + + val formula = Formula(expr, operations) + + val qualInputStrat = if (calc.hasTriggeringQuality && calc.getTriggeringQuality.hasStrategy) { + QualityInputStrategy.build(calc.getTriggeringQuality.getStrategy) + } else { + throw new CalcConfigException("Need quality input strategy in calculation config") + } + + val qualOutputStrat = if (calc.hasQualityOutput && calc.getQualityOutput.hasStrategy) { + QualityOutputStrategy.build(calc.getQualityOutput.getStrategy) + } else { + throw new CalcConfigException("Need quality output strategy in calculation config") + } + + val varNameToPointMap = calc.getCalcInputsList.map { in => + if (!in.hasPointUuid) { + throw new CalcConfigException("Input must contain point uuid") + } + if (!in.hasVariableName) { + throw new CalcConfigException("Input must contain variable name") + } + + (in.getVariableName, in.getPointUuid) + }.toMap + + new CalculationEvaluator(formula, varNameToPointMap, qualInputStrat, qualOutputStrat) + } +} + +class CalculationEvaluator(formula: Formula, + varNameToPointMap: Map[String, ModelUUID], + qualInputStrategy: QualityInputStrategy, + qualOutputStrategy: QualityOutputStrategy) { + + import io.greenbus.calc.lib.CalculationEvaluator._ + + def evaluate(inputs: Map[ModelUUID, MeasContainer]): Option[Measurement] = { + + // Only calculate if all inputs are present and nonempty + val filteredInputs: Option[Map[ModelUUID, MeasContainer]] = + qualInputStrategy.checkInputs(inputs) + + val variablesOpt: Option[Map[String, MeasContainer]] = + filteredInputs.flatMap(mapPointsToVars) + + variablesOpt.map { variableMap => + + val inputOpValues = variableMap.mapValues(containerToOpValue) + + val result = formula.evaluate(new MappedVariableSource(inputOpValues)) + + val inputMeases = variableMap.mapValues(containerToSeq) + + val qual = qualOutputStrategy.getQuality(inputMeases) + + val time = TimeStrategy.getTime(inputMeases) + + MeasurementConverter.convertOperationValue(result) + .setQuality(qual) + .setTime(time) + .build() + } + } + + private def mapPointsToVars(inputs: Map[ModelUUID, MeasContainer]): Option[Map[String, MeasContainer]] = { + + if (varNameToPointMap.values.forall(inputs.contains)) { + val toVarNames = varNameToPointMap.map { + case (varName, pointUuid) => (varName, inputs(pointUuid)) + } + Some(toVarNames) + } else { + None + } + } +} + diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/CalculationMetrics.scala b/calculation/src/main/scala/io/greenbus/calc/lib/CalculationMetrics.scala new file mode 100755 index 0000000..70693ac --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/CalculationMetrics.scala @@ -0,0 +1,40 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib + +import io.greenbus.jmx.{ MetricsSource, Metrics } + +class CalculationMetrics(metrics: Metrics) { + + val attempts = metrics.counter("Attempts") + + val evals = metrics.counter("Evals") + + val errors = metrics.counter("Errors") + + val evalTime = metrics.timer("Time") +} + +class CalculationMetricsSource(source: MetricsSource, shared: Boolean = false) { + + private lazy val sharedSink = new CalculationMetrics(source.metrics("all")) + + def getCalcMetrics(calcName: String) = if (shared) sharedSink else new CalculationMetrics(source.metrics(calcName)) + +} \ No newline at end of file diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/ErrorMeasurement.scala b/calculation/src/main/scala/io/greenbus/calc/lib/ErrorMeasurement.scala new file mode 100644 index 0000000..8ce82c7 --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/ErrorMeasurement.scala @@ -0,0 +1,32 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib + +import io.greenbus.client.service.proto.Measurements.{ Quality, Measurement } + +object ErrorMeasurement { + def build() = { + Measurement.newBuilder + .setType(Measurement.Type.STRING) + .setStringVal("#ERROR") + .setQuality(Quality.newBuilder.setValidity(Quality.Validity.QUESTIONABLE).setSource(Quality.Source.PROCESS)) + .setTime(System.currentTimeMillis()) + .build() + } +} diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/InputBucket.scala b/calculation/src/main/scala/io/greenbus/calc/lib/InputBucket.scala new file mode 100755 index 0000000..0598a8f --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/InputBucket.scala @@ -0,0 +1,174 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib + +import io.greenbus.client.service.proto.Measurements.Measurement +import io.greenbus.calc.lib.eval.{ ValueRange, OperationValue } +import io.greenbus.client.service.proto.Calculations.CalculationInput +import scala.collection.immutable.Queue +import io.greenbus.client.service.proto.Calculations.SingleMeasurement.MeasurementStrategy + +// Difference between singular input and collection that happens to have length 1 +sealed trait MeasContainer +case class MeasRange(range: Seq[Measurement]) extends MeasContainer +case class SingleMeas(m: Measurement) extends MeasContainer + +sealed trait MeasRequest +case object SingleLatest extends MeasRequest +case class MultiSince(from: Long, limit: Int) extends MeasRequest +case class MultiLimit(count: Int) extends MeasRequest +case class SincePublishing(count: Int) extends MeasRequest + +trait InputBucket { + def added(m: Measurement): InputBucket + def snapshot(): (Option[MeasContainer], InputBucket) + + def added(seq: Seq[Measurement]): InputBucket = { + seq.foldLeft(this) { (b, m) => b.added(m) } + } +} + +object InputBucket { + + def build(calc: CalculationInput): InputBucket = { + import io.greenbus.util.Optional._ + + val limitOpt = for { + range <- optGet(calc.hasRange, calc.getRange) + limit <- optGet(range.hasLimit, range.getLimit) + } yield limit + + val limit = limitOpt.getOrElse(100) + + if (calc.hasSingle && calc.getSingle.hasStrategy) { + calc.getSingle.getStrategy match { + case MeasurementStrategy.MOST_RECENT => new SingleLatestBucket(None) + case x => throw new Exception("Unknown single measurement strategy: " + x) + } + } else if (calc.hasRange && calc.getRange.hasFromMs) { + new FromRangeBucket(Queue.empty[Measurement], SystemTimeSource, calc.getRange.getFromMs, limit) + } else if (calc.hasRange && calc.getRange.hasLimit) { + new LimitRangeBucket(Queue.empty[Measurement], limit) + } else if (calc.hasRange && calc.getRange.hasSinceLast && calc.getRange.getSinceLast) { + new NoStorageBucket(Queue.empty[Measurement], limit) + } else { + throw new Exception("Cannot build input from configuration") + } + } + + class FromRangeBucket(queue: Queue[Measurement], timeSource: TimeSource, from: Long, limit: Int, minimum: Int = 1) extends InputBucket { + + private def copy(q: Queue[Measurement]) = { + new FromRangeBucket(q, timeSource, from, limit, minimum) + } + + private def prune(current: Queue[Measurement]): Queue[Measurement] = { + val horizon = timeSource.now + from + + val extra = current.size - limit + val trimmed = if (extra > 0) { + current.drop(extra) + } else { + current + } + + trimmed.dropWhile(m => m.getTime <= horizon) + } + + def added(m: Measurement): InputBucket = { + val q = queue.enqueue(m) + new FromRangeBucket(prune(q), timeSource, from, limit) + } + + def snapshot(): (Option[MeasContainer], InputBucket) = { + val pruned = prune(queue) + + val bucket = if (pruned == queue) this else copy(pruned) + + val opVal = if (pruned.nonEmpty && pruned.size >= minimum) { + Some(MeasRange(pruned)) + } else { + None + } + + (opVal, bucket) + } + } + + class LimitRangeBucket(queue: Queue[Measurement], limit: Int, minimum: Int = 1) extends InputBucket { + + private def copy(q: Queue[Measurement]) = { + new LimitRangeBucket(q, limit, minimum) + } + + def added(m: Measurement): InputBucket = { + val q = queue.enqueue(m) + val extra = q.size - limit + val trimmed = if (extra > 0) { + q.drop(extra) + } else { + q + } + copy(trimmed) + } + + def snapshot(): (Option[MeasContainer], InputBucket) = { + val opVal = if (queue.nonEmpty && queue.size >= minimum) { + Some(MeasRange(queue)) + } else { + None + } + + (opVal, this) + } + } + + case class NoStorageBucket(queue: Queue[Measurement], limit: Int) extends InputBucket { + + def added(m: Measurement): InputBucket = { + var q = queue.enqueue(m) + while (q.size > limit) { + val (_, temp) = q.dequeue + q = temp + } + copy(q) + } + + def snapshot(): (Option[MeasContainer], InputBucket) = { + val opVal = if (queue.nonEmpty) { + Some(MeasRange(queue)) + } else { + None + } + + (opVal, copy(queue = Queue.empty[Measurement])) + } + } + + class SingleLatestBucket(meas: Option[Measurement]) extends InputBucket { + + def added(m: Measurement): InputBucket = { + new SingleLatestBucket(Some(m)) + } + + def snapshot(): (Option[MeasContainer], InputBucket) = { + (meas.map(SingleMeas), this) + } + } +} diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/MappedVariableSource.scala b/calculation/src/main/scala/io/greenbus/calc/lib/MappedVariableSource.scala new file mode 100644 index 0000000..8b9421a --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/MappedVariableSource.scala @@ -0,0 +1,30 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib + +import eval.{ ValueRange, VariableSource, OperationValue, EvalException } +import io.greenbus.client.service.proto.Measurements.Measurement + +class MappedVariableSource(map: Map[String, OperationValue]) extends VariableSource { + def forName(name: String): OperationValue = { + map.get(name).getOrElse { + throw new EvalException("Variable does not exist: " + name + " " + map) + } + } +} diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/MeasurementConverter.scala b/calculation/src/main/scala/io/greenbus/calc/lib/MeasurementConverter.scala new file mode 100755 index 0000000..0e1eae7 --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/MeasurementConverter.scala @@ -0,0 +1,61 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib + +import eval._ +import io.greenbus.client.service.proto.Measurements.Measurement + +object MeasurementConverter { + + def convertMeasurement(m: Measurement): OperationValue = { + if (m.getType == Measurement.Type.DOUBLE || (m.getType == Measurement.Type.STRING && m.hasDoubleVal)) { + NumericMeas(m.getDoubleVal, m.getTime) + } else if (m.getType == Measurement.Type.INT || (m.getType == Measurement.Type.STRING && m.hasIntVal)) { + LongMeas(m.getIntVal, m.getTime) + } else if (m.getType == Measurement.Type.BOOL || (m.getType == Measurement.Type.STRING && m.hasBoolVal)) { + BooleanMeas(m.getBoolVal, m.getTime) + } else { + throw new EvalException("Cannot use measurement as input: " + m) + } + } + + def convertOperationValue(v: OperationValue): Measurement.Builder = { + val b = Measurement.newBuilder + + // weird bug in case matching, see LongValue.unapply + v match { + case LongValue(d) => + b.setType(Measurement.Type.INT) + b.setIntVal(d) + case _ => + v match { + case NumericValue(d) => + b.setType(Measurement.Type.DOUBLE) + b.setDoubleVal(d) + case BooleanConst(bv) => + b.setType(Measurement.Type.BOOL) + b.setBoolVal(bv) + case _ => + throw new EvalException("Cannot use value as output: " + v) + } + } + + b + } +} diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/QualityInputStrategy.scala b/calculation/src/main/scala/io/greenbus/calc/lib/QualityInputStrategy.scala new file mode 100755 index 0000000..1f7aa19 --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/QualityInputStrategy.scala @@ -0,0 +1,70 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib + +import io.greenbus.client.service.proto.Calculations.InputQuality +import io.greenbus.client.service.proto.Measurements.{ Quality, Measurement } + +trait QualityInputStrategy { + def checkInputs[A](inputs: Map[A, MeasContainer]): Option[Map[A, MeasContainer]] +} + +object QualityInputStrategy { + + def build(config: InputQuality.Strategy): QualityInputStrategy = config match { + case InputQuality.Strategy.ACCEPT_ALL => AcceptAll + case InputQuality.Strategy.ONLY_WHEN_ALL_OK => WhenAllOk + case InputQuality.Strategy.REMOVE_BAD_AND_CALC => FilterOutBad + case _ => throw new Exception("Unknown quality input strategy") + } + + private def isGoodQuality(m: Measurement): Boolean = m.getQuality.getValidity == Quality.Validity.GOOD + + object AcceptAll extends QualityInputStrategy { + def checkInputs[A](inputs: Map[A, MeasContainer]): Option[Map[A, MeasContainer]] = { + Some(inputs) + } + } + + object WhenAllOk extends QualityInputStrategy { + def checkInputs[A](inputs: Map[A, MeasContainer]): Option[Map[A, MeasContainer]] = { + + val allGood = inputs.values.forall { + case SingleMeas(m) => isGoodQuality(m) + case MeasRange(seq) => seq.forall(isGoodQuality) + } + + if (allGood) Some(inputs) else None + } + } + + object FilterOutBad extends QualityInputStrategy { + def checkInputs[A](inputs: Map[A, MeasContainer]): Option[Map[A, MeasContainer]] = { + + val filtMap = inputs.flatMap { + case kv @ (_, SingleMeas(m)) => if (isGoodQuality(m)) Some(kv) else None + case kv @ (_, MeasRange(seq)) => + val filtered = seq.filter(isGoodQuality) + if (filtered.nonEmpty) Some(kv) else None + } + + Some(filtMap) + } + } +} diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/QualityOutputStrategy.scala b/calculation/src/main/scala/io/greenbus/calc/lib/QualityOutputStrategy.scala new file mode 100644 index 0000000..78711da --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/QualityOutputStrategy.scala @@ -0,0 +1,103 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib + +import io.greenbus.client.service.proto.Calculations.OutputQuality +import io.greenbus.client.service.proto.Measurements.{ DetailQual, Quality, Measurement } + +trait QualityOutputStrategy { + def getQuality(inputs: Map[String, Seq[Measurement]]): Quality +} + +object QualityOutputStrategy { + def build(config: OutputQuality.Strategy) = config match { + case OutputQuality.Strategy.ALWAYS_OK => AlwaysOk + case OutputQuality.Strategy.WORST_QUALITY => WorstQuality + case _ => throw new Exception("Unknown quality output strategy") + } + + object AlwaysOk extends QualityOutputStrategy { + def getQuality(inputs: Map[String, Seq[Measurement]]): Quality = { + Quality.newBuilder().setValidity(Quality.Validity.GOOD).setSource(Quality.Source.PROCESS).build() + } + } + + object WorstQuality extends QualityOutputStrategy { + def getQuality(inputs: Map[String, Seq[Measurement]]): Quality = { + if (inputs.nonEmpty) { + inputs.values.flatten.map(_.getQuality).reduceLeft((l, r) => merge(l, r)) + } else { + Quality.newBuilder().setValidity(Quality.Validity.GOOD).setSource(Quality.Source.PROCESS).build() + } + } + + private def merge(l: Quality, r: Quality): Quality = { + val ldetail = if (l.hasDetailQual) l.getDetailQual else DetailQual.newBuilder().build() + val rdetail = if (r.hasDetailQual) r.getDetailQual else DetailQual.newBuilder().build() + + Quality.newBuilder() + .setDetailQual(merge(ldetail, rdetail)) + .setSource(merge(l.getSource, r.getSource)) + .setValidity(merge(l.getValidity, r.getValidity)) + .setTest(mergeTest(l.getTest, r.getTest)) + .setOperatorBlocked(mergeBlocked(l.getOperatorBlocked, r.getOperatorBlocked)) + .build() + } + + private def rank(v: Quality.Validity) = v match { + case Quality.Validity.GOOD => 1 + case Quality.Validity.INVALID => 2 + case Quality.Validity.QUESTIONABLE => 3 + } + + private def merge(left: Quality.Validity, right: Quality.Validity) = { + if (rank(left) >= rank(right)) left else right + } + + private def merge(left: Quality.Source, right: Quality.Source) = { + if (left == Quality.Source.SUBSTITUTED || right == Quality.Source.SUBSTITUTED) { + Quality.Source.SUBSTITUTED + } else { + Quality.Source.PROCESS + } + } + + private def mergeTest(left: Boolean, right: Boolean) = { + left || right + } + + private def mergeBlocked(left: Boolean, right: Boolean) = { + left || right + } + + private def merge(left: DetailQual, right: DetailQual) = { + DetailQual.newBuilder() + .setBadReference(left.getBadReference || right.getBadReference) + .setOverflow(left.getOverflow || right.getOverflow) + .setOutOfRange(left.getOutOfRange || right.getOutOfRange) + .setOscillatory(left.getOscillatory || right.getOscillatory) + .setFailure(left.getFailure || right.getFailure) + .setOldData(left.getOldData || right.getOldData) + .setInconsistent(left.getInconsistent || right.getInconsistent) + .setInaccurate(left.getInaccurate || right.getInaccurate) + .build() + } + + } +} diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/TimeSource.scala b/calculation/src/main/scala/io/greenbus/calc/lib/TimeSource.scala new file mode 100755 index 0000000..6635baa --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/TimeSource.scala @@ -0,0 +1,27 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib + +trait TimeSource { + def now: Long +} + +object SystemTimeSource extends TimeSource { + def now = System.currentTimeMillis() +} \ No newline at end of file diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/TimeStrategy.scala b/calculation/src/main/scala/io/greenbus/calc/lib/TimeStrategy.scala new file mode 100644 index 0000000..74c283f --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/TimeStrategy.scala @@ -0,0 +1,35 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib + +import io.greenbus.client.service.proto.Measurements.Measurement + +object TimeStrategy { + + def getTime(inputs: Map[String, Seq[Measurement]]): Long = { + val time = inputs.values.flatten.map(_.getTime).foldLeft(0L) { + case (l, r) => if (l >= r) l else r + } + if (time != 0) { + time + } else { + System.currentTimeMillis() + } + } +} diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/eval/BasicOperationSource.scala b/calculation/src/main/scala/io/greenbus/calc/lib/eval/BasicOperationSource.scala new file mode 100755 index 0000000..bfce406 --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/eval/BasicOperationSource.scala @@ -0,0 +1,25 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib.eval + +class BasicOperationSource(ops: List[(List[String], () => Operation)]) extends OperationSource { + private val map: Map[String, () => Operation] = ops.flatMap { case (names, opFun) => names.map((_, opFun)) }.toMap + + def forName(name: String): Option[Operation] = map.get(name).map { _() } +} diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/eval/BasicOperations.scala b/calculation/src/main/scala/io/greenbus/calc/lib/eval/BasicOperations.scala new file mode 100755 index 0000000..eb6f994 --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/eval/BasicOperations.scala @@ -0,0 +1,208 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib.eval + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.calc.lib.eval.OperationPatterns._ + +object BasicOperations { + + def getSource = { + new BasicOperationSource(List( + (List("SUM", "+"), () => new Sum), + (List("-"), () => new Subtract), + (List("PRODUCT", "*"), () => new Product), + (List("QUOTIENT", "/"), () => new Divide), + (List("POWER", "^"), () => new Power), + (List("AVERAGE"), () => new Average), + (List("MAX"), () => new Max), + (List("MIN"), () => new Min), + (List("ABS"), () => new Abs), + (List("GT"), () => new Greater), + (List("GTE"), () => new Gte), + (List("LT"), () => new Less), + (List("LTE"), () => new Lte), + (List("EQ"), () => new Eq), + (List("SQRT"), () => new SquareRoot), + (List("COUNT"), () => new Count), + (List("AND"), () => new And), + (List("OR"), () => new Or), + (List("NOT"), () => new Not), + (List("IF"), () => new If), + (List("LATEST"), () => new Latest), + (List("INTEGRATE"), () => new Integrate))) + } + + class Sum extends MultiNumericOperation { + def eval(args: List[Double]): Double = { + args.foldLeft(0.0) { _ + _ } + } + } + + class Subtract extends PairNumericOperation { + def eval(l: Double, r: Double): Double = { l - r } + } + + class Product extends MultiNumericOperation { + def eval(args: List[Double]): Double = { + args.reduceLeft(_ * _) + } + } + + class Divide extends PairNumericOperation { + def eval(l: Double, r: Double): Double = { l / r } + } + + class Power extends PairNumericOperation { + def eval(l: Double, r: Double): Double = { math.pow(l, r) } + } + + class Average extends MultiNumericOperation { + def eval(args: List[Double]): Double = { + args.foldLeft(0.0) { _ + _ } / args.size + } + } + + class Max extends MultiNumericOperation { + def eval(args: List[Double]): Double = { + args.foldLeft(Option.empty[Double]) { (result, value) => + result match { + case Some(last) => Some(if (last > value) last else value) + case None => Some(value) + } + }.get + } + } + + class Min extends MultiNumericOperation { + def eval(args: List[Double]): Double = { + args.foldLeft(Option.empty[Double]) { (result, value) => + result match { + case Some(last) => Some(if (last < value) last else value) + case None => Some(value) + } + }.get + } + } + + class Abs extends SingleNumericOperation { + def eval(v: Double): Double = math.abs(v) + } + + class Greater extends ConditionalPairNumericOperation { + def eval(l: Double, r: Double) = l > r + } + + class Gte extends ConditionalPairNumericOperation { + def eval(l: Double, r: Double) = l >= r + } + + class Less extends ConditionalPairNumericOperation { + def eval(l: Double, r: Double) = l < r + } + + class Lte extends ConditionalPairNumericOperation { + def eval(l: Double, r: Double) = l <= r + } + + class Eq extends ConditionalPairNumericOperation { + def eval(l: Double, r: Double) = l == r + } + + class SquareRoot extends SingleNumericOperation { + def eval(v: Double): Double = math.sqrt(v) + } + + class Not extends SingleBooleanOperation { + def eval(arg: Boolean) = !arg + } + + class And extends BooleanReduceOperation { + def eval(args: List[Boolean]) = { + args.foldLeft(true) { case (out, v) => out && v } + } + } + + class Or extends BooleanReduceOperation { + def eval(args: List[Boolean]) = { + args.foldLeft(false) { case (out, v) => out || v } + } + } + + class Count extends BooleanFoldOperation { + def eval(args: List[Boolean]) = { + args.foldLeft(0) { case (sum, v) => sum + (if (v) 1 else 0) } + } + } + + class If extends AbstractOperation { + def apply(args: List[OperationValue]): OperationValue = { + args match { + case List(condition, consequent, alternative) => { + condition match { + case BooleanValue(conditionResult) => { + if (conditionResult) consequent else alternative + } + case _ => throw new EvalException("If statement condition must be a boolean expression.") + } + } + case _ => throw new EvalException("If statement must take three arguments: IF(condition, value if true, value if false)") + } + } + } + + class Integrate extends AccumulatedNumericOperation with Logging { + def eval(initialValue: AccumulatedValue, args: List[NumericMeas]) = { + args.foldLeft(initialValue.copy(value = 0)) { + case (state, meas) => + state.lastMeas match { + case Some(NumericMeas(v, t)) => + + val time = meas.time - t + if (time < 0) { + logger.warn("Measurements out of order. new: " + meas.time + " previous: " + t + " delta: " + time) + state + } else { + val area = if (time > 0) ((meas.doubleValue + v) * time) / 2 + else 0 + state.copy(lastMeas = Some(meas), value = state.value + area) + } + case None => + state.copy(lastMeas = Some(meas)) + } + } + } + } + + class Latest extends AbstractOperation { + def apply(args: List[OperationValue]): OperationValue = { + val measurements = args.flatMap { + case m: MeasuredValue => Some(m) + case _ => None + } + + if (measurements.nonEmpty) { + measurements.maxBy(_.time) + } else { + throw new EvalException("Latest function must have measurements as inputs") + } + } + + } +} diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/eval/EvalException.scala b/calculation/src/main/scala/io/greenbus/calc/lib/eval/EvalException.scala new file mode 100644 index 0000000..fe20e3c --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/eval/EvalException.scala @@ -0,0 +1,21 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib.eval + +class EvalException(s: String) extends Exception(s) diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/eval/Expression.scala b/calculation/src/main/scala/io/greenbus/calc/lib/eval/Expression.scala new file mode 100755 index 0000000..5738f95 --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/eval/Expression.scala @@ -0,0 +1,27 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib.eval + +trait Expression { + def prepare(ops: OperationSource): PreparedExpression +} + +trait PreparedExpression { + def evaluate(inputs: VariableSource): OperationValue +} diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/eval/Formula.scala b/calculation/src/main/scala/io/greenbus/calc/lib/eval/Formula.scala new file mode 100755 index 0000000..b816f82 --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/eval/Formula.scala @@ -0,0 +1,48 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib.eval + +trait Formula { + def evaluate(inputs: VariableSource): OperationValue +} + +class AccumulatedFormula(var lastValue: OperationValue, formula: Formula) extends Formula { + + def evaluate(inputs: VariableSource) = { + val incrementalValue = formula.evaluate(inputs) + val result = OperationValue.combine(lastValue, incrementalValue) + lastValue = result + result + } +} + +object Formula { + def apply(expr: Expression, opSource: OperationSource) = { + new BasicFormulaEvaluator(expr, opSource) + } + + class BasicFormulaEvaluator(expr: Expression, opSource: OperationSource) extends Formula { + + private val prepared = expr.prepare(opSource) + + def evaluate(inputs: VariableSource): OperationValue = { + prepared.evaluate(inputs) + } + } +} diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/eval/Operation.scala b/calculation/src/main/scala/io/greenbus/calc/lib/eval/Operation.scala new file mode 100755 index 0000000..5dbc666 --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/eval/Operation.scala @@ -0,0 +1,42 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib.eval +/** + * Copyright 2011 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +trait Operation { + + def apply(args: List[OperationValue]): OperationValue +} diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/eval/OperationInterpreter.scala b/calculation/src/main/scala/io/greenbus/calc/lib/eval/OperationInterpreter.scala new file mode 100755 index 0000000..0dd87d9 --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/eval/OperationInterpreter.scala @@ -0,0 +1,79 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib.eval + +object OperationInterpreter { + + private def getOrElse(ops: OperationSource, name: String): Operation = { + ops.forName(name).getOrElse { throw new EvalException("Operation not found: " + name) } + } + + case class Fun(fun: String, args: List[Expression]) extends Expression { + def prepare(ops: OperationSource): PreparedExpression = { + PreparedFun(getOrElse(ops, fun), args.map { _.prepare(ops) }) + } + } + + case class PreparedFun(operation: Operation, args: List[PreparedExpression]) extends PreparedExpression { + def evaluate(inputs: VariableSource) = { + operation.apply(args.map(_.evaluate(inputs)).flatMap(_.toSeq)) + } + } + + case class Infix(op: String, left: Expression, right: Expression) extends Expression { + def prepare(ops: OperationSource): PreparedExpression = { + PreparedInfix(getOrElse(ops, op), left.prepare(ops), right.prepare(ops)) + } + } + + case class PreparedInfix(operation: Operation, left: PreparedExpression, right: PreparedExpression) extends PreparedExpression { + def evaluate(inputs: VariableSource) = { + operation.apply(List(left.evaluate(inputs), right.evaluate(inputs)).flatMap(_.toSeq)) + } + } + + trait SimpleExpr extends Expression with PreparedExpression { + def prepare(ops: OperationSource) = this + } + + case class ConstDouble(v: Double) extends SimpleExpr { + def evaluate(inputs: VariableSource): OperationValue = { + NumericConst(v) + } + } + + case class ConstLong(v: Long) extends SimpleExpr { + def evaluate(inputs: VariableSource): OperationValue = { + LongConst(v) + } + } + + case class ConstBoolean(v: Boolean) extends SimpleExpr { + def evaluate(inputs: VariableSource): OperationValue = { + BooleanConst(v) + } + } + + case class Var(name: String) extends SimpleExpr { + def evaluate(inputs: VariableSource): OperationValue = { + inputs.forName(name) + } + } + +} diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/eval/OperationParser.scala b/calculation/src/main/scala/io/greenbus/calc/lib/eval/OperationParser.scala new file mode 100755 index 0000000..c19717a --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/eval/OperationParser.scala @@ -0,0 +1,86 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib.eval + +import util.parsing.combinator.JavaTokenParsers + +/** + * expr :: = mult { "+" mult | "-" mult } + * mult :: = exp { "*" fac | "/" fac | "%" fac } + * exp ::= leaf { "^" leaf } + * leaf ::= const | fun | var | "(" expr ")" + * fun ::= ident "(" csv ")" + * csv ::= expr { "," expr } + * + */ +object OperationParser extends JavaTokenParsers { + + import OperationInterpreter._ + + def expr: Parser[Expression] = mult ~ rep("+" ~ mult ^^ part | "-" ~ mult ^^ part) ^^ multiInfix + + def mult: Parser[Expression] = exp ~ rep("*" ~ exp ^^ part | "/" ~ exp ^^ part) ^^ multiInfix + + def exp: Parser[Expression] = leaf ~ rep("^" ~ leaf ^^ part) ^^ multiInfix + + def leaf: Parser[Expression] = constants | fun | variable | "(" ~> expr <~ ")" + + def fun: Parser[Expression] = ident ~ ("(" ~> csv <~ ")") ^^ { + case f ~ args => Fun(f, args) + } + + def csv: Parser[List[Expression]] = expr ~ rep("," ~> expr) ^^ { + case head ~ tail => head :: tail + } + + def variable: Parser[Expression] = ident ^^ { + Var(_) + } + + def constants: Parser[Expression] = doubleConst ||| longConst ||| boolConst + + def longConst: Parser[Expression] = wholeNumber ^^ { x => + ConstLong(x.toLong) + } + def doubleConst: Parser[Expression] = (decimalNumber | floatingPointNumber) ^^ { x => + ConstDouble(x.toDouble) + } + def boolConst: Parser[Expression] = """true|false""".r ^^ { x => + ConstBoolean(x.toBoolean) + } + + case class Part(op: String, right: Expression) + + def part: ~[String, Expression] => Part = { + case op ~ r => Part(op, r) + } + + def multiInfix: ~[Expression, List[Part]] => Expression = { + case n ~ Nil => n + case l ~ reps => reps.foldLeft(l) { + case (l, part) => Infix(part.op, l, part.right) + } + } + + def parseFormula(formula: String): Expression = { + parseAll(expr, formula) getOrElse { + throw new Exception("Bad Parse: " + formula) + } + } +} diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/eval/OperationPatterns.scala b/calculation/src/main/scala/io/greenbus/calc/lib/eval/OperationPatterns.scala new file mode 100755 index 0000000..a7bd777 --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/eval/OperationPatterns.scala @@ -0,0 +1,139 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib.eval + +object OperationPatterns { + trait AbstractOperation extends Operation { + protected val name = this.getClass.getSimpleName + } + + trait MultiNumericOperation extends AbstractOperation { + def apply(args: List[OperationValue]): OperationValue = { + if (args.size > 0) { + val nums = args.map { + case NumericValue(v) => v + case _ => throw new EvalException("Operation " + name + " only takes numeric values") + } + NumericConst(eval(nums)) + } else { + throw new EvalException("Operation " + name + " requires one or more value") + } + } + + def eval(args: List[Double]): Double + } + + trait PairNumericOperation extends AbstractOperation { + def apply(args: List[OperationValue]): OperationValue = { + args match { + case List(NumericValue(l), NumericValue(r)) => NumericConst(eval(l, r)) + case _ => throw new EvalException("Operation " + name + " requires exactly two numeric values") + } + } + + def eval(l: Double, r: Double): Double + } + + trait ConditionalPairNumericOperation extends AbstractOperation { + def apply(args: List[OperationValue]): OperationValue = { + args match { + case List(NumericValue(l), NumericValue(r)) => BooleanConst(eval(l, r)) + case _ => throw new EvalException("Operation " + name + " requires exactly two numeric values") + } + } + + def eval(l: Double, r: Double): Boolean + } + + trait SingleNumericOperation extends AbstractOperation { + def apply(args: List[OperationValue]): OperationValue = { + args match { + case List(NumericValue(v)) => NumericConst(eval(v)) + case _ => throw new EvalException("Operation " + name + " requires one numeric value") + } + } + + def eval(v: Double): Double + } + + abstract class BooleanFoldOperation extends AbstractOperation { + def apply(args: List[OperationValue]): OperationValue = { + if (args.size > 0) { + val vals = args.map { + case BooleanValue(v) => v + case _ => throw new EvalException("Operation " + name + " only takes boolean values") + } + LongConst(eval(vals)) + } else { + throw new EvalException("Operation " + name + " requires one or more value") + } + } + + def eval(args: List[Boolean]): Int + } + + abstract class SingleBooleanOperation extends AbstractOperation { + def apply(args: List[OperationValue]): OperationValue = { + args match { + case List(BooleanValue(v)) => BooleanConst(eval(v)) + case _ => throw new EvalException("Operation " + name + " requires one boolean value") + } + } + + def eval(arg: Boolean): Boolean + } + + abstract class BooleanReduceOperation extends AbstractOperation { + def apply(args: List[OperationValue]): OperationValue = { + if (args.size > 0) { + val nums = args.map { + case BooleanValue(v) => v + case _ => throw new EvalException("Operation " + name + " only takes boolean values") + } + BooleanConst(eval(nums)) + } else { + throw new EvalException("Operation " + name + " requires one or more value") + } + } + + def eval(args: List[Boolean]): Boolean + } + + trait AccumulatedNumericOperation extends AbstractOperation { + + case class AccumulatedValue(value: Double, lastMeas: Option[NumericMeas]) + + var accumulatedValue = AccumulatedValue(0, None) + + def apply(args: List[OperationValue]): OperationValue = { + if (args.size > 0) { + val nums = args.map { + case NumericMeas(v, t) => NumericMeas(v, t) + case _ => throw new EvalException("Operation " + name + " only takes numeric values") + } + accumulatedValue = eval(accumulatedValue, nums) + NumericConst(accumulatedValue.value) + } else { + NumericConst(accumulatedValue.value) + } + } + + def eval(initialValue: AccumulatedValue, args: List[NumericMeas]): AccumulatedValue + } +} diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/eval/OperationSource.scala b/calculation/src/main/scala/io/greenbus/calc/lib/eval/OperationSource.scala new file mode 100644 index 0000000..993877c --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/eval/OperationSource.scala @@ -0,0 +1,41 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib.eval +/** + * Copyright 2011 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +trait OperationSource { + def forName(name: String): Option[Operation] +} diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/eval/OperationValue.scala b/calculation/src/main/scala/io/greenbus/calc/lib/eval/OperationValue.scala new file mode 100755 index 0000000..e21ac6d --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/eval/OperationValue.scala @@ -0,0 +1,92 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib.eval +/** + * Copyright 2011 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +sealed trait OperationValue { + def toSeq: Seq[OperationValue] = List(this) +} +object OperationValue { + def combine(a1: OperationValue, a2: OperationValue): OperationValue = { + (a1, a2) match { + case (NumericValue(v1), NumericValue(v2)) => NumericConst(v1 + v2) + case _ => throw new EvalException("Cannot combine " + a1 + " and " + a2) + } + } +} + +sealed trait MeasuredValue { + val time: Long +} + +case class ValueRange(list: Seq[OperationValue]) extends OperationValue { + override def toSeq: Seq[OperationValue] = list +} + +trait NumericValue extends OperationValue { + def doubleValue: Double +} +object NumericValue { + def unapply(v: NumericValue): Option[Double] = Some(v.doubleValue) +} +case class NumericConst(doubleValue: Double) extends NumericValue +case class NumericMeas(doubleValue: Double, time: Long) extends NumericValue with MeasuredValue + +// we can have both classes defined but can only match on one at a time. +// weird bug where you can't match on both types in same match block +// https://issues.scala-lang.org/browse/SI-5081 +// https://issues.scala-lang.org/browse/SI-4832 +trait LongValue extends NumericValue { + def longValue: Long + def doubleValue = longValue.toDouble +} +object LongValue { + def unapply(v: LongValue): Option[Long] = Some(v.longValue) +} + +case class LongConst(longValue: Long) extends LongValue +case class LongMeas(longValue: Long, time: Long) extends LongValue with MeasuredValue + +trait BooleanValue extends OperationValue { + def value: Boolean +} +object BooleanValue { + def unapply(v: BooleanValue): Option[Boolean] = Some(v.value) +} + +case class BooleanConst(value: Boolean) extends BooleanValue +case class BooleanMeas(value: Boolean, time: Long) extends BooleanValue with MeasuredValue + diff --git a/calculation/src/main/scala/io/greenbus/calc/lib/eval/VariableSource.scala b/calculation/src/main/scala/io/greenbus/calc/lib/eval/VariableSource.scala new file mode 100644 index 0000000..bfe550a --- /dev/null +++ b/calculation/src/main/scala/io/greenbus/calc/lib/eval/VariableSource.scala @@ -0,0 +1,41 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib.eval +/** + * Copyright 2011 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +trait VariableSource { + def forName(name: String): OperationValue +} diff --git a/calculation/src/test/scala/io/greenbus/calc/lib/CalcLibTestHelpers.scala b/calculation/src/test/scala/io/greenbus/calc/lib/CalcLibTestHelpers.scala new file mode 100755 index 0000000..26e12c9 --- /dev/null +++ b/calculation/src/test/scala/io/greenbus/calc/lib/CalcLibTestHelpers.scala @@ -0,0 +1,39 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib + +import io.greenbus.client.service.proto.Measurements.{ Quality, Measurement } + +object CalcLibTestHelpers { + + def makeTraceMeas(v: Int, time: Long = 0) = { + Measurement.newBuilder() + .setType(Measurement.Type.INT) + .setIntVal(v) + .setQuality(Quality.newBuilder) + .setTime(time) + .build + } + + class MockTimeSource(var time: Long = 0) extends TimeSource { + + def now = time + } +} + diff --git a/calculation/src/test/scala/io/greenbus/calc/lib/InputBucketTest.scala b/calculation/src/test/scala/io/greenbus/calc/lib/InputBucketTest.scala new file mode 100755 index 0000000..744b983 --- /dev/null +++ b/calculation/src/test/scala/io/greenbus/calc/lib/InputBucketTest.scala @@ -0,0 +1,146 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib + +import org.scalatest.FunSuite +import org.scalatest.matchers.ShouldMatchers +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner +import io.greenbus.client.service.proto.Measurements.{ Quality, Measurement } +import scala.collection.immutable.Queue + +@RunWith(classOf[JUnitRunner]) +class InputBucketTest extends FunSuite with ShouldMatchers { + import InputBucket._ + + import CalcLibTestHelpers._ + + class MutableBucketFacade(protected var bucket: InputBucket) { + def onReceived(m: Measurement) { + bucket = bucket.added(m) + } + def getSnapshot: Option[MeasContainer] = { + val (contOpt, buck) = bucket.snapshot() + bucket = buck + contOpt + } + } + + test("SinceLastBucket") { + val buck = new MutableBucketFacade(new SingleLatestBucket(None)) + buck.getSnapshot should equal(None) + + val first = makeTraceMeas(1) + buck.onReceived(first) + buck.getSnapshot should equal(Some(SingleMeas(first))) + + val second = makeTraceMeas(2) + buck.onReceived(second) + buck.getSnapshot should equal(Some(SingleMeas(second))) + } + + test("Limit Bucket for last two values") { + val buck = new MutableBucketFacade(new LimitRangeBucket(Queue.empty[Measurement], 2, 2)) + buck.getSnapshot should equal(None) + + val first = makeTraceMeas(1) + buck.onReceived(first) + buck.getSnapshot should equal(None) + + val second = makeTraceMeas(2) + buck.onReceived(second) + buck.getSnapshot should equal(Some(MeasRange(List(first, second)))) + + val third = makeTraceMeas(3) + buck.onReceived(third) + buck.getSnapshot should equal(Some(MeasRange(List(second, third)))) + } + + test("Limit Bucket with upto 100 values") { + val buck = new MutableBucketFacade(new LimitRangeBucket(Queue.empty[Measurement], 100)) + buck.getSnapshot should equal(None) + + val values = (0 to 199).map { i => makeTraceMeas(i) } + + (0 to 99).foreach { i => + buck.onReceived(values(i)) + buck.getSnapshot should equal(Some(MeasRange(values.slice(0, i + 1)))) + } + (100 to 199).foreach { i => + buck.onReceived(values(i)) + buck.getSnapshot should equal(Some(MeasRange(values.slice(i - 99, i + 1)))) + } + } + + test("Time Bucket with upto 100 values") { + val timeSource = new MockTimeSource(0) + val buck = new MutableBucketFacade(new FromRangeBucket(Queue.empty[Measurement], timeSource, -1000, 100)) + buck.getSnapshot should equal(None) + + val values = (0 to 199).map { i => makeTraceMeas(i, 0) } + + (0 to 99).foreach { i => + buck.onReceived(values(i)) + buck.getSnapshot should equal(Some(MeasRange(values.slice(0, i + 1)))) + } + (100 to 199).foreach { i => + buck.onReceived(values(i)) + buck.getSnapshot should equal(Some(MeasRange(values.slice(i - 99, i + 1)))) + } + } + + test("Time Bucket with expiring measurements") { + val timeSource = new MockTimeSource(0) + val buck = new MutableBucketFacade(new FromRangeBucket(Queue.empty[Measurement], timeSource, -100, 10000)) + buck.getSnapshot should equal(None) + + val values = (0 to 199).map { i => makeTraceMeas(i, i) } + + (0 to 99).foreach { i => + timeSource.time = i + buck.onReceived(values(i)) + buck.getSnapshot should equal(Some(MeasRange(values.slice(0, i + 1)))) + } + (100 to 199).foreach { i => + timeSource.time = i + buck.onReceived(values(i)) + buck.getSnapshot should equal(Some(MeasRange(values.slice(i - 99, i + 1)))) + } + + timeSource.time = 250 + buck.getSnapshot should equal(Some(MeasRange(values.slice(151, 200)))) + + timeSource.time = 298 + buck.getSnapshot should equal(Some(MeasRange(values.slice(199, 200)))) + + timeSource.time = 299 + buck.getSnapshot should equal(None) + } + + test("No Storage Bucket") { + val buck = new MutableBucketFacade(new NoStorageBucket(Queue.empty[Measurement], 10000)) + buck.getSnapshot should equal(None) + + val first = makeTraceMeas(1) + buck.onReceived(first) + buck.getSnapshot should equal(Some(MeasRange(List(first)))) + buck.getSnapshot should equal(None) + } +} + diff --git a/calculation/src/test/scala/io/greenbus/calc/lib/eval/OperationIntegrationTest.scala b/calculation/src/test/scala/io/greenbus/calc/lib/eval/OperationIntegrationTest.scala new file mode 100755 index 0000000..a1f7391 --- /dev/null +++ b/calculation/src/test/scala/io/greenbus/calc/lib/eval/OperationIntegrationTest.scala @@ -0,0 +1,299 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib.eval + +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner +import org.scalatest.FunSuite +import org.scalatest.matchers.ShouldMatchers + +@RunWith(classOf[JUnitRunner]) +class OperationIntegrationTest extends FunSuite with ShouldMatchers { + + def parseFormula(f: String): Formula = { + Formula(OperationParser.parseFormula(f), BasicOperations.getSource) + } + + test("Constant value") { + val f = "8.443" + val expr = parseFormula(f) + val result = NumericConst(8.443) + + expr.evaluate(new ValueMap(Map())) should equal(result) + } + + test("Simple math") { + val f = "5 * 2" + val expr = parseFormula(f) + val result = NumericConst(10.0) + + expr.evaluate(new ValueMap(Map())) should equal(result) + } + + test("Average") { + val f = "B + AVERAGE(A)" + val expr = parseFormula(f) + val result = NumericConst(11.5) + + val doubleValues = Map("B" -> 1.5, "A" -> ValueRange(List(NumericConst(5.0), NumericConst(10.0), NumericConst(15.0)))) + expr.evaluate(new ValueMap(doubleValues)) should equal(result) + + // show that math works when we pass in Longs and cast them up to doubles + val longValues = Map("B" -> 1.5, "A" -> ValueRange(List(LongConst(5), LongConst(10), LongConst(15)))) + expr.evaluate(new ValueMap(longValues)) should equal(result) + } + + test("Numeric MAX") { + + val tests = List( + (5.0, List(5.0)), + (10.0, List(10.0, 5.0)), + (-10.0, List(-10.0, -50.0)), + (50.0, List(50.0, 49.9, 49.8))) + + testNumeric("MAX(A)", tests) + } + + test("Numeric MIN") { + + val tests = List( + (5.0, List(5.0)), + (5.0, List(10.0, 5.0)), + (-50.0, List(-10.0, -50.0)), + (49.8, List(50.0, 49.9, 49.8))) + + testNumeric("MIN(A)", tests) + } + + test("Numeric ABS") { + + val tests = List( + (5.0, List(5.0)), + (5.0, List(-5.0))) + + testNumeric("ABS(A)", tests) + } + + test("Boolean AND") { + + val tests = List( + ("AND(true)", BooleanConst(true)), + ("AND(false)", BooleanConst(false)), + ("AND(true,true)", BooleanConst(true)), + ("AND(true,false)", BooleanConst(false)), + ("AND(false,false)", BooleanConst(false))) + + testWithoutValues(tests) + } + + test("Boolean OR") { + + val tests = List( + ("OR(true)", BooleanConst(true)), + ("OR(false)", BooleanConst(false)), + ("OR(true,true)", BooleanConst(true)), + ("OR(true,false)", BooleanConst(true)), + ("OR(false,false)", BooleanConst(false))) + + testWithoutValues(tests) + } + + test("Boolean NOT") { + + val tests = List( + ("NOT(true)", BooleanConst(false)), + ("NOT(false)", BooleanConst(true))) + + testWithoutValues(tests) + + val errorTests = List( + ("NOT(true,true)", "requires one")) + + testErrorsWithoutValues(errorTests) + } + + test("Boolean COUNT") { + + val tests = List( + ("COUNT(true)", LongConst(1)), + ("COUNT(false)", LongConst(0)), + ("COUNT(true,true)", LongConst(2)), + ("COUNT(true,false)", LongConst(1)), + ("COUNT(false,false)", LongConst(0))) + + testWithoutValues(tests) + } + + test("Boolean GT") { + + val tests = List( + ("GT(0,0)", BooleanConst(false)), + ("GT(1,0)", BooleanConst(true)), + ("GT(5.5,5.4)", BooleanConst(true)), + ("GT(-19,-25)", BooleanConst(true)), + ("GT(-25,-20)", BooleanConst(false)), + ("GT(4,7)", BooleanConst(false))) + + testWithoutValues(tests) + } + + test("Boolean GTE") { + + val tests = List( + ("GTE(0,0)", BooleanConst(true)), + ("GTE(1,0)", BooleanConst(true)), + ("GTE(5.5,5.4)", BooleanConst(true)), + ("GTE(-19,-25)", BooleanConst(true)), + ("GTE(-25,-20)", BooleanConst(false)), + ("GTE(4,7)", BooleanConst(false))) + + testWithoutValues(tests) + } + + test("Boolean LT") { + + val tests = List( + ("LT(0,0)", BooleanConst(false)), + ("LT(1,0)", BooleanConst(false)), + ("LT(5.5,5.4)", BooleanConst(false)), + ("LT(-19,-25)", BooleanConst(false)), + ("LT(-25,-20)", BooleanConst(true)), + ("LT(4,7)", BooleanConst(true))) + + testWithoutValues(tests) + } + + test("Boolean LTE") { + + val tests = List( + ("LTE(0,0)", BooleanConst(true)), + ("LTE(1,0)", BooleanConst(false)), + ("LTE(5.5,5.4)", BooleanConst(false)), + ("LTE(-19,-25)", BooleanConst(false)), + ("LTE(-25,-20)", BooleanConst(true)), + ("LTE(4,7)", BooleanConst(true))) + + testWithoutValues(tests) + } + + test("Boolean EQ") { + + val tests = List( + ("EQ(0,0)", BooleanConst(true)), + ("EQ(-1,-1)", BooleanConst(true)), + ("EQ(5.5,5.5)", BooleanConst(true)), + ("EQ(5.5,5.4)", BooleanConst(false)), + ("EQ(-19,-25)", BooleanConst(false))) + + testWithoutValues(tests) + } + + test("Boolean IF") { + + val tests = List( + ("IF(true,3,4)", LongConst(3)), + ("IF(false,3,4)", LongConst(4))) + + testWithoutValues(tests) + + val errorTests = List( + ("IF(1.0,3,4)", "condition must be a boolean expression"), + ("IF(3,4)", ""), + ("IF(true,4)", ""), + ("IF(true)", "")) + + testErrorsWithoutValues(errorTests) + } + + test("Numeric INTEGRATE") { + + val f = "INTEGRATE(A)" + + val tests = List( + (20 * 5.0, List((5.0, 0), (5.0, 10), (5.0, 20))), + (0.0, List((0.0, 0), (0.0, 10), (0.0, 20))), + (100.0, List((0.0, 0), (5.0, 10), (10.0, 20))), + (100.0, List((10.0, 0), (5.0, 10), (0.0, 20))), + (300.0, List((10.0, 0), (10.0, 10), (20.0, 10), (20.0, 20))), + (5.0 * 10, List((5.0, 10), (300.0, 8), (5.0, 20)))) // Ignore out of order + + tests.foreach { + case (output, inputs) => + val values = Map("A" -> ValueRange(inputs.map { v => NumericMeas(v._1, v._2) })) + val result = NumericConst(output) + val expr = parseFormula(f) + expr.evaluate(new ValueMap(values)) should equal(result) + } + } + + test("Numeric INTEGRATE (Accumulated)") { + + val f = "INTEGRATE(A)" + val expr = new AccumulatedFormula(NumericConst(0), parseFormula(f)) + + val tests = List( + (10.0, List((5.0, 0), (5.0, 1), (5.0, 2))), + (30.0, List((10.0, 2), (10.0, 3), (10.0, 4))), + (30.0, List((20.0, 4))), + (50.0, List((20.0, 5)))) + + tests.foreach { + case (output, inputs) => + val values = Map("A" -> ValueRange(inputs.map { v => NumericMeas(v._1, v._2) })) + val result = NumericConst(output) + expr.evaluate(new ValueMap(values)) should equal(result) + } + } + + private def testErrorsWithoutValues(errorTests: List[(String, String)]) { + errorTests.foreach { + case (f, errString) => + val expr = parseFormula(f) + intercept[EvalException] { + expr.evaluate(new ValueMap(Map())) + }.getMessage should include(errString) + } + } + + private def testWithValues(map: ValueMap, tests: List[(String, OperationValue)]) { + tests.foreach { + case (f, result) => + val expr = parseFormula(f) + expr.evaluate(map) should equal(result) + } + } + + private def testWithoutValues(tests: List[(String, OperationValue)]) { + tests.foreach { + case (f, result) => + val expr = parseFormula(f) + expr.evaluate(new ValueMap(Map())) should equal(result) + } + } + + private def testNumeric(f: String, tests: List[(Double, List[Double])]) { + tests.foreach { + case (output, inputs) => + val values = Map("A" -> ValueRange(inputs.map { NumericConst(_) })) + val result = NumericConst(output) + val expr = parseFormula(f) + expr.evaluate(new ValueMap(values)) should equal(result) + } + } +} diff --git a/calculation/src/test/scala/io/greenbus/calc/lib/eval/OperationInterpreterTest.scala b/calculation/src/test/scala/io/greenbus/calc/lib/eval/OperationInterpreterTest.scala new file mode 100755 index 0000000..a730b0d --- /dev/null +++ b/calculation/src/test/scala/io/greenbus/calc/lib/eval/OperationInterpreterTest.scala @@ -0,0 +1,78 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib.eval + +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner +import org.scalatest.FunSuite +import org.scalatest.matchers.ShouldMatchers + +@RunWith(classOf[JUnitRunner]) +class OperationInterpreterTest extends FunSuite with ShouldMatchers { + import OperationInterpreter._ + + class OpMap(map: Map[String, Operation]) extends OperationSource { + def forName(name: String): Option[Operation] = map.get(name) + } + + class Op(n: String, f: List[OperationValue] => OperationValue) extends Operation { + def names = List(n) + def apply(inputs: List[OperationValue]): OperationValue = f(inputs) + } + + test("FunTest") { + + val exp = Fun("SUM", List(Var("A"), Var("B"), ConstDouble(5.0))) + + val ins = Map("A" -> NumericConst(2.0), "B" -> NumericConst(3.0)) + + def check(inputs: List[OperationValue]): OperationValue = inputs match { + case List(NumericConst(2.0), NumericConst(3.0), NumericConst(5.0)) => NumericConst(10.0) + case _ => throw new Exception("not matching") + } + + val ops = new OpMap(Map("SUM" -> new Op("SUM", check))) + + exp.prepare(ops).evaluate(new ValueMap(ins)) should equal(NumericConst(10.0)) + } + + test("Nesting") { + + val exp = Fun("SUM", List(Var("A"), Var("B"), Infix("*", Var("C"), Var("D")))) + + val ins = Map( + "A" -> NumericConst(2.0), + "B" -> NumericConst(3.0), + "C" -> NumericConst(5.0), + "D" -> NumericConst(7.0)) + + def sum(inputs: List[OperationValue]): OperationValue = inputs match { + case List(NumericConst(2.0), NumericConst(3.0), NumericConst(35.0)) => NumericConst(40.0) + case _ => throw new Exception("wrong") + } + def mult: List[OperationValue] => OperationValue = { + case List(NumericConst(5.0), NumericConst(7.0)) => NumericConst(35.0) + case _ => throw new Exception("wrong") + } + + val ops = new OpMap(Map("SUM" -> new Op("SUM", sum), "*" -> new Op("*", mult))) + + exp.prepare(ops).evaluate(new ValueMap(ins)) should equal(NumericConst(40.0)) + } +} diff --git a/calculation/src/test/scala/io/greenbus/calc/lib/eval/OperationParserTest.scala b/calculation/src/test/scala/io/greenbus/calc/lib/eval/OperationParserTest.scala new file mode 100755 index 0000000..5192609 --- /dev/null +++ b/calculation/src/test/scala/io/greenbus/calc/lib/eval/OperationParserTest.scala @@ -0,0 +1,57 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib.eval + +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner +import org.scalatest.FunSuite +import org.scalatest.matchers.ShouldMatchers + +@RunWith(classOf[JUnitRunner]) +class OperationParserTest extends FunSuite with ShouldMatchers { + import OperationInterpreter._ + + test("Recursive Nesting") { + + val f = "5.5 + A * AVG(B, 5 + C, SUM(D, 2.2, 3))" + + val tree = Infix("+", ConstDouble(5.5), Infix("*", Var("A"), Fun("AVG", List(Var("B"), Infix("+", ConstLong(5), Var("C")), Fun("SUM", List(Var("D"), ConstDouble(2.2), ConstLong(3))))))) + + OperationParser.parseFormula(f) should equal(tree) + } + + test("Precedence") { + + val f = "5 + 3 / 4 + 7.7" + + val tree = Infix("+", Infix("+", ConstLong(5), Infix("/", ConstLong(3), ConstLong(4))), ConstDouble(7.7)) + + OperationParser.parseFormula(f) should equal(tree) + } + + test("Literal Handling") { + + val f = "(5 + 5.5) + (true + false)" + + val tree = Infix("+", Infix("+", ConstLong(5), ConstDouble(5.5)), Infix("+", ConstBoolean(true), ConstBoolean(false))) + + OperationParser.parseFormula(f) should equal(tree) + } + +} diff --git a/calculation/src/test/scala/io/greenbus/calc/lib/eval/ValueMap.scala b/calculation/src/test/scala/io/greenbus/calc/lib/eval/ValueMap.scala new file mode 100644 index 0000000..0d6845d --- /dev/null +++ b/calculation/src/test/scala/io/greenbus/calc/lib/eval/ValueMap.scala @@ -0,0 +1,30 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.calc.lib.eval + +class ValueMap(input: Map[String, Any]) extends VariableSource { + private val map: Map[String, OperationValue] = input.mapValues { + case v: OperationValue => v + case d: Double => NumericConst(d) + case b: Boolean => BooleanConst(b) + case _ => throw new Exception("Invalid op value") + } + + def forName(name: String): OperationValue = map(name) +} \ No newline at end of file diff --git a/cli/pom.xml b/cli/pom.xml new file mode 100755 index 0000000..0ee6344 --- /dev/null +++ b/cli/pom.xml @@ -0,0 +1,108 @@ + + 4.0.0 + + + io.greenbus + greenbus-scala-base + 3.0.0 + ../scala-base + + + greenbus-cli + jar + + + 2.3.1 + ${karaf.version} + 1.4.0 + GreenBus + GreenBus Platform + Dedicated to enabling the smart grid. + + + + + AGPLv3 + http://www.gnu.org/licenses/agpl-3.0.txt + + + + + + + ${project.basedir}/src/main/resources + true + + **/* + + + + + + com.mycila.maven-license-plugin + maven-license-plugin + +
../AGPL_FILE_HEADER
+
+
+ + maven-assembly-plugin + + + + io.greenbus.cli.CliMain + + + + jar-with-dependencies + + + + + make-assembly + package + + single + + + + +
+
+ + + + io.greenbus + greenbus-client + 3.0.0 + + + io.greenbus + greenbus-util + 3.0.0 + + + io.greenbus.msg + greenbus-msg-qpid + 1.0.0 + + + commons-cli + commons-cli + 1.2 + + + jline + jline + 2.11 + + + org.fusesource.jansi + jansi + 1.11 + + + + +
diff --git a/cli/src/main/resources/io.greenbus.cli.branding/branding.properties b/cli/src/main/resources/io.greenbus.cli.branding/branding.properties new file mode 100644 index 0000000..cda6ac9 --- /dev/null +++ b/cli/src/main/resources/io.greenbus.cli.branding/branding.properties @@ -0,0 +1,21 @@ +# +# Copyright 2011-2016 Green Energy Corp. +# +# Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more +# contributor license agreements. See the NOTICE file distributed with this +# work for additional information regarding copyright ownership. Green Energy +# Corp licenses this file to you under the GNU Affero General Public License +# Version 3.0 (the "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.gnu.org/licenses/agpl.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +# + + +welcome=\r\n \u001B[32m\u001B[1m _____ ____\u001B[0m\r\n \u001B[32m\u001B[1m / ____| | _ \\ \u001B[0m\r\n \u001B[32m\u001B[1m | | __ _ __ ___ ___ _ __ | |_) |_ _ ___\u001B[0m\r\n \u001B[32m\u001B[1m | | |_ | '__/ _ \\/ _ \\ '_ \\| _ <| | | / __|\u001B[0m\r\n \u001B[32m\u001B[1m | |__| | | | __/ __/ | | | |_) | |_| \\__ \\\u001B[0m\r\n \u001B[32m\u001B[1m \\_____|_| \\___|\\___|_| |_|____/ \\__,_|___/\u001B[0m\r\n\r\n\u001B[1m${app.fullname}\u001B[0m (${project.version})\r\n${app.blurb}\r\n\r\nGreen Energy Corp (\u001B[1mwww.greenenergycorp.com\u001B[0m)\r\n\r\nHit '\u001B[1m\u001B[0m' for a list of available commands\r\nand '\u001B[1mhelp [cmd]\u001B[0m' for help on a specific command.\r\nHit '\u001B[1m\u001B[0m' to close console.\r\n diff --git a/cli/src/main/scala/io/greenbus/cli/CliContext.scala b/cli/src/main/scala/io/greenbus/cli/CliContext.scala new file mode 100644 index 0000000..684e225 --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/CliContext.scala @@ -0,0 +1,68 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli + +import io.greenbus.msg.Session +import jline.console.ConsoleReader +import io.greenbus.client.ServiceConnection + +trait CliContext { + def reader: ConsoleReader + def session: Session + def agent: String +} + +trait ManagementCliContext extends CliContext { + + def setSession(sessOpt: Option[Session]) + def setToken(token: Option[String]) + def setAgent(agentOpt: Option[String]) + + def connection: ServiceConnection + def tokenOption: Option[String] + def agentOption: Option[String] +} + +object CliContext { + def apply(reader: ConsoleReader, connection: ServiceConnection): ManagementCliContext = new DefaultCliContext(reader, connection) + + private class DefaultCliContext(val reader: ConsoleReader, val connection: ServiceConnection) extends ManagementCliContext { + private var sess = Option.empty[Session] + private var agentName = Option.empty[String] + private var tokenString = Option.empty[String] + + def session: Session = sess.getOrElse(throw new IllegalArgumentException("Not logged in.")) + def agent: String = agentName.getOrElse(throw new IllegalArgumentException("Not logged in.")) + + def setSession(sessOpt: Option[Session]) { + sess = sessOpt + } + def setToken(token: Option[String]) { + tokenString = token + } + def setAgent(agentOpt: Option[String]) { + agentName = agentOpt + } + + def token = tokenString + + def agentOption = agentName + def tokenOption = tokenString + } +} diff --git a/cli/src/main/scala/io/greenbus/cli/CliMain.scala b/cli/src/main/scala/io/greenbus/cli/CliMain.scala new file mode 100644 index 0000000..244818d --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/CliMain.scala @@ -0,0 +1,342 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.amqp.util.LoadingException +import jline.console.ConsoleReader +import io.greenbus.msg.amqp.AmqpSettings +import io.greenbus.msg.qpid.QpidBroker +import io.greenbus.client.{ ServiceHeaders, ServiceConnection } +import scala.concurrent.duration._ +import scala.concurrent.Await +import io.greenbus.cli.commands._ +import jline.console.completer.{ NullCompleter, StringsCompleter, ArgumentCompleter } +import scala.collection.JavaConversions._ +import io.greenbus.util.{ ConfigurationException, UserSettings } +import java.util.Properties +import org.fusesource.jansi.AnsiConsole +import io.greenbus.client.exception.ServiceException +import io.greenbus.client.version.Version +import java.util.concurrent.TimeoutException +import java.io.IOException +import io.greenbus.msg.SessionUnusableException + +object CliMain extends Logging { + + val commandList = List( + () => new LoginCommand, + () => new LogoutCommand, + () => new EntityViewCommand, + () => new EntityListCommand, + () => new EntityTypeCommand, + () => new EntityChildrenCommand, + () => new KvListCommand, + () => new KvViewCommand, + () => new KvSetCommand, + () => new KvDeleteCommand, + () => new PointListCommand, + () => new CommandListCommand, + () => new CommandIssueCommand, + () => new LockListCommand, + () => new LockSelectCommand, + () => new LockBlockCommand, + () => new LockDeleteCommand, + () => new EndpointListCommand, + () => new EndpointSubscribeCommand, + () => new EndpointEnableCommand, + () => new EndpointDisableCommand, + () => new AgentListCommand, + () => new AgentViewCommand, + () => new AgentCreateCommand, + () => new AgentModifyCommand, + () => new AgentPasswordCommand, + () => new AgentPermissionsListCommand, + () => new AgentPermissionsViewCommand, + () => new MeasListCommand, + () => new MeasHistoryCommand, + () => new MeasDownloadCommand, + () => new MeasSubscribeCommand, + () => new MeasBlockCommand, + () => new MeasUnblockCommand, + () => new MeasReplaceCommand, + () => new EventConfigListCommmand, + () => new EventConfigViewCommmand, + () => new EventConfigPutCommmand, + () => new EventConfigDeleteCommmand, + () => new EventPostCommmand, + () => new EventListCommmand, + () => new EventSubscribeCommmand, + () => new EventViewCommmand, + () => new AlarmListCommand, + () => new AlarmSubscribeCommmand, + () => new AlarmSilenceCommand, + () => new AlarmAckCommand, + () => new AlarmRemoveCommand, + () => new CalculationListCommand) + + val map: Map[String, () => Command[_ >: ManagementCliContext <: CliContext]] = commandList.map { fac => + (fac().commandName, fac) + }.toMap + + def main(args: Array[String]): Unit = { + try { + if (args.length > 0) { + run(execute(args, _, _, _)) + } else { + run(runCli) + } + } catch { + case ex: LoadingException => + System.err.println(ex.getMessage) + case ex: IllegalStateException => + System.err.println(ex.getMessage) + case ex: Throwable => + logger.error("Unhandled exception: " + ex) + System.err.println("Error: " + ex.getMessage) + } + } + + def run(fun: (ServiceConnection, String, Option[UserSettings]) => Unit) { + if (System.getProperty("jline.terminal") == null) { + System.setProperty("jline.terminal", "jline.UnsupportedTerminal") + } + + val baseDir = Option(System.getProperty("io.greenbus.config.base")).getOrElse("") + val amqpConfigPath = Option(System.getProperty("io.greenbus.config.amqp")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.msg.amqp.cfg") + val userConfigPath = Option(System.getProperty("io.greenbus.config.user")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.user.cfg") + + val settings = AmqpSettings.load(amqpConfigPath) + + val userSettingsOpt = try { + Some(UserSettings.load(userConfigPath)) + } catch { + case ex: ConfigurationException => None + } + + val conn = try { + ServiceConnection.connect(settings, QpidBroker, 10000) + } catch { + case ex: Throwable => + logger.error("Broker connection error: " + ex) + throw new IllegalStateException(ex.getMessage) + } + + try { + fun(conn, settings.host, userSettingsOpt) + + } finally { + conn.disconnect() + } + + } + + def execute(args: Array[String], conn: ServiceConnection, host: String, userOpt: Option[UserSettings]): Unit = { + val reader = new ConsoleReader(null, System.in, AnsiConsole.out(), null, "UTF-8") + var context = CliContext(reader, conn) + + val userSettings = userOpt.getOrElse(throw new IllegalStateException("Must provide user settings to run single commands.")) + + try { + val session = Await.result(conn.login(userSettings.user, userSettings.password), 5000.milliseconds) + conn.session + context.setSession(Some(session)) + context.setAgent(Some(userSettings.user)) + context.setToken(session.headers.get(ServiceHeaders.tokenHeader())) + } catch { + case ex: Throwable => + throw new IllegalStateException("Login failed.") + } + + try { + val commandName = args(0) + + commandName match { + case "version" => + println(Version.clientVersion) + case "help" => + if (args.length == 1) { + println("Usage: help ") + } else { + val helpCmdName = args(1) + + helpCmdName match { + case "version" => println("Prints build version of client library.") + case _ => + map.get(helpCmdName) match { + case None => println("Unknown command") + case Some(cmdFun) => + val cmd = cmdFun() + cmd.printHelpString() + } + } + } + case _ => + map.get(commandName) match { + case None => println("Unknown command") + case Some(cmdFun) => + val cmd = cmdFun() + println("") + cmd.run(args.drop(1), context) + } + } + println("") + } catch { + case sue: SessionUnusableException => + println("Session failure: " + sue.getMessage) + println("") + + // The broker killing sessions is a detail we hide from the user + context.setSession(None) + val session = conn.session + context.tokenOption.foreach(session.addHeader(ServiceHeaders.tokenHeader(), _)) + context.setSession(Some(session)) + + case ioe: IOException => + println("Communication failure: " + ioe.getMessage) + println("") + case rse: ServiceException => + println("Request failure: " + rse.getStatus + " - " + rse.getMessage) + println("") + case te: TimeoutException => + println("Request timeout") + println("") + case ex: IllegalArgumentException => + println(ex.getMessage) + println("") + } + } + + def runCli(conn: ServiceConnection, host: String, userOpt: Option[UserSettings]) { + + val allCmds = (map.keys.toSeq ++ Seq("login", "logout", "version")).sorted + + val reader = new ConsoleReader(null, System.in, AnsiConsole.out(), null, "UTF-8") + + reader.addCompleter(new ArgumentCompleter( + new StringsCompleter(allCmds ++ Seq("help")), + new NullCompleter)) + + reader.addCompleter(new ArgumentCompleter( + new StringsCompleter(Seq("help")), + new StringsCompleter(allCmds), + new NullCompleter)) + + loadTitle(reader) + + var keepGoing = true + var context = CliContext(reader, conn) + + userOpt.foreach { userSettings => + reader.println(s"Attempting auto-login for user ${userSettings.user}.") + + try { + val session = Await.result(conn.login(userSettings.user, userSettings.password), 5000.milliseconds) + conn.session + context.setSession(Some(session)) + context.setAgent(Some(userSettings.user)) + context.setToken(session.headers.get(ServiceHeaders.tokenHeader())) + } catch { + case ex: Throwable => + reader.println("Login failed.") + } + reader.println("") + } + + while (keepGoing) { + val agentPrompt = context.agentOption.getOrElse("") + val prompt = s"\u001B[1m${agentPrompt}\u001B[0m@$host> " + + val lineRead = Option(reader.readLine(prompt)).map(_.trim) + + lineRead match { + case None => keepGoing = false + case Some("") => + case Some(line) => + try { + + val (cmdArr, rest) = SearchingTokenizer.tokenize(line).toArray.splitAt(1) + val commandName = cmdArr(0) + + commandName match { + case "version" => + println(Version.clientVersion) + case "help" => + if (rest.length == 0) { + println("Usage: help ") + } else { + val helpCmdName = rest(0) + + helpCmdName match { + case "version" => println("Prints build version of client library.") + case _ => + map.get(helpCmdName) match { + case None => println("Unknown command") + case Some(cmdFun) => + val cmd = cmdFun() + cmd.printHelpString() + } + } + } + case _ => + map.get(commandName) match { + case None => println("Unknown command") + case Some(cmdFun) => + val cmd = cmdFun() + println("") + cmd.run(rest, context) + } + } + println("") + } catch { + case sue: SessionUnusableException => + println("Session failure: " + sue.getMessage) + println("") + + // The broker killing sessions is a detail we hide from the user + context.setSession(None) + val session = conn.session + context.tokenOption.foreach(session.addHeader(ServiceHeaders.tokenHeader(), _)) + context.setSession(Some(session)) + + case ioe: IOException => + println("Communication failure: " + ioe.getMessage) + println("") + case rse: ServiceException => + println("Request failure: " + rse.getStatus + " - " + rse.getMessage) + println("") + case te: TimeoutException => + println("Request timeout") + println("") + case ex: IllegalArgumentException => + println(ex.getMessage) + println("") + } + } + } + } + + def loadTitle(reader: ConsoleReader) { + val stream = this.getClass.getClassLoader.getResourceAsStream("io.greenbus.cli.branding/branding.properties") + val props = new Properties + props.load(stream) + Option(props.getProperty("welcome")) foreach { s => reader.println(s) } + } + +} diff --git a/cli/src/main/scala/io/greenbus/cli/Command.scala b/cli/src/main/scala/io/greenbus/cli/Command.scala new file mode 100644 index 0000000..1d919d2 --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/Command.scala @@ -0,0 +1,305 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli + +import scala.collection.mutable.ListBuffer +import org.apache.commons.cli.{ CommandLine, Options } +import org.apache.commons.cli +import java.io.PrintStream + +trait RequiredInputRef[A] { + def value: A +} +trait OptionalInputRef[A] { + def value: Option[A] +} +trait SeqInputRef[A] { + def value: Seq[A] +} +trait SwitchInputRef { + def value: Boolean +} + +sealed trait SettableInputRef +trait SingleRef extends SettableInputRef { + def set(in: String) +} +trait SeqRef extends SettableInputRef { + def set(in: Seq[String]) +} +trait SwitchRef extends SettableInputRef { + def set(v: Boolean) +} + +class RequiredRefContainer[A](parser: String => A) extends RequiredInputRef[A] with SingleRef { + protected var v = Option.empty[A] + def value: A = v.get + def set(in: String) { + v = Some(parser(in)) + } +} +class OptionalRefContainer[A](parser: String => A) extends OptionalInputRef[A] with SingleRef { + protected var v = Option.empty[A] + def value: Option[A] = v + def set(in: String) { + v = Some(parser(in)) + } +} +class SeqRefContainer[A](parser: String => A) extends SeqInputRef[A] with SeqRef { + protected var v = Seq.empty[A] + def value: Seq[A] = v + def set(in: Seq[String]) { + v = in map parser + } +} +class SwitchRefContainer extends SwitchInputRef with SwitchRef { + protected var v = false + def value: Boolean = v + def set(v: Boolean) { + this.v = v + } +} + +sealed trait PresenceType +case object SingleRequired extends PresenceType +case object SingleOptional extends PresenceType +case object Sequential extends PresenceType +case object Switch extends PresenceType + +trait ArgumentDesc { + val name: String + val displayName: String + val typ: PresenceType +} + +trait OptionDesc { + val shortName: Option[String] + val longName: Option[String] + val desc: String + val typ: PresenceType +} + +case class RegisteredArg(ref: SingleRef, name: String, displayName: String, typ: PresenceType) extends ArgumentDesc +case class RegisteredRepeatedArg(ref: SeqRef, name: String, displayName: String, typ: PresenceType) extends ArgumentDesc +case class RegisteredOption(ref: SettableInputRef, shortName: Option[String], longName: Option[String], desc: String, typ: PresenceType) extends OptionDesc + +object Command { + + def optionString(regOp: OptionDesc): String = { + regOp.shortName.map("-" + _ + " ").getOrElse("") + regOp.longName.map("--" + _).getOrElse("") + (if (regOp.typ == Switch) "" else " ") + } +} + +trait Command[ContextType] { + + val commandName: String + val description: String + + private val argsRequired = new ListBuffer[RegisteredArg] + private val argsOptional = new ListBuffer[RegisteredArg] + private var argsRepeated = Option.empty[RegisteredRepeatedArg] + + private val options = new ListBuffer[RegisteredOption] + private val optionMap = scala.collection.mutable.Map.empty[String, SettableInputRef] + + def argumentDescriptions: Seq[ArgumentDesc] = argsRequired.toVector ++ argsOptional.toVector ++ argsRepeated.toVector + def optionDescriptions: Seq[OptionDesc] = options.toVector + + private def addArg[A](ref: RequiredInputRef[A] with SingleRef, name: String, desc: String): RequiredInputRef[A] = { + if (argsOptional.nonEmpty) { + throw new IllegalArgumentException("Cannot add a required argument after an optional argument") + } + if (argsRepeated.nonEmpty) { + throw new IllegalArgumentException("Cannot add a required argument after a repeated argument") + } + argsRequired += RegisteredArg(ref, name, desc, SingleRequired) + ref + } + private def addArgOpt[A](ref: OptionalInputRef[A] with SingleRef, name: String, desc: String): OptionalInputRef[A] = { + if (argsRepeated.nonEmpty) { + throw new IllegalArgumentException("Cannot add an optional argument after a repeated argument") + } + argsOptional += RegisteredArg(ref, name, desc, SingleOptional) + ref + } + private def addArgRepeated[A](ref: SeqInputRef[A] with SeqRef, name: String, desc: String): SeqInputRef[A] = { + if (argsOptional.nonEmpty) { + throw new IllegalArgumentException("Cannot add a repeated argument after an optional argument") + } + if (argsRepeated.nonEmpty) { + throw new IllegalArgumentException("Cannot only have one repeated argument") + } + argsRepeated = Some(RegisteredRepeatedArg(ref, name, desc, Sequential)) + ref + } + + private def addAnyOption[A <: SettableInputRef](ref: A, short: Option[String], long: Option[String], desc: String, typ: PresenceType): A = { + if (short.isEmpty && long.isEmpty) { + throw new IllegalArgumentException(s"Must include either a short or long option form for option") + } + options += RegisteredOption(ref, short, long, desc, typ) + short.foreach { s => optionMap += (s -> ref) } + long.foreach { s => optionMap += (s -> ref) } + ref + } + + private def addOption[A](ref: OptionalInputRef[A] with SettableInputRef, short: Option[String], long: Option[String], desc: String): OptionalInputRef[A] = { + addAnyOption(ref, short, long, desc, SingleOptional) + } + + private def addOptionRepeated[A](ref: SeqInputRef[A] with SettableInputRef, short: Option[String], long: Option[String], desc: String): SeqInputRef[A] = { + addAnyOption(ref, short, long, desc, Sequential) + } + + private def addOptionSwitch[A](ref: SwitchInputRef with SettableInputRef, short: Option[String], long: Option[String], desc: String): SwitchInputRef = { + addAnyOption(ref, short, long, desc, Switch) + } + + protected class Registry[A](parser: String => A) { + def arg(name: String, desc: String): RequiredInputRef[A] = addArg(new RequiredRefContainer[A](parser), name, desc) + def argOptional(name: String, desc: String): OptionalInputRef[A] = addArgOpt(new OptionalRefContainer[A](parser), name, desc) + def argRepeated(name: String, desc: String): SeqInputRef[A] = addArgRepeated(new SeqRefContainer[A](parser), name, desc) + + def option(short: Option[String], long: Option[String], desc: String): OptionalInputRef[A] = addOption(new OptionalRefContainer[A](parser), short, long, desc) + def optionRepeated(short: Option[String], long: Option[String], desc: String): SeqInputRef[A] = addOptionRepeated(new SeqRefContainer[A](parser), short, long, desc) + } + + def optionSwitch(short: Option[String], long: Option[String], desc: String): SwitchInputRef = addOptionSwitch(new SwitchRefContainer, short, long, desc) + + protected val strings = new Registry[String](v => v) + protected val ints = new Registry[Int](_.toInt) + protected val doubles = new Registry[Double](_.toDouble) + + def printHelpString(stream: PrintStream = System.out) { + import stream._ + println("DESCRIPTION") + println(s"\t$commandName") + println("") + println(s"\t$description") + println("") + println("SYNTAX") + println(s"\t$syntax") + println("") + if (argsRequired.nonEmpty || argsOptional.nonEmpty || argsRepeated.nonEmpty) { + println("ARGUMENTS") + (argsRequired ++ argsOptional).foreach { regArg => + println("\t" + regArg.name) + println("\t\t" + regArg.displayName) + println("") + } + argsRepeated.foreach { regArg => + println("\t" + regArg.name) + println("\t\t" + regArg.displayName) + println("") + } + } else { + println("") + } + if (options.nonEmpty) { + println("OPTIONS") + options.foreach { regOp => + val optStr = Command.optionString(regOp) + println("\t" + optStr) + println("\t\t" + regOp.desc) + println("") + } + if (options.isEmpty) { + println("") + } + } + } + + def syntax: String = { + val optString = if (options.nonEmpty) "[options] " else "" + + commandName + " " + + optString + + argsRequired.toList.map("<" + _.name + ">").mkString(" ") + " " + + argsRepeated.map("[<" + _.name + "> ...]").getOrElse("") + + argsOptional.map("[<" + _.name + ">]").mkString(" ") + } + + def buildOptions: Options = { + val opts = new Options + + options.foreach { regOpt => + + val shortName = regOpt.shortName.getOrElse(null) + val longName = regOpt.longName.getOrElse(null) + + regOpt.ref match { + case ref: SingleRef => opts.addOption(new cli.Option(shortName, longName, true, regOpt.desc)) + case ref: SeqRef => opts.addOption(new cli.Option(shortName, longName, true, regOpt.desc)) + case ref: SwitchRef => opts.addOption(new cli.Option(shortName, longName, false, regOpt.desc)) + } + } + + opts + } + + private def interpretLine(line: CommandLine) { + + options.foreach { regOpt => + val key = (regOpt.shortName orElse regOpt.longName).getOrElse { throw new IllegalStateException("Must have short or long name for option") } + if (line.hasOption(key)) { + regOpt.ref match { + case ref: SingleRef => ref.set(line.getOptionValue(key)) + case ref: SeqRef => ref.set(line.getOptionValues(key).toSeq) + case ref: SwitchRef => ref.set(true) + } + } + } + + val argsList = line.getArgs.toList + + if (argsList.size < argsRequired.size) { + throw new IllegalArgumentException("Missing arguments: " + argsRequired.drop(argsList.size).map("<" + _.name + ">").mkString(" ")) + } + val (reqdArgs, extraArgs) = argsList.splitAt(argsRequired.size) + + argsRequired.map(_.ref).zip(reqdArgs).foreach { + case (ref: SingleRef, v: String) => ref.set(v) + } + if (argsOptional.nonEmpty) { + argsOptional.map(_.ref).zip(extraArgs).foreach { + case (ref: SingleRef, v: String) => ref.set(v) + } + } else if (argsRepeated.nonEmpty) { + argsRepeated.get.ref.set(extraArgs) + } + } + + def run(args: Array[String], context: ContextType) { + val cmdOptions = buildOptions + val parser = new cli.BasicParser + + val line = try { + parser.parse(cmdOptions, args) + } catch { + case ex: Throwable => + throw new IllegalArgumentException(ex.getMessage) + } + + interpretLine(line) + + execute(context) + } + + protected def execute(context: ContextType) +} diff --git a/cli/src/main/scala/io/greenbus/cli/LineTokenizer.scala b/cli/src/main/scala/io/greenbus/cli/LineTokenizer.scala new file mode 100644 index 0000000..1ab0b13 --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/LineTokenizer.scala @@ -0,0 +1,91 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli + +import scala.annotation.tailrec + +object LineTokenizer { + + val tokenRegex = """".*"\s+|".*"$|\S+\s+|\S+$""".r + + def tokenize(line: String): Seq[String] = { + + @tailrec + def findAll(s: String, tokens: List[String]): List[String] = { + val in = s.trim + tokenRegex.findPrefixOf(in) match { + case None => tokens.reverse + case Some(rawTok) => + val trimmed = rawTok.trim + val tok = if (trimmed.size >= 2 && trimmed.head == '\"' && trimmed.last == '\"') { + trimmed.drop(1).dropRight(1) + } else { + trimmed + } + + findAll(in.drop(rawTok.size), tok :: tokens) + } + } + + findAll(line, Nil) + } +} + +object SearchingTokenizer { + + def isQuote(c: Char): Boolean = c == '\"' + + def tokenize(line: String): Seq[String] = { + + def untilSpaceSearch(s: String, tokens: List[String]): List[String] = { + val found = s.indexWhere(c => c.isWhitespace) + if (found < 0) { + (s :: tokens).reverse + } else { + val tok = s.take(found) + inBetweenSearch(s.drop(found + 1), tok :: tokens) + } + } + + def untilQuoteSearch(s: String, tokens: List[String]): List[String] = { + val found = s.indexWhere(c => c == '\"') + if (found < 0) { + throw new IllegalArgumentException("Unterminated quote in input") + } else { + val tok = s.take(found) + inBetweenSearch(s.drop(found + 1), tok :: tokens) + } + } + + def inBetweenSearch(s: String, tokens: List[String]): List[String] = { + val found = s.indexWhere(c => !c.isWhitespace) + if (found < 0) { + tokens.reverse + } else { + if (s.charAt(found) == '\"') { + untilQuoteSearch(s.drop(found + 1), tokens) + } else { + untilSpaceSearch(s.drop(found), tokens) + } + } + } + + inBetweenSearch(line, Nil) + } +} diff --git a/cli/src/main/scala/io/greenbus/cli/commands/AgentCommands.scala b/cli/src/main/scala/io/greenbus/cli/commands/AgentCommands.scala new file mode 100644 index 0000000..c822188 --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/commands/AgentCommands.scala @@ -0,0 +1,269 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.commands + +import io.greenbus.cli.view._ +import io.greenbus.cli.{ CliContext, Command } +import io.greenbus.client.service.AuthService +import io.greenbus.client.service.proto.Auth.{ Agent, PermissionSet } +import io.greenbus.client.service.proto.AuthRequests._ +import io.greenbus.client.service.proto.Model.ModelID +import io.greenbus.client.service.proto.ModelRequests.EntityPagingParams + +import scala.collection.JavaConversions._ +import scala.concurrent.Await +import scala.concurrent.duration._ + +class AgentListCommand extends Command[CliContext] { + + val commandName = "agent:list" + val description = "List all agents" + + protected def execute(context: CliContext) { + val authClient = AuthService.client(context.session) + + def results(last: Option[String], pageSize: Int) = { + + val pageB = EntityPagingParams.newBuilder().setPageSize(pageSize) + last.foreach(pageB.setLastName) + + authClient.agentQuery(AgentQuery.newBuilder().setPagingParams(pageB.build()).build()) + } + + def pageBoundary(results: Seq[Agent]): String = results.last.getName + + Paging.page(context.reader, results, pageBoundary, AgentView.printTable, AgentView.printRows) + } +} + +object AgentCommon { + + def handleAgentResult(authClient: AuthService, results: Seq[Agent], otherwise: Option[String]) = { + + results.headOption match { + case Some(agent) => + val permNames = agent.getPermissionSetsList.toSeq + + val permissionSets = if (permNames.nonEmpty) { + val permKeySet = PermissionSetKeySet.newBuilder().addAllNames(permNames).build() + Await.result(authClient.getPermissionSets(permKeySet), 5000.milliseconds) + } else { + Seq() + } + + AgentView.viewAgent(agent, permissionSets) + + case None => + otherwise.foreach(println) + } + } +} + +class AgentCreateCommand extends Command[CliContext] { + + val commandName = "agent:create" + val description = "Create an agent" + + val name = strings.arg("user name", "Agent name to create.") + + val passOpt = strings.option(None, Some("password"), "Supply password instead of prompting, useful for scripting. WARNING: Password will be visible in command history.") + + val permissions = strings.optionRepeated(Some("p"), Some("permission-set"), "Attach permission set to agent") + + protected def execute(context: CliContext) { + val authClient = AuthService.client(context.session) + + val password = passOpt.value.getOrElse { + + def prompt(retries: Int): String = { + if (retries == 0) { + throw new IllegalArgumentException("Failed to provide a password") + } else { + + println("Enter password: ") + val first = readLine() + println("Re-enter password: ") + val second = readLine() + if (first == second) { + first + } else { + println("Passwords did not match.\n") + prompt(retries - 1) + } + } + } + + prompt(3) + } + + println() + val template = AgentTemplate.newBuilder() + .setName(name.value) + .setPassword(password) + .addAllPermissionSets(permissions.value) + .build() + + val agentResult = Await.result(authClient.putAgents(Seq(template)), 10000.milliseconds) + + AgentCommon.handleAgentResult(authClient, agentResult, None) + } +} + +class AgentModifyCommand extends Command[CliContext] { + + val commandName = "agent:modify" + val description = "Modify an agent's permission sets" + + val name = strings.arg("user name", "Agent name to modify.") + + val addPerms = strings.optionRepeated(Some("a"), Some("add"), "Attach permission set to agent") + val remPerms = strings.optionRepeated(Some("r"), Some("remove"), "Attach permission set to agent") + + protected def execute(context: CliContext) { + val authClient = AuthService.client(context.session) + + val agentGetResult = Await.result(authClient.getAgents(AgentKeySet.newBuilder().addNames(name.value).build()), 5000.milliseconds) + + agentGetResult.headOption match { + case None => println("Agent not found.") + case Some(agent) => + val currentPermSet = agent.getPermissionSetsList.toSet + val withoutRemoves = currentPermSet diff remPerms.value.toSet + val withAdds = withoutRemoves ++ addPerms.value + + val template = AgentTemplate.newBuilder() + .setUuid(agent.getUuid) + .setName(name.value) + .addAllPermissionSets(withAdds) + .build() + + val putResult = Await.result(authClient.putAgents(Seq(template)), 10000.milliseconds) + AgentCommon.handleAgentResult(authClient, putResult, None) + } + } +} + +class AgentPasswordCommand extends Command[CliContext] { + + val commandName = "agent:password" + val description = "Modify an agent's password" + + val name = strings.arg("user name", "Agent name to modify.") + + val passOpt = strings.option(None, Some("password"), "Supply password instead of prompting, useful for scripting. WARNING: Password will be visible in command history.") + + protected def execute(context: CliContext) { + val authClient = AuthService.client(context.session) + + val password = passOpt.value.getOrElse { + + def prompt(retries: Int): String = { + if (retries == 0) { + throw new IllegalArgumentException("Failed to provide a password") + } else { + + println("Enter password: ") + val first = readLine() + println("Re-enter password: ") + val second = readLine() + if (first == second) { + first + } else { + println("Passwords did not match.\n") + prompt(retries - 1) + } + } + } + + prompt(3) + } + + val agentGetResult = Await.result(authClient.getAgents(AgentKeySet.newBuilder().addNames(name.value).build()), 5000.milliseconds) + + agentGetResult.headOption match { + case None => println("Agent not found.") + case Some(agent) => + val update = AgentPasswordUpdate.newBuilder() + .setUuid(agent.getUuid) + .setPassword(password) + .build() + + val putResult = Await.result(authClient.putAgentPasswords(Seq(update)), 10000.milliseconds) + AgentCommon.handleAgentResult(authClient, putResult, None) + } + } +} + +class AgentViewCommand extends Command[CliContext] { + + val commandName = "agent:view" + val description = "View details of agent" + + val setName = strings.arg("name", "Agent name") + + protected def execute(context: CliContext) { + val authClient = AuthService.client(context.session) + + val agentResult = Await.result(authClient.getAgents(AgentKeySet.newBuilder().addNames(setName.value).build()), 5000.milliseconds) + AgentCommon.handleAgentResult(authClient, agentResult, Some("Agent not found.")) + } +} + +class AgentPermissionsListCommand extends Command[CliContext] { + + val commandName = "agent-permissions:list" + val description = "List all permission sets" + + protected def execute(context: CliContext) { + val authClient = AuthService.client(context.session) + + def results(last: Option[ModelID], pageSize: Int) = { + val queryBuilder = PermissionSetQuery.newBuilder().setPageSize(pageSize) + last.foreach(queryBuilder.setLastId) + val query = queryBuilder.build() + + authClient.permissionSetQuery(query) + } + + def pageBoundary(results: Seq[PermissionSet]) = results.last.getId + + Paging.page(context.reader, results, pageBoundary, PermissionSetView.printTable, PermissionSetView.printRows) + } +} + +class AgentPermissionsViewCommand extends Command[CliContext] { + + val commandName = "agent-permissions:view" + val description = "View permissions in a permission set" + + val setName = strings.arg("name", "Permission set name") + + protected def execute(context: CliContext) { + val authClient = AuthService.client(context.session) + + val result = Await.result(authClient.getPermissionSets(PermissionSetKeySet.newBuilder().addNames(setName.value).build()), 5000.milliseconds) + + result.headOption match { + case None => println("Permission set not found") + case Some(set) => + PermissionSetView.viewPermissionSet(set) + } + + } +} diff --git a/cli/src/main/scala/io/greenbus/cli/commands/CalculationCommands.scala b/cli/src/main/scala/io/greenbus/cli/commands/CalculationCommands.scala new file mode 100644 index 0000000..85243f0 --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/commands/CalculationCommands.scala @@ -0,0 +1,95 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.commands + +import io.greenbus.cli.view.CalculationView +import io.greenbus.cli.{ CliContext, Command } +import io.greenbus.client.service.ModelService +import io.greenbus.client.service.proto.Calculations.CalculationDescriptor +import io.greenbus.client.service.proto.Model.{ Entity, ModelUUID } +import io.greenbus.client.service.proto.ModelRequests.{ EntityPagingParams, EndpointQuery, EntityKeyPair, EntityRelationshipFlatQuery } + +import scala.collection.JavaConversions._ +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration._ +import scala.concurrent.{ Await, Future } + +class CalculationListCommand extends Command[CliContext] { + + val commandName = "calc:list" + val description = "List all calculations" + + protected def execute(context: CliContext) { + val modelClient = ModelService.client(context.session) + + val calcEndpoints = Await.result(modelClient.endpointQuery(EndpointQuery.newBuilder().addProtocols("calculator").build()), 5000.milliseconds) + + val endpointUuids = calcEndpoints.map(_.getUuid) + + def allQueryResults(last: Option[ModelUUID], pageSize: Int): Future[Seq[(Entity, Option[CalculationDescriptor])]] = { + + val queryB = EntityRelationshipFlatQuery.newBuilder() + .addAllStartUuids(endpointUuids) + .setRelationship("source") + .setDescendantOf(true) + .addEndTypes("Point") + + val pageParamsB = EntityPagingParams.newBuilder() + .setPageSize(pageSize) + + last.foreach(pageParamsB.setLastUuid) + + val query = queryB.setPagingParams(pageParamsB).build() + + modelClient.relationshipFlatQuery(query).flatMap { + case Seq() => Future.successful(Nil) + case points => + val uuidKeys = points.map { p => + EntityKeyPair.newBuilder().setUuid(p.getUuid).setKey("calculation").build() + } + + modelClient.getEntityKeyValues(uuidKeys).map { kvs => + + val pointToCalcMap: Map[ModelUUID, CalculationDescriptor] = kvs.flatMap { kv => + val calcDescOpt = if (kv.hasValue && kv.getValue.hasByteArrayValue) { + try { + Some(CalculationDescriptor.parseFrom(kv.getValue.getByteArrayValue)) + } catch { + case ex: Throwable => None + } + } else { + None + } + calcDescOpt.map(c => (kv.getUuid, c)) + }.toMap + + points.map(p => (p, pointToCalcMap.get(p.getUuid))) + } + } + } + + def pageBoundary(results: Seq[(Entity, Option[CalculationDescriptor])]) = results.last._1.getUuid + + if (endpointUuids.nonEmpty) { + Paging.page(context.reader, allQueryResults, pageBoundary, CalculationView.printTable, CalculationView.printRows) + } else { + CalculationView.printTable(Nil) + } + } +} diff --git a/cli/src/main/scala/io/greenbus/cli/commands/CommandCommands.scala b/cli/src/main/scala/io/greenbus/cli/commands/CommandCommands.scala new file mode 100644 index 0000000..6a5c936 --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/commands/CommandCommands.scala @@ -0,0 +1,298 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.commands + +import io.greenbus.msg.Session +import io.greenbus.cli.view.CommandLockView +import io.greenbus.cli.{ CliContext, Command } +import io.greenbus.client.service.proto.AuthRequests.AgentKeySet +import io.greenbus.client.service.proto.CommandRequests.{ CommandBlock, CommandLockQuery, CommandSelect } +import io.greenbus.client.service.proto.Commands.{ CommandLock, CommandRequest } +import io.greenbus.client.service.proto.Model.ModelID +import io.greenbus.client.service.proto.ModelRequests.EntityKeySet +import io.greenbus.client.service.{ AuthService, CommandService, ModelService } + +import scala.collection.JavaConversions._ +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration._ +import scala.concurrent.{ Await, Future } + +object CommandIssueCommand { + + def interpretValue(b: CommandRequest.Builder, s: String) { + + def asOpt[A](f: => A): Option[A] = { + try { Some(f) } catch { case _: NumberFormatException => None } + } + + val parsed = asOpt(s.toInt) orElse + asOpt(s.toDouble) getOrElse (s) + + parsed match { + case v: Int => + b.setIntVal(v); b.setType(CommandRequest.ValType.INT) + case v: Double => + b.setDoubleVal(v); b.setType(CommandRequest.ValType.DOUBLE) + case v: String => b.setStringVal(v); b.setType(CommandRequest.ValType.STRING) + } + } +} + +class CommandIssueCommand extends Command[CliContext] { + + val commandName = "command:issue" + val description = "Select commands for execution" + + val commandToIssue = strings.arg("command name", "Command name") + val commandValue = strings.argOptional("command value", "Command value") + + protected def execute(context: CliContext) { + val modelClient = ModelService.client(context.session) + val commandClient = CommandService.client(context.session) + + if (commandToIssue.value.isEmpty) { + throw new IllegalArgumentException("Must include command to issue") + } + + val cmdFut = modelClient.getCommands(EntityKeySet.newBuilder().addNames(commandToIssue.value).build()) + + val cmdResults = Await.result(cmdFut, 5000.milliseconds) + + if (cmdResults.isEmpty) { + throw new IllegalArgumentException("Commands not found") + } + + val cmdUuid = cmdResults.head.getUuid + + val b = CommandRequest.newBuilder() + .setCommandUuid(cmdUuid) + + commandValue.value.foreach(CommandIssueCommand.interpretValue(b, _)) + + val cmdReq = b.build() + + // Can fail because the front end isn't there, server will send ServiceException with timeout + val result = Await.result(commandClient.issueCommandRequest(cmdReq), 10000.milliseconds) + + val errorStr = if (result.hasErrorMessage) " - " + result.getErrorMessage else "" + + println(result.getStatus.toString + errorStr) + } +} + +object LocksCommands { + + def lockToRow(session: Session, cmdLocks: Seq[CommandLock]): Future[Seq[(CommandLock, Seq[String], String)]] = { + val modelClient = ModelService.client(session) + + if (cmdLocks.isEmpty) { + Future.successful(Nil) + } else { + + val agentUuids = cmdLocks.map(_.getAgentUuid).distinct + val cmdUuids = cmdLocks.flatMap(_.getCommandUuidsList).distinct + + val keys = EntityKeySet.newBuilder().addAllUuids(agentUuids ++ cmdUuids).build() + + modelClient.get(keys).map { ents => + val uuidToName = ents.map(e => (e.getUuid, e.getName)).toMap + + cmdLocks.map { lock => + val cmdNames = lock.getCommandUuidsList.toList.flatMap(uuidToName.get) + val agentName = uuidToName.get(lock.getAgentUuid).getOrElse("unknown") + (lock, cmdNames, agentName) + } + } + } + } +} + +class LockListCommand extends Command[CliContext] { + + val commandName = "lock:list" + val description = "List command locks" + + val commandNames = strings.optionRepeated(Some("c"), Some("command"), "Command names") + + val agentNames = strings.optionRepeated(Some("a"), Some("agent"), "Agent names") + + val isSelect = optionSwitch(Some("s"), Some("select"), "List command selects only") + + val isBlock = optionSwitch(Some("b"), Some("block"), "List command blocks only") + + protected def execute(context: CliContext) { + val modelClient = ModelService.client(context.session) + val commandClient = CommandService.client(context.session) + + val agentUuids = if (agentNames.value.nonEmpty) { + val authClient = AuthService.client(context.session) + + val keys = AgentKeySet.newBuilder().addAllNames(agentNames.value).build() + val agents = Await.result(authClient.getAgents(keys), 5000.milliseconds) + + if (agents.isEmpty) { + throw new IllegalArgumentException("Agents specified not found") + } + + agents.map(_.getUuid) + } else { + Nil + } + + val commandUuids = if (commandNames.value.nonEmpty) { + val modelClient = ModelService.client(context.session) + + val keys = EntityKeySet.newBuilder().addAllNames(commandNames.value).build() + val commands = Await.result(modelClient.getCommands(keys), 5000.milliseconds) + + if (commands.isEmpty) { + throw new IllegalArgumentException("Commands specified not found") + } + + commands.map(_.getUuid) + } else { + Nil + } + + if (isSelect.value && isBlock.value) { + throw new IllegalArgumentException("Can choose only one of select or block") + } + + def getResults(last: Option[ModelID], pageSize: Int) = { + val b = CommandLockQuery.newBuilder() + + if (isSelect.value) b.setAccess(CommandLock.AccessMode.ALLOWED) + if (isBlock.value) b.setAccess(CommandLock.AccessMode.BLOCKED) + + if (agentUuids.nonEmpty) b.addAllAgentUuids(agentUuids) + if (commandUuids.nonEmpty) b.addAllCommandUuids(commandUuids) + + last.foreach(b.setLastId) + b.setPageSize(pageSize) + + val lockFut = commandClient.commandLockQuery(b.build()) + + lockFut.flatMap { cmdLocks => LocksCommands.lockToRow(context.session, cmdLocks) } + } + + def pageBoundary(results: Seq[(CommandLock, Seq[String], String)]) = { + results.last._1.getId + } + + Paging.page(context.reader, getResults, pageBoundary, CommandLockView.printTable, CommandLockView.printRows) + } +} + +class LockSelectCommand extends Command[CliContext] { + + val commandName = "lock:select" + val description = "Select commands for execution" + + val duration = ints.option(Some("t"), Some("expiry"), "Timeout duration in milliseconds. Default 30000.") + + val commandNames = strings.argRepeated("command name", "Command names") + + protected def execute(context: CliContext) { + val modelClient = ModelService.client(context.session) + val commandClient = CommandService.client(context.session) + + val durationMs = duration.value.getOrElse(30000) + + if (commandNames.value.isEmpty) { + throw new IllegalArgumentException("Must include at least one command") + } + + val cmdNames = commandNames.value + val cmdFut = modelClient.getCommands(EntityKeySet.newBuilder().addAllNames(cmdNames).build()) + + val cmdResults = Await.result(cmdFut, 5000.milliseconds) + + if (cmdResults.isEmpty) { + throw new IllegalArgumentException("Commands not found") + } + + val select = CommandSelect.newBuilder() + .addAllCommandUuids(cmdResults.map(_.getUuid)) + .setExpireDuration(durationMs) + .build() + + val lock = Await.result(commandClient.selectCommands(select), 5000.milliseconds) + + CommandLockView.printInspect(lock, cmdResults.map(_.getName), context.agent) + + } +} + +class LockBlockCommand extends Command[CliContext] { + + val commandName = "lock:block" + val description = "Block commands from being execution" + + val commandNames = strings.argRepeated("command name", "Command names") + + protected def execute(context: CliContext) { + val modelClient = ModelService.client(context.session) + val commandClient = CommandService.client(context.session) + + if (commandNames.value.isEmpty) { + throw new IllegalArgumentException("Must include at least one command") + } + + val cmdNames = commandNames.value + val cmdFut = modelClient.getCommands(EntityKeySet.newBuilder().addAllNames(cmdNames).build()) + + val cmdResults = Await.result(cmdFut, 5000.milliseconds) + + if (cmdResults.isEmpty) { + throw new IllegalArgumentException("Commands not found") + } + + val block = CommandBlock.newBuilder() + .addAllCommandUuids(cmdResults.map(_.getUuid)) + .build() + + val lock = Await.result(commandClient.blockCommands(block), 5000.milliseconds) + + CommandLockView.printInspect(lock, cmdResults.map(_.getName), context.agent) + } +} + +class LockDeleteCommand extends Command[CliContext] { + + val commandName = "lock:delete" + val description = "Delete command locks" + + val commandIds = strings.argRepeated("command name", "Command names") + + protected def execute(context: CliContext) { + val commandClient = CommandService.client(context.session) + + if (commandIds.value.isEmpty) { + throw new IllegalArgumentException("Must include command IDs to delete") + } + + val ids = commandIds.value.map(id => ModelID.newBuilder().setValue(id).build()) + + val rowFut = commandClient.deleteCommandLocks(ids).flatMap(LocksCommands.lockToRow(context.session, _)) + + val rows = Await.result(rowFut, 5000.milliseconds) + + CommandLockView.printTable(rows.toList) + } +} diff --git a/cli/src/main/scala/io/greenbus/cli/commands/EntityCommands.scala b/cli/src/main/scala/io/greenbus/cli/commands/EntityCommands.scala new file mode 100644 index 0000000..975bc0e --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/commands/EntityCommands.scala @@ -0,0 +1,150 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.commands + +import io.greenbus.cli.{ CliContext, Command } +import io.greenbus.cli.view._ +import scala.concurrent.Await +import io.greenbus.client.service.proto.ModelRequests._ +import scala.concurrent.duration._ +import io.greenbus.client.service.ModelService +import io.greenbus.client.service.proto.Model.{ Entity, ModelUUID } +import scala.collection.JavaConversions._ + +class EntityListCommand extends Command[CliContext] { + + val commandName = "entity:list" + val description = "List all entities" + + protected def execute(context: CliContext) { + val client = ModelService.client(context.session) + + def getResults(last: Option[ModelUUID], pageSize: Int) = { + val pageBuilder = EntityPagingParams.newBuilder().setPageSize(pageSize) + last.foreach(pageBuilder.setLastUuid) + client.entityQuery(EntityQuery.newBuilder().setPagingParams(pageBuilder).build()) + } + + def pageBoundary(results: Seq[Entity]) = { + results.last.getUuid + } + + Paging.page(context.reader, getResults, pageBoundary, EntityView.printTable, EntityView.printRows) + } +} + +class EntityViewCommand extends Command[CliContext] { + + val commandName = "entity:view" + val description = "View details of an entity specified by name" + + val name = strings.arg("entity name", "Entity name") + + protected def execute(context: CliContext) { + val client = ModelService.client(context.session) + + val result = Await.result(client.get(EntityKeySet.newBuilder().addNames(name.value).build()), 5000.milliseconds) + + result.headOption.map(EntityView.printInspect).getOrElse(println("No entities found.")) + } +} + +class EntityTypeCommand extends Command[CliContext] { + + val commandName = "entity:type" + val description = "List entities that include any of the given types" + + val types = strings.argRepeated("entity type", "Entity type to search for") + + protected def execute(context: CliContext) { + + if (types.value.isEmpty) { + throw new IllegalArgumentException("Must include at least one type") + } + + val client = ModelService.client(context.session) + + def getResults(last: Option[ModelUUID], pageSize: Int) = { + val pageBuilder = EntityPagingParams.newBuilder().setPageSize(pageSize) + last.foreach(pageBuilder.setLastUuid) + + val query = EntityQuery.newBuilder + .setTypeParams(EntityTypeParams.newBuilder() + .addAllIncludeTypes(types.value)) + .setPagingParams(pageBuilder) + .build() + + client.entityQuery(query) + } + + def pageBoundary(results: Seq[Entity]) = { + results.last.getUuid + } + + Paging.page(context.reader, getResults, pageBoundary, EntityView.printTable, EntityView.printRows) + } +} + +class EntityChildrenCommand extends Command[CliContext] { + + val commandName = "entity:children" + val description = "List children of specified entity " + + val name = strings.arg("entity name", "Entity name") + + val relation = strings.option(Some("r"), Some("relation"), "Relationship to children (default: \"owns\")") + + val childTypes = strings.optionRepeated(Some("t"), Some("child-type"), "Child types to include") + + val depthLimit = ints.option(Some("d"), Some("depth"), "Depth limit for children") + + protected def execute(context: CliContext) { + + val relationship = relation.value.getOrElse("owns") + + val client = ModelService.client(context.session) + + def getResults(last: Option[ModelUUID], pageSize: Int) = { + val queryBuilder = EntityRelationshipFlatQuery.newBuilder + .addStartNames(name.value) + .setDescendantOf(true) + .setRelationship(relationship) + + if (childTypes.value.nonEmpty) { + queryBuilder.addAllEndTypes(childTypes.value) + } + + depthLimit.value.foreach(queryBuilder.setDepthLimit) + + val pageParamsB = EntityPagingParams.newBuilder() + .setPageSize(pageSize) + + last.foreach(pageParamsB.setLastUuid) + + val query = queryBuilder.setPagingParams(pageParamsB).build() + client.relationshipFlatQuery(query) + } + + def pageBoundary(results: Seq[Entity]) = { + results.last.getUuid + } + + Paging.page(context.reader, getResults, pageBoundary, EntityView.printTable, EntityView.printRows) + } +} diff --git a/cli/src/main/scala/io/greenbus/cli/commands/EventCommands.scala b/cli/src/main/scala/io/greenbus/cli/commands/EventCommands.scala new file mode 100644 index 0000000..3d70cd4 --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/commands/EventCommands.scala @@ -0,0 +1,476 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.commands + +import io.greenbus.cli.{ CliContext, Command } +import io.greenbus.cli.view._ +import scala.concurrent.Await +import scala.concurrent.duration._ +import io.greenbus.client.service.{ ModelService, EventService } +import io.greenbus.client.service.proto.EventRequests._ +import io.greenbus.client.service.proto.Events.{ Alarm, EventConfig, Attribute } +import io.greenbus.client.service.proto.ModelRequests.EntityKeySet +import io.greenbus.client.service.proto.Model.ModelID +import scala.collection.JavaConversions._ +import java.util.concurrent.atomic.AtomicReference + +class EventConfigListCommmand extends Command[CliContext] { + + val commandName = "event-config:list" + val description = "List all event configurations" + + protected def execute(context: CliContext) { + val client = EventService.client(context.session) + + def getResults(last: Option[String], pageSize: Int) = { + val queryBuilder = EventConfigQuery.newBuilder().setPageSize(pageSize) + last.foreach(queryBuilder.setLastEventType) + val query = queryBuilder.build() + client.eventConfigQuery(query) + } + + def pageBoundary(results: Seq[EventConfig]) = { + results.last.getEventType + } + + Paging.page(context.reader, getResults, pageBoundary, EventConfigView.printTable, EventConfigView.printRows) + } +} + +class EventConfigViewCommmand extends Command[CliContext] { + + val commandName = "event-config:view" + val description = "View particular event configurations" + + val eventTypes = strings.argRepeated("event type", "Event type") + + protected def execute(context: CliContext) { + val client = EventService.client(context.session) + + if (eventTypes.value.isEmpty) { + throw new IllegalArgumentException("Must include at least one event type") + } + + val results = Await.result(client.getEventConfigs(eventTypes.value), 5000.milliseconds) + EventConfigView.printTable(results.toList) + } +} + +class EventConfigDeleteCommmand extends Command[CliContext] { + + val commandName = "event-config:delete" + val description = "Delete event configurations" + + val eventTypes = strings.argRepeated("event type", "Event type") + + protected def execute(context: CliContext) { + val client = EventService.client(context.session) + + if (eventTypes.value.isEmpty) { + throw new IllegalArgumentException("Must include at least one event type") + } + + val results = Await.result(client.deleteEventConfigs(eventTypes.value), 5000.milliseconds) + EventConfigView.printTable(results.toList) + } +} + +class EventConfigPutCommmand extends Command[CliContext] { + + val commandName = "event-config:put" + val description = "Create/modify event configurations" + + val eventType = strings.arg("event type", "Event type") + val resource = strings.arg("resource", "Resource string to be rendered for events") + + val severity = ints.option(Some("s"), Some("severity"), "Severity of processed events") + + val alarm = optionSwitch(Some("a"), Some("alarm"), "Event generates an alarm") + + val log = optionSwitch(Some("l"), Some("log"), "Event is a log entry") + + val silent = optionSwitch(Some("q"), Some("silent"), "Alarm is silent (used with --alarm)") + + protected def execute(context: CliContext) { + val client = EventService.client(context.session) + + val designation = (alarm.value, log.value) match { + case (true, true) => throw new IllegalArgumentException("Cannot set both --alarm and --log flags.") + case (true, false) => EventConfig.Designation.ALARM + case (false, true) => EventConfig.Designation.LOG + case (false, false) => EventConfig.Designation.EVENT + } + + if (!alarm.value && silent.value) { + throw new IllegalArgumentException("Cannot use --silent when --alarm not set") + } + + val alarmState = if (silent.value) Alarm.State.UNACK_SILENT else Alarm.State.UNACK_AUDIBLE + + val template = EventConfigTemplate.newBuilder() + .setEventType(eventType.value) + .setResource(resource.value) + .setSeverity(severity.value getOrElse 4) + .setDesignation(designation) + .setAlarmState(alarmState) + .build() + + val results = Await.result(client.putEventConfigs(List(template)), 5000.milliseconds) + EventConfigView.printTable(results.toList) + } +} + +class EventPostCommmand extends Command[CliContext] { + + val commandName = "event:post" + val description = "Post an event. Refer to event configs for types and arguments." + + val eventType = strings.arg("event type", "Event type") + + val subsystem = strings.option(None, Some("subsystem"), "Subsystem event applies to") + + val entity = strings.option(None, Some("entity"), "Entity name event applies to") + + val args = strings.optionRepeated(Some("a"), Some("arg"), "Attribute, in form \"key=value\"") + + protected def execute(context: CliContext) { + val client = EventService.client(context.session) + + val attrPairs = args.value.map { s => + val parts = s.split("=") + parts.toList match { + case List(key, value) => (key, value) + case other => throw new IllegalArgumentException(s"Couldn't parse argument $s") + } + } + + val entUuid = entity.value.map { name => + val entClient = ModelService.client(context.session) + val results = Await.result(entClient.get(EntityKeySet.newBuilder().addNames(name).build()), 5000.milliseconds) + if (results.size == 1) { + results.head.getUuid + } else { + throw new IllegalArgumentException(s"Couldn't find entity with name $name") + } + } + + val attributes = attrPairs.map { + case (key, value) => + Attribute.newBuilder() + .setName(key) + .setValueString(value) + .build() + } + + val b = EventTemplate.newBuilder() + .setEventType(eventType.value) + .addAllArgs(attributes) + + subsystem.value.foreach(b.setSubsystem) + entUuid.foreach(b.setEntityUuid) + + val results = Await.result(client.postEvents(List(b.build())), 5000.milliseconds) + + EventView.printTable(results.toList) + } +} + +class EventViewCommmand extends Command[CliContext] { + + val commandName = "event:view" + val description = "Show specific events by ID." + + val eventIds = strings.argRepeated("event id", "Event IDs to display") + + protected def execute(context: CliContext) { + val client = EventService.client(context.session) + + if (eventIds.value.isEmpty) { + throw new IllegalArgumentException("Must include at least one ID") + } + + val ids = eventIds.value.map(v => ModelID.newBuilder.setValue(v).build()) + + val results = Await.result(client.getEvents(ids), 5000.milliseconds) + + EventView.printTable(results.toList) + } +} + +class EventListCommmand extends Command[CliContext] { + + val commandName = "event:list" + val description = "List events." + + val eventType = strings.optionRepeated(Some("t"), Some("type"), "Event types to include") + val severities = ints.optionRepeated(Some("s"), Some("severity"), "Severities to include") + val severityOrHigher = ints.option(None, Some("min-severity"), "Filters to event with this severity or higher") + val agents = strings.optionRepeated(Some("a"), Some("agent"), "Agent names to include") + + val alarm = optionSwitch(None, Some("alarm"), "Only include events that are alarms") + val notAlarm = optionSwitch(None, Some("not-alarm"), "Only include events that are not alarms") + + val limit = ints.option(None, Some("limit"), "Maximum number of events to return") + + val nonLatest = optionSwitch(Some("b"), Some("beginning"), "Fill page window from the beginning of the result set") + + protected def execute(context: CliContext) { + val client = EventService.client(context.session) + + val b = EventQueryParams.newBuilder() + + eventType.value.foreach(b.addEventType) + severities.value.foreach(b.addSeverity) + severityOrHigher.value.foreach(b.setSeverityOrHigher) + agents.value.foreach(b.addAgent) + + val qb = EventQuery.newBuilder().setQueryParams(b) + limit.value.foreach(qb.setPageSize) + + val isAlarm = (alarm.value, notAlarm.value) match { + case (true, true) => throw new IllegalArgumentException("Must use only one of --alarm or --not-alarm") + case (true, false) => Some(true) + case (false, true) => Some(false) + case (false, false) => None + } + + isAlarm.foreach(b.setIsAlarm) + + b.setLatest(!nonLatest.value) + + val results = Await.result(client.eventQuery(qb.build()), 5000.milliseconds) + + EventView.printTable(results.toList) + } +} + +class EventSubscribeCommmand extends Command[CliContext] { + + val commandName = "event:subscribe" + val description = "Subscribe to events." + + val eventType = strings.optionRepeated(Some("t"), Some("type"), "Event types to include") + val severities = ints.optionRepeated(Some("s"), Some("severity"), "Severities to include") + val agents = strings.optionRepeated(Some("a"), Some("agent"), "Agent names to include") + + val limit = ints.option(None, Some("limit"), "Maximum number of events to return") + + protected def execute(context: CliContext) { + val client = EventService.client(context.session) + + val b = EventSubscriptionQuery.newBuilder() + + eventType.value.foreach(b.addEventType) + severities.value.foreach(b.addSeverity) + agents.value.foreach(b.addAgent) + + limit.value.foreach(b.setLimit) + + val (results, sub) = Await.result(client.subscribeToEvents(b.build()), 5000.milliseconds) + + val widths = new AtomicReference[Seq[Int]](Nil) + + val origWidths = EventView.printTable(results.toList) + + widths.set(origWidths) + + sub.start { eventNotification => + widths.set(EventView.printRows(List(eventNotification.getValue), widths.get())) + } + + context.reader.readCharacter() + sub.cancel() + } +} + +class AlarmListCommand extends Command[CliContext] { + + val commandName = "alarm:list" + val description = "List alarms." + + val alarmStates = strings.optionRepeated(None, Some("alarm-state"), "Alarm states to include: " + List(Alarm.State.UNACK_SILENT, Alarm.State.UNACK_AUDIBLE, Alarm.State.ACKNOWLEDGED, Alarm.State.REMOVED).mkString(", ")) + + val eventType = strings.optionRepeated(Some("t"), Some("type"), "Event types to include") + val severities = ints.optionRepeated(Some("s"), Some("severity"), "Severities to include") + val severityOrHigher = ints.option(None, Some("min-severity"), "Filters to event with this severity or higher") + val agents = strings.optionRepeated(Some("a"), Some("agent"), "Agent names to include") + + val limit = ints.option(None, Some("limit"), "Maximum number of events to return") + + val nonLatest = optionSwitch(Some("b"), Some("beginning"), "Fill page window from the beginning of the result set") + + protected def execute(context: CliContext) { + val client = EventService.client(context.session) + + val states = try { + alarmStates.value.map { str => + Alarm.State.valueOf(str) + } + } catch { + case ex: Throwable => + throw new IllegalArgumentException("Couldn't parse alarm state") + } + + val b = EventQueryParams.newBuilder() + eventType.value.foreach(b.addEventType) + severities.value.foreach(b.addSeverity) + severityOrHigher.value.foreach(b.setSeverityOrHigher) + agents.value.foreach(b.addAgent) + b.setLatest(!nonLatest.value) + + val ab = AlarmQuery.newBuilder() + states.foreach(ab.addAlarmStates) + limit.value.foreach(ab.setPageSize) + ab.setEventQueryParams(b.build()) + + val query = ab.build() + + val results = Await.result(client.alarmQuery(query), 5000.milliseconds) + + AlarmView.printTable(results.toList) + } +} + +class AlarmSubscribeCommmand extends Command[CliContext] { + + val commandName = "alarm:subscribe" + val description = "Subscribe to alarms." + + val alarmStates = strings.optionRepeated(None, Some("alarm-state"), "Alarm states to include: " + List(Alarm.State.UNACK_SILENT, Alarm.State.UNACK_AUDIBLE, Alarm.State.ACKNOWLEDGED, Alarm.State.REMOVED).mkString(", ")) + + val eventType = strings.optionRepeated(Some("t"), Some("type"), "Event types to include") + val severities = ints.optionRepeated(Some("s"), Some("severity"), "Severities to include") + val agents = strings.optionRepeated(Some("a"), Some("agent"), "Agent names to include") + + val limit = ints.option(None, Some("limit"), "Maximum number of events to return") + + protected def execute(context: CliContext) { + val client = EventService.client(context.session) + + val states = try { + alarmStates.value.map { str => + Alarm.State.valueOf(str) + } + } catch { + case ex: Throwable => + throw new IllegalArgumentException("Couldn't parse alarm state") + } + + val b = EventSubscriptionQuery.newBuilder() + + eventType.value.foreach(b.addEventType) + severities.value.foreach(b.addSeverity) + agents.value.foreach(b.addAgent) + + limit.value.foreach(b.setLimit) + + val alarmBuilder = AlarmSubscriptionQuery.newBuilder().setEventQuery(b.build()) + + states.foreach(alarmBuilder.addAlarmStates) + + val (results, sub) = Await.result(client.subscribeToAlarms(alarmBuilder.build()), 5000.milliseconds) + + val widths = new AtomicReference[Seq[Int]](Nil) + + val origWidths = AlarmView.printTable(results.toList) + + widths.set(origWidths) + + sub.start { alarmNotification => + widths.set(AlarmView.printRows(List(alarmNotification.getValue), widths.get())) + } + + context.reader.readCharacter() + sub.cancel() + } +} + +class AlarmSilenceCommand extends Command[CliContext] { + + val commandName = "alarm:silence" + val description = "Silence events specified by ID." + + val alarmIds = strings.argRepeated("alarm id", "Alarm IDs") + + protected def execute(context: CliContext) { + val client = EventService.client(context.session) + + val updates = alarmIds.value.map { id => + AlarmStateUpdate.newBuilder() + .setAlarmId(ModelID.newBuilder().setValue(id).build()) + .setAlarmState(Alarm.State.UNACK_SILENT) + .build() + } + + val results = Await.result(client.putAlarmState(updates), 5000.milliseconds) + + AlarmView.printTable(results.toList) + } +} + +class AlarmAckCommand extends Command[CliContext] { + + val commandName = "alarm:ack" + val description = "Acknowledge events specified by ID." + + val alarmIds = strings.argRepeated("alarm id", "Alarm IDs") + + protected def execute(context: CliContext) { + val client = EventService.client(context.session) + + if (alarmIds.value.isEmpty) { + throw new IllegalArgumentException("Must include at least one id") + } + + val updates = alarmIds.value.map { id => + AlarmStateUpdate.newBuilder() + .setAlarmId(ModelID.newBuilder().setValue(id).build()) + .setAlarmState(Alarm.State.ACKNOWLEDGED) + .build() + } + + val results = Await.result(client.putAlarmState(updates), 5000.milliseconds) + + AlarmView.printTable(results.toList) + } +} + +class AlarmRemoveCommand extends Command[CliContext] { + + val commandName = "alarm:remove" + val description = "Remove events specified by ID." + + val alarmIds = strings.argRepeated("alarm id", "Alarm IDs") + + protected def execute(context: CliContext) { + val client = EventService.client(context.session) + + val updates = alarmIds.value.map { id => + AlarmStateUpdate.newBuilder() + .setAlarmId(ModelID.newBuilder().setValue(id).build()) + .setAlarmState(Alarm.State.REMOVED) + .build() + } + + val results = Await.result(client.putAlarmState(updates), 5000.milliseconds) + + AlarmView.printTable(results.toList) + } +} + diff --git a/cli/src/main/scala/io/greenbus/cli/commands/FrontEndCommands.scala b/cli/src/main/scala/io/greenbus/cli/commands/FrontEndCommands.scala new file mode 100644 index 0000000..eed94ca --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/commands/FrontEndCommands.scala @@ -0,0 +1,364 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.commands + +import java.util.concurrent.atomic.AtomicReference + +import io.greenbus.cli.view._ +import io.greenbus.cli.{ CliContext, Command } +import io.greenbus.client.service.proto.FrontEnd.FrontEndConnectionStatus +import io.greenbus.client.service.{ FrontEndService, ModelService } +import io.greenbus.client.service.proto.Model.{ Endpoint, Point, ModelUUID, Command => ProtoCommand } +import io.greenbus.client.service.proto.ModelRequests.{ EntityEdgeQuery, EntityKeySet, EntityPagingParams, _ } +import io.greenbus.msg.Session + +import scala.collection.JavaConversions._ +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration._ +import scala.concurrent.{ Await, Future } + +object EndpointListCommand { + + def endpointCombined(session: Session, query: EndpointQuery): Future[Seq[(Endpoint, Option[(FrontEndConnectionStatus.Status, Long)])]] = { + val modelClient = ModelService.client(session) + val frontEndClient = FrontEndService.client(session) + + val endFut = modelClient.endpointQuery(query) + + endFut.flatMap { endpoints => + val uuids = endpoints.map(_.getUuid).distinct + + frontEndClient.getFrontEndConnectionStatuses(EntityKeySet.newBuilder().addAllUuids(uuids).build()).map { statuses => + val statusMap = statuses.map(s => (s.getEndpointUuid, (s.getState, s.getUpdateTime))).toMap + endpoints.map { end => + (end, statusMap.get(end.getUuid)) + } + } + } + } + +} +class EndpointListCommand extends Command[CliContext] { + + val commandName = "endpoint:list" + val description = "List all endpoints" + + val protocols = strings.optionRepeated(Some("p"), Some("protocol"), "Endpoint protocols to query for") + + protected def execute(context: CliContext) { + val modelClient = ModelService.client(context.session) + val frontEndClient = FrontEndService.client(context.session) + + def allQueryResults(last: Option[ModelUUID], pageSize: Int): Future[Seq[(Endpoint, Option[(FrontEndConnectionStatus.Status, Long)])]] = { + + val pageBuilder = EntityPagingParams.newBuilder().setPageSize(pageSize) + last.foreach(pageBuilder.setLastUuid) + + val b = EndpointQuery.newBuilder().setPagingParams(pageBuilder) + + if (protocols.value.nonEmpty) { + b.addAllProtocols(protocols.value) + } + + EndpointListCommand.endpointCombined(context.session, b.build()) + } + + def pageBoundary(results: Seq[(Endpoint, Option[(FrontEndConnectionStatus.Status, Long)])]) = results.last._1.getUuid + + Paging.page(context.reader, allQueryResults, pageBoundary, EndpointStatusView.printTable, EndpointStatusView.printRows) + } +} + +class EndpointSubscribeCommand extends Command[CliContext] { + + val commandName = "endpoint:subscribe" + val description = "Subscribe to endpoints" + + val protocols = strings.optionRepeated(Some("p"), Some("protocol"), "Endpoint protocols to query for") + val allUpdates = optionSwitch(Some("a"), Some("all"), "Shows all subscription updates, even those with no status change") + + private case class Cache(widths: Seq[Int], endpoints: Map[ModelUUID, Endpoint], statuses: Map[ModelUUID, (FrontEndConnectionStatus.Status, Long)]) + + protected def execute(context: CliContext) { + val modelClient = ModelService.client(context.session) + val frontEndClient = FrontEndService.client(context.session) + + val b = EndpointSubscriptionQuery.newBuilder() + + if (protocols.value.nonEmpty) { + b.addAllProtocols(protocols.value) + } + + val query = b.build() + + val immediateEndpoints = { + val regQuery = EndpointQuery.newBuilder() + if (protocols.value.nonEmpty) b.addAllProtocols(protocols.value) + Await.result(EndpointListCommand.endpointCombined(context.session, regQuery.build()), 5000.milliseconds) + } + + val (_, sub) = Await.result(modelClient.subscribeToEndpoints(query), 5000.milliseconds) + val (_, statusSub) = Await.result(frontEndClient.subscribeToFrontEndConnectionStatuses(query), 5000.milliseconds) + + val endpointMap = immediateEndpoints.map(tup => (tup._1.getUuid, tup._1)).toMap + val statusMap = immediateEndpoints.flatMap(tup => tup._2.map(stat => (tup._1.getUuid, stat))).toMap + + val originals = immediateEndpoints.map(tup => (tup._1, tup._2)) + val origWidths = EndpointStatusView.printTable(originals.toList) + + val cacheRef = new AtomicReference[Cache](Cache(origWidths, endpointMap, statusMap)) + + sub.start { endpointEvent => + val cache = cacheRef.get() + val update = (endpointEvent.getValue, cache.statuses.get(endpointEvent.getValue.getUuid)) + val nextWidths = EndpointStatusView.printRows(List(update), cache.widths) + cacheRef.set( + cache.copy( + widths = nextWidths, + endpoints = cache.endpoints.updated(endpointEvent.getValue.getUuid, endpointEvent.getValue))) + } + + statusSub.start { statusEvent => + val stateTup = (statusEvent.getValue.getState, statusEvent.getValue.getUpdateTime) + val cache = cacheRef.get() + endpointMap.get(statusEvent.getValue.getEndpointUuid) match { + case Some(endpoint) => + val currentStatusOpt: Option[(FrontEndConnectionStatus.Status, Long)] = cache.statuses.get(endpoint.getUuid) + if (!currentStatusOpt.exists(tup => tup._1 == statusEvent.getValue.getState) || allUpdates.value) { + val update = (endpoint, Some(stateTup)) + val nextWidths = EndpointStatusView.printRows(List(update), cache.widths) + cacheRef.set( + cache.copy( + widths = nextWidths, + statuses = cache.statuses.updated(statusEvent.getValue.getEndpointUuid, stateTup))) + } else { + cacheRef.set(cache.copy(statuses = cache.statuses.updated(statusEvent.getValue.getEndpointUuid, stateTup))) + } + + case None => + cacheRef.set(cache.copy(statuses = cache.statuses.updated(statusEvent.getValue.getEndpointUuid, stateTup))) + } + } + + try { + context.reader.readCharacter() + } finally { + sub.cancel() + statusSub.cancel() + } + } +} + +class EndpointEnableCommand extends Command[CliContext] { + + val commandName = "endpoint:enable" + val description = "Enable endpoint" + + val names = strings.argRepeated("endpoint name", "Endpoint names") + + protected def execute(context: CliContext) { + val modelClient = ModelService.client(context.session) + + if (names.value.isEmpty) { + throw new IllegalArgumentException("Must include at least one argument") + } + + val keyQuery = EntityKeySet.newBuilder().addAllNames(names.value).build() + + val endpoints = Await.result(modelClient.getEndpoints(keyQuery), 5000.milliseconds) + + val nameToUuidMap = endpoints.map(end => (end.getName, end.getUuid)).toMap + + val updates = names.value.flatMap { name => + nameToUuidMap.get(name).map { uuid => + EndpointDisabledUpdate.newBuilder() + .setEndpointUuid(uuid) + .setDisabled(false) + .build() + } + } + + val results = Await.result(modelClient.putEndpointDisabled(updates), 5000.milliseconds) + + EndpointView.printTable(results.toList) + } +} + +class EndpointDisableCommand extends Command[CliContext] { + + val commandName = "endpoint:disable" + val description = "Disable endpoint" + + val names = strings.argRepeated("endpoint name", "Endpoint names") + + protected def execute(context: CliContext) { + val modelClient = ModelService.client(context.session) + + if (names.value.isEmpty) { + throw new IllegalArgumentException("Must include at least one argument") + } + + val keyQuery = EntityKeySet.newBuilder().addAllNames(names.value).build() + + val endpoints = Await.result(modelClient.getEndpoints(keyQuery), 5000.milliseconds) + + val nameToUuidMap = endpoints.map(end => (end.getName, end.getUuid)).toMap + + val updates = names.value.flatMap { name => + nameToUuidMap.get(name).map { uuid => + EndpointDisabledUpdate.newBuilder() + .setEndpointUuid(uuid) + .setDisabled(true) + .build() + } + } + + val results = Await.result(modelClient.putEndpointDisabled(updates), 5000.milliseconds) + + EndpointView.printTable(results.toList) + } +} + +class PointListCommand extends Command[CliContext] { + + val commandName = "point:list" + val description = "List all points" + + val showCommands = optionSwitch(Some("c"), Some("show-commands"), "Show commands associated with points") + + protected def execute(context: CliContext) { + val modelClient = ModelService.client(context.session) + + def vanillaResults(last: Option[ModelUUID], pageSize: Int) = { + + val pageBuilder = EntityPagingParams.newBuilder().setPageSize(pageSize) + last.foreach(pageBuilder.setLastUuid) + + val query = PointQuery.newBuilder().setPagingParams(pageBuilder).build() + modelClient.pointQuery(query) + } + + def resultsWithCommands(last: Option[ModelUUID], pageSize: Int): Future[Seq[(Point, Seq[String])]] = { + + val pageBuilder = EntityPagingParams.newBuilder().setPageSize(pageSize) + last.foreach(pageBuilder.setLastUuid) + + val pointQuery = PointQuery.newBuilder().setPagingParams(pageBuilder).build() + val pointsFut = modelClient.pointQuery(pointQuery) + + pointsFut.flatMap { + case Seq() => Future.successful(Nil) + case points => + + val query = EntityEdgeQuery.newBuilder() + .addAllParentUuids(points.map(_.getUuid)) + .addRelationships("feedback") + .build() + + modelClient.edgeQuery(query).flatMap { edges => + val grouped = edges.map(e => (e.getParent, e.getChild)).groupBy(_._1).mapValues(_.map(_._2)) + val commandIds = edges.map(_.getChild).distinct + + modelClient.getCommands(EntityKeySet.newBuilder().addAllUuids(commandIds).build()).map { commands => + val commandMap = commands.map(c => (c.getUuid, c.getName)).toMap + points.map { p => + val cmdIds: Seq[ModelUUID] = grouped.get(p.getUuid).getOrElse(Nil) + val cmdNames: Seq[String] = cmdIds.flatMap(id => commandMap.get(id)) + (p, cmdNames) + } + } + } + } + } + + def pageBoundary(results: Seq[Point]) = results.last.getUuid + def pageBoundaryWithCommands(results: Seq[(Point, Seq[String])]) = results.last._1.getUuid + + if (showCommands.value) { + Paging.page(context.reader, resultsWithCommands, pageBoundaryWithCommands, PointWithCommandsView.printTable, PointWithCommandsView.printRows) + } else { + Paging.page(context.reader, vanillaResults, pageBoundary, PointView.printTable, PointView.printRows) + } + } +} + +class CommandListCommand extends Command[CliContext] { + + val commandName = "command:list" + val description = "List all commands" + + val showCommands = optionSwitch(Some("p"), Some("show-points"), "Show points associated with commands") + + protected def execute(context: CliContext) { + val modelClient = ModelService.client(context.session) + + def vanillaResults(last: Option[ModelUUID], pageSize: Int) = { + + val pageBuilder = EntityPagingParams.newBuilder().setPageSize(pageSize) + last.foreach(pageBuilder.setLastUuid) + + val query = CommandQuery.newBuilder().setPagingParams(pageBuilder).build() + modelClient.commandQuery(query) + } + + def resultsWithPoints(last: Option[ModelUUID], pageSize: Int): Future[Seq[(ProtoCommand, Seq[String])]] = { + + val pageBuilder = EntityPagingParams.newBuilder().setPageSize(pageSize) + last.foreach(pageBuilder.setLastUuid) + + val query = CommandQuery.newBuilder().setPagingParams(pageBuilder).build() + + modelClient.commandQuery(query).flatMap { + case Seq() => Future.successful(Nil) + case commands => + + val query = EntityEdgeQuery.newBuilder() + .addAllChildUuids(commands.map(_.getUuid)) + .addRelationships("feedback") + .build() + + modelClient.edgeQuery(query).flatMap { + case Seq() => Future.successful(Nil) + case edges => + val grouped = edges.map(e => (e.getChild, e.getParent)).groupBy(_._1).mapValues(_.map(_._2)) + val pointIds = edges.map(_.getParent).distinct + + modelClient.getPoints(EntityKeySet.newBuilder().addAllUuids(pointIds).build()).map { points => + val pointMap = points.map(c => (c.getUuid, c.getName)).toMap + commands.map { c => + val pointIds: Seq[ModelUUID] = grouped.get(c.getUuid).getOrElse(Nil) + val pointNames: Seq[String] = pointIds.flatMap(id => pointMap.get(id)) + (c, pointNames) + } + } + } + } + } + + def pageBoundary(results: Seq[ProtoCommand]) = results.last.getUuid + def pageBoundaryWithCommands(results: Seq[(ProtoCommand, Seq[String])]) = results.last._1.getUuid + + if (showCommands.value) { + Paging.page(context.reader, resultsWithPoints, pageBoundaryWithCommands, CommandWithPointsView.printTable, CommandWithPointsView.printRows) + } else { + Paging.page(context.reader, vanillaResults, pageBoundary, CommandView.printTable, CommandView.printRows) + } + } +} + diff --git a/cli/src/main/scala/io/greenbus/cli/commands/KeyValueCommands.scala b/cli/src/main/scala/io/greenbus/cli/commands/KeyValueCommands.scala new file mode 100644 index 0000000..646b2d2 --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/commands/KeyValueCommands.scala @@ -0,0 +1,182 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.commands + +import io.greenbus.cli.view.KvView +import io.greenbus.cli.{ CliContext, Command } +import io.greenbus.client.service.ModelService +import io.greenbus.client.service.proto.Model.{ EntityKeyValue, StoredValue } +import io.greenbus.client.service.proto.ModelRequests.{ EntityKeyPair, EntityKeySet } + +import scala.concurrent.Await +import scala.concurrent.duration._ + +class KvListCommand extends Command[CliContext] { + + val commandName = "kv:list" + val description = "List keys of key-values for entity specified by name" + + val name = strings.arg("entity name", "Entity name") + + protected def execute(context: CliContext) { + val client = ModelService.client(context.session) + + val result = Await.result(client.get(EntityKeySet.newBuilder().addNames(name.value).build()), 5000.milliseconds) + + result.headOption match { + case None => println(s"Entity with name '${name.value}' not found.") + case Some(ent) => { + val keyResults = Await.result(client.getEntityKeys(Seq(ent.getUuid)), 5000.milliseconds) + + if (keyResults.nonEmpty) { + val kvResults = Await.result(client.getEntityKeyValues(keyResults), 5000.milliseconds) + + val nameAndKv = kvResults.map(r => (name.value, r)).sortBy(_._2.getKey) + KvView.printTable(nameAndKv) + } + } + } + } +} + +class KvViewCommand extends Command[CliContext] { + + val commandName = "kv:view" + val description = "View key-value for entity" + + val name = strings.arg("entity name", "Entity name") + val key = strings.arg("key", "Key") + + protected def execute(context: CliContext) { + val client = ModelService.client(context.session) + + val result = Await.result(client.get(EntityKeySet.newBuilder().addNames(name.value).build()), 5000.milliseconds) + + result.headOption match { + case None => println(s"Entity with name '${name.value}' not found.") + case Some(ent) => { + val keyPair = EntityKeyPair.newBuilder().setUuid(ent.getUuid).setKey(key.value).build() + val kvpResults = Await.result(client.getEntityKeyValues(Seq(keyPair)), 5000.milliseconds) + + kvpResults.headOption match { + case None => println(s"Key not found.") + case Some(kvp) => { + KvView.printInspect(kvp) + } + } + } + } + } +} + +class KvSetCommand extends Command[CliContext] { + + val commandName = "kv:set" + val description = "Set key-value for entity" + + val name = strings.arg("entity name", "Entity name") + val key = strings.arg("key", "Key") + + val boolVal = ints.option(Some("b"), Some("bool-val"), "Boolean value [0, 1]") + + val intVal = ints.option(Some("i"), Some("int-val"), "Integer value") + + val floatVal = doubles.option(Some("f"), Some("float-val"), "Floating point value") + + val stringVal = strings.option(Some("s"), Some("string-val"), "String value") + + protected def execute(context: CliContext) { + + List(boolVal.value, intVal.value, floatVal.value, stringVal.value).flatten.size match { + case 0 => throw new IllegalArgumentException("Must include at least one value option") + case 1 => + case n => throw new IllegalArgumentException("Must include only one value option") + } + + def storedValue = { + val b = StoredValue.newBuilder() + if (boolVal.value.nonEmpty) { + b.setBoolValue(boolVal.value != Some(0)) + } else if (intVal.value.nonEmpty) { + b.setInt64Value(intVal.value.get) + } else if (floatVal.value.nonEmpty) { + b.setDoubleValue(floatVal.value.get) + } else if (stringVal.value.nonEmpty) { + b.setStringValue(stringVal.value.get) + } else { + throw new IllegalArgumentException("Must include one value option") + } + b.build() + } + + val client = ModelService.client(context.session) + + val result = Await.result(client.get(EntityKeySet.newBuilder().addNames(name.value).build()), 5000.milliseconds) + + result.headOption match { + case None => println(s"Entity with name '${name.value}' not found.") + case Some(ent) => { + val kv = EntityKeyValue.newBuilder() + .setUuid(ent.getUuid) + .setKey(key.value) + .setValue(storedValue) + .build() + + val kvpResults = Await.result(client.putEntityKeyValues(Seq(kv)), 5000.milliseconds) + + kvpResults.headOption match { + case None => println(s"Key not put.") + case Some(kvp) => { + KvView.printInspect(kvp) + } + } + } + } + } +} + +class KvDeleteCommand extends Command[CliContext] { + + val commandName = "kv:delete" + val description = "Delete key-value for entity specified by name" + + val name = strings.arg("entity name", "Entity name") + val key = strings.arg("key", "Key") + + protected def execute(context: CliContext) { + val client = ModelService.client(context.session) + + val result = Await.result(client.get(EntityKeySet.newBuilder().addNames(name.value).build()), 5000.milliseconds) + + result.headOption match { + case None => println(s"Entity with name '${name.value}' not found.") + case Some(ent) => { + val keyPair = EntityKeyPair.newBuilder().setUuid(ent.getUuid).setKey(key.value).build() + val kvpResults = Await.result(client.deleteEntityKeyValues(Seq(keyPair)), 5000.milliseconds) + + kvpResults.headOption match { + case None => println(s"Key not found.") + case Some(kvp) => { + KvView.printInspect(kvp) + } + } + } + } + } +} \ No newline at end of file diff --git a/cli/src/main/scala/io/greenbus/cli/commands/LoginCommands.scala b/cli/src/main/scala/io/greenbus/cli/commands/LoginCommands.scala new file mode 100644 index 0000000..1edd99f --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/commands/LoginCommands.scala @@ -0,0 +1,70 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.commands + +import io.greenbus.cli.{ ManagementCliContext, Command } +import scala.concurrent.Await +import scala.concurrent.duration._ +import io.greenbus.client.service.LoginService +import io.greenbus.client.service.proto.LoginRequests.LogoutRequest +import io.greenbus.client.ServiceHeaders + +class LoginCommand extends Command[ManagementCliContext] { + + val commandName = "login" + val description = "Obtain an auth token from the services" + + val name = strings.arg("user name", "Agent name to login as.") + + val passOpt = strings.option(Some("p"), Some("password"), "Supply password instead of prompting, useful for scripting. WARNING: Password will be visible in command history.") + + protected def execute(context: ManagementCliContext) { + + val password = passOpt.value.getOrElse { + println("Enter password: ") + readLine() + } + + val session = Await.result(context.connection.login(name.value, password), 5000.milliseconds) + + context.setSession(Some(session)) + context.setAgent(Some(name.value)) + context.setToken(session.headers.get(ServiceHeaders.tokenHeader())) + } +} + +class LogoutCommand extends Command[ManagementCliContext] { + + val commandName = "logout" + val description = "Invalidate auth token and remove it" + + protected def execute(context: ManagementCliContext) { + + val sess = context.session + val token = sess.headers.get(ServiceHeaders.tokenHeader()).getOrElse { + throw new IllegalArgumentException("No auth token in session") + } + + val client = LoginService.client(sess) + val success = Await.result(client.logout(LogoutRequest.newBuilder().setToken(token).build()), 5000.milliseconds) + + context.setSession(None) + context.setAgent(None) + } +} \ No newline at end of file diff --git a/cli/src/main/scala/io/greenbus/cli/commands/MeasListCommand.scala b/cli/src/main/scala/io/greenbus/cli/commands/MeasListCommand.scala new file mode 100644 index 0000000..ba7a1c3 --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/commands/MeasListCommand.scala @@ -0,0 +1,290 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.commands + +import java.io.{ PrintWriter, FileOutputStream } +import java.text.SimpleDateFormat +import java.util.concurrent.atomic.AtomicReference + +import io.greenbus.cli.view._ +import io.greenbus.cli.{ CliContext, Command } +import io.greenbus.client.service.proto.MeasurementRequests.MeasurementHistoryQuery +import io.greenbus.client.service.proto.Measurements.Measurement +import io.greenbus.client.service.proto.Model.{ Point, ModelUUID } +import io.greenbus.client.service.proto.ModelRequests.{ EntityKeySet, EntityPagingParams, PointQuery } +import io.greenbus.client.service.{ MeasurementService, ModelService } + +import scala.collection.JavaConversions._ +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration._ +import scala.concurrent.{ Await, Future } + +class MeasListCommand extends Command[CliContext] { + + val commandName = "meas:list" + val description = "List current measurement values" + + protected def execute(context: CliContext) { + val modelClient = ModelService.client(context.session) + val measClient = MeasurementService.client(context.session) + + def results(last: Option[ModelUUID], pageSize: Int) = { + + val pageBuilder = EntityPagingParams.newBuilder().setPageSize(pageSize) + last.foreach(pageBuilder.setLastUuid) + + val query = PointQuery.newBuilder().setPagingParams(pageBuilder).build() + + modelClient.pointQuery(query).flatMap { + case Seq() => Future.successful(Nil) + case points => + val uuids = points.map(_.getUuid) + measClient.getCurrentValues(uuids).map { pointMeas => + val pointMap = pointMeas.map(p => (p.getPointUuid, p)).toMap + points.map { point => + val measOpt = pointMap.get(point.getUuid) + (point, measOpt.map(_.getValue)) + } + } + } + + } + + def pageBoundary(results: Seq[(Point, Option[Measurement])]) = results.last._1.getUuid + + Paging.page(context.reader, results, pageBoundary, MeasWithEmptiesView.printTable, MeasWithEmptiesView.printRows) + } +} + +class MeasHistoryCommand extends Command[CliContext] { + + val commandName = "meas:history" + val description = "Get history for measurement" + + val measName = strings.arg("name", "Name of point to retrieve history for") + + protected def execute(context: CliContext) { + val modelClient = ModelService.client(context.session) + val measClient = MeasurementService.client(context.session) + + val pointResults = Await.result(modelClient.get(EntityKeySet.newBuilder().addNames(measName.value).build()), 5000.milliseconds) + + val point = pointResults.headOption.getOrElse { + throw new IllegalArgumentException("Point does not exist") + } + + def results(last: Option[Long], pageSize: Int) = { + val queryBuilder = MeasurementHistoryQuery.newBuilder() + .setPointUuid(point.getUuid) + .setLatest(true) + .setLimit(pageSize) + + last.foreach(queryBuilder.setTimeTo) + + val query = queryBuilder.build() + + measClient.getHistory(query).map(_.getValueList.toSeq.reverse) + } + + def pageBoundary(results: Seq[Measurement]) = results.last.getTime + + Paging.page(context.reader, results, pageBoundary, MeasurementHistoryView.printTable, MeasurementHistoryView.printRows) + } +} + +class MeasSubscribeCommand extends Command[CliContext] { + + val commandName = "meas:subscribe" + val description = "Subscribe to measurement update values" + + val allFlag = optionSwitch(Some("a"), Some("all"), "Subscribe to all updates") + + val measNames = strings.argRepeated("name", "Name of point to retrieve history for") + + protected def execute(context: CliContext) { + val modelClient = ModelService.client(context.session) + val measClient = MeasurementService.client(context.session) + + val sub = if (allFlag.value) { + + if (measNames.value.nonEmpty) { + throw new IllegalArgumentException("Must not include measurement names when subscribing to all") + } + + val (_, sub) = Await.result(measClient.getCurrentValuesAndSubscribe(Seq()), 5000.milliseconds) + + val widths = new AtomicReference(Seq.empty[Int]) + + sub.start { measNotification => + widths.set(SimpleMeasurementView.printRows(List((measNotification.getPointName, measNotification.getValue, "--")), widths.get())) + } + + sub + + } else { + + if (measNames.value.isEmpty) { + throw new IllegalArgumentException("Must include at least one measurement name") + } + + val pointResults = Await.result(modelClient.getPoints(EntityKeySet.newBuilder().addAllNames(measNames.value).build()), 5000.milliseconds) + + if (pointResults.isEmpty) { + throw new IllegalArgumentException("Points did not exist") + } + + val uuidToPointMap = pointResults.map(p => p.getUuid -> p).toMap + + val (results, sub) = Await.result(measClient.getCurrentValuesAndSubscribe(pointResults.map(_.getUuid)), 5000.milliseconds) + + val origWidths = SimpleMeasurementView.printTable(results.flatMap(r => uuidToPointMap.get(r.getPointUuid).map(p => (p.getName, r.getValue, p.getUnit))).toList) + + val widths = new AtomicReference(origWidths) + + sub.start { measNotification => + val pointOpt = uuidToPointMap.get(measNotification.getPointUuid) + pointOpt.foreach { point => + widths.set(SimpleMeasurementView.printRows(List((measNotification.getPointName, measNotification.getValue, point.getUnit)), widths.get())) + } + } + + sub + } + + context.reader.readCharacter() + sub.cancel() + } +} + +class MeasDownloadCommand extends Command[CliContext] { + + val commandName = "meas:download" + val description = "Download measurement history to a file" + + val measName = strings.arg("name", "Name of point to retrieve history for") + + val fileName = strings.option(Some("o"), Some("output"), "Filename to write output to") + + val startTimeOption = strings.option(Some("s"), Some("start"), "Start time, UTC, in format \"yyyy-MM-dd HH:mm\"") + val endTimeOption = strings.option(Some("e"), Some("end"), "End time, UTC, in format \"yyyy-MM-dd HH:mm\"") + + val offsetHours = ints.option(None, Some("hours-offset"), "Duration time, in hours, offset from the end time") + val offsetMinutes = ints.option(None, Some("minutes-offset"), "Duration time, in minutes, offset from the end time") + + val delimiterOption = strings.option(Some("d"), None, "Delimiter character (e.g. tab-separated, comma-separated). Tab-separated by default.") + + val rawTimeOption = optionSwitch(None, Some("raw-time"), "Write time in milliseconds since 1970 UTC") + val dateFormatOption = strings.option(None, Some("date-format"), "Specifies format for time. See documentation for the SimpleDataFormat Java class for the syntax.") + + protected def execute(context: CliContext) { + val modelClient = ModelService.client(context.session) + val measClient = MeasurementService.client(context.session) + + val pointResults = Await.result(modelClient.get(EntityKeySet.newBuilder().addNames(measName.value).build()), 5000.milliseconds) + + val point = pointResults.headOption.getOrElse { + throw new IllegalArgumentException(s"Point ${measName.value} not found.") + } + + val endTimeMillis = endTimeOption.value match { + case None => System.currentTimeMillis() + case Some(endTime) => + try { + val date = new SimpleDateFormat("yyyy-MM-dd HH:mm").parse(endTimeOption.value.get) + date.getTime + } catch { + case ex: Throwable => + throw new IllegalArgumentException("Could not parse date format for end time.") + } + } + + val startTimeMillis = if (startTimeOption.value.nonEmpty) { + try { + val date = new SimpleDateFormat("yyyy-MM-dd HH:mm").parse(startTimeOption.value.get) + date.getTime + } catch { + case ex: Throwable => + throw new IllegalArgumentException("Could not parse date format for start time.") + } + } else if (offsetHours.value.nonEmpty || offsetMinutes.value.nonEmpty) { + endTimeMillis - + offsetHours.value.map(h => h * 60 * 60 * 1000).getOrElse(0) - + offsetMinutes.value.map(min => min * 60 * 1000).getOrElse(0) + } else { + throw new IllegalArgumentException("Must set a start time or a time offset.") + } + + val delimiter = delimiterOption.value.getOrElse("\t") + + val dateFormatter = dateFormatOption.value.map(s => new SimpleDateFormat(s)) + + def timeFormat(m: Measurement): String = { + if (rawTimeOption.value) { + m.getTime.toString + } else if (dateFormatOption.value.nonEmpty) { + dateFormatter.map(f => f.format(m.getTime)).getOrElse(throw new IllegalArgumentException("Couldn't build date formatter")) + } else { + MeasViewCommon.timeString(m) + } + } + + def toMeasLine(m: Measurement): String = { + val time = timeFormat(m) + Seq(time, MeasViewCommon.value(m), MeasViewCommon.shortQuality(m), MeasViewCommon.longQuality(m)).mkString(delimiter) + } + + val pageSize = 1000 + + def historyBuilder(startTime: Long): MeasurementHistoryQuery = { + MeasurementHistoryQuery.newBuilder() + .setPointUuid(point.getUuid) + .setLatest(false) + .setTimeFrom(startTime) + .setTimeTo(endTimeMillis) + .setLimit(pageSize) + .build() + } + + val filepath = fileName.value.getOrElse(throw new IllegalArgumentException("Must include output filename.")) + val file = new FileOutputStream(filepath) + val pw = new PrintWriter(file) + + try { + + var latestStartTime = startTimeMillis + var done = false + while (!done) { + val query = historyBuilder(latestStartTime) + val results = Await.result(measClient.getHistory(query), 10000.milliseconds) + + results.getValueList.foreach { m => pw.println(toMeasLine(m)) } + + if (results.getValueList.size() < pageSize) { + done = true + } else { + latestStartTime = results.getValueList.last.getTime + } + } + } finally { + pw.flush() + pw.close() + } + } +} + diff --git a/cli/src/main/scala/io/greenbus/cli/commands/Paging.scala b/cli/src/main/scala/io/greenbus/cli/commands/Paging.scala new file mode 100644 index 0000000..9771c1a --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/commands/Paging.scala @@ -0,0 +1,58 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.commands + +import jline.console.ConsoleReader +import scala.concurrent.{ Await, Future } +import scala.annotation.tailrec +import scala.concurrent.duration._ + +object Paging { + + def page[A, B](reader: ConsoleReader, getResults: (Option[B], Int) => Future[Seq[A]], pageBoundary: Seq[A] => B, displayFirst: (Seq[A], Seq[Int]) => Seq[Int], displaySubsequent: (Seq[A], Seq[Int]) => Seq[Int]) { + + @tailrec + def getAndPrint(last: Option[B], minColWidths: Seq[Int]) { + val terminalHeight = reader.getTerminal.getHeight + val pageSize = if (last.isEmpty) terminalHeight - 3 else terminalHeight + + val pageResults = Await.result(getResults(last, pageSize), 5000.milliseconds) + + if (pageResults.nonEmpty) { + + val columnWidths = if (last.isEmpty) { + displayFirst(pageResults.toList, minColWidths) + } else { + displaySubsequent(pageResults.toList, minColWidths) + } + + if (pageResults.size == pageSize) { + val inputInt = reader.readCharacter() + val input = inputInt.asInstanceOf[Char] + if (!(input == 'q')) { + getAndPrint(Some(pageBoundary(pageResults)), columnWidths) + } + } + + } + } + + getAndPrint(None, Nil) + } +} diff --git a/cli/src/main/scala/io/greenbus/cli/commands/ProcessingCommands.scala b/cli/src/main/scala/io/greenbus/cli/commands/ProcessingCommands.scala new file mode 100644 index 0000000..5a3e3b8 --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/commands/ProcessingCommands.scala @@ -0,0 +1,174 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.commands + +import io.greenbus.cli.{ CliContext, Command } +import io.greenbus.client.service.proto.Measurements.{ Quality, Measurement } +import io.greenbus.client.service.proto.ModelRequests.EntityKeySet +import io.greenbus.client.service.proto.Processing.MeasOverride +import io.greenbus.client.service.{ MeasurementService, ModelService, ProcessingService } + +import scala.concurrent.Await +import scala.concurrent.duration._ + +class MeasBlockCommand extends Command[CliContext] { + + val commandName = "meas:block" + val description = "Block measurement from being updated" + + val measName = strings.arg("measurement name", "Measurement name") + + protected def execute(context: CliContext) { + + val modelClient = ModelService.client(context.session) + val pointFut = modelClient.getPoints(EntityKeySet.newBuilder().addNames(measName.value).build()) + val pointResults = Await.result(pointFut, 5000.milliseconds) + + if (pointResults.isEmpty) { + throw new IllegalArgumentException(s"Point '${measName.value}' not found") + } + + val pointUuid = pointResults.head.getUuid + + val procClient = ProcessingService.client(context.session) + + val overs = MeasOverride.newBuilder() + .setPointUuid(pointUuid) + .build() + + Await.result(procClient.putOverrides(Seq(overs)), 5000.milliseconds) + + println("Success") + } +} + +class MeasUnblockCommand extends Command[CliContext] { + + val commandName = "meas:unblock" + val description = "Unblock measurement" + + val measName = strings.arg("measurement name", "Measurement name") + + protected def execute(context: CliContext) { + + val modelClient = ModelService.client(context.session) + val pointFut = modelClient.getPoints(EntityKeySet.newBuilder().addNames(measName.value).build()) + val pointResults = Await.result(pointFut, 5000.milliseconds) + + if (pointResults.isEmpty) { + throw new IllegalArgumentException(s"Point '${measName.value}' not found") + } + + val pointUuid = pointResults.head.getUuid + + val procClient = ProcessingService.client(context.session) + + Await.result(procClient.deleteOverrides(Seq(pointUuid)), 5000.milliseconds) + + println("Success") + + } +} + +class MeasReplaceCommand extends Command[CliContext] { + + val commandName = "meas:replace" + val description = "Block measurement and replace with manual value" + + val measName = strings.arg("measurement name", "Measurement name") + + val boolVal = ints.option(Some("b"), Some("bool-val"), "Boolean value [0, 1]") + + val intVal = ints.option(Some("i"), Some("int-val"), "Integer value") + + val floatVal = doubles.option(Some("f"), Some("float-val"), "Floating point value") + + val stringVal = strings.option(Some("s"), Some("string-val"), "String value") + + val isTest = optionSwitch(Some("t"), Some("test"), "Replace is a test value") + + protected def execute(context: CliContext) { + + List(boolVal.value, intVal.value, floatVal.value, stringVal.value).flatten.size match { + case 0 => throw new IllegalArgumentException("Must include at least one value option") + case 1 => + case n => throw new IllegalArgumentException("Must include only one value option") + } + + def reinitializeBuilder(b: Measurement.Builder) { + if (boolVal.value.nonEmpty) { + if (boolVal.value.get == 0) { + b.setBoolVal(false).setType(Measurement.Type.BOOL) + } else if (boolVal.value.get == 1) { + b.setBoolVal(true).setType(Measurement.Type.BOOL) + } else { + throw new IllegalArgumentException("Boolean values must be 0 or 1") + } + } else if (intVal.value.nonEmpty) { + b.setIntVal(intVal.value.get).setType(Measurement.Type.INT) + } else if (floatVal.value.nonEmpty) { + b.setDoubleVal(floatVal.value.get).setType(Measurement.Type.DOUBLE) + } else if (stringVal.value.nonEmpty) { + b.setStringVal(stringVal.value.get).setType(Measurement.Type.STRING) + } else { + throw new IllegalArgumentException("Must include at least one value option") + } + } + + val modelClient = ModelService.client(context.session) + val pointFut = modelClient.getPoints(EntityKeySet.newBuilder().addNames(measName.value).build()) + val pointResults = Await.result(pointFut, 5000.milliseconds) + + if (pointResults.isEmpty) { + throw new IllegalArgumentException(s"Point '${measName.value}' not found") + } + + val pointUuid = pointResults.head.getUuid + + val measClient = MeasurementService.client(context.session) + + val measFut = measClient.getCurrentValues(Seq(pointUuid)) + + val measResults = Await.result(measFut, 5000.milliseconds) + + val measBuilder = if (measResults.isEmpty) { + Measurement.newBuilder().setQuality(Quality.newBuilder().build()).setTime(System.currentTimeMillis()) + } else { + measResults.head.getValue.toBuilder + } + + reinitializeBuilder(measBuilder) + + val procClient = ProcessingService.client(context.session) + + val overBuilder = MeasOverride.newBuilder() + .setPointUuid(pointUuid) + .setMeasurement(measBuilder.build()) + + if (isTest.value) { + overBuilder.setTestValue(true) + } + + val overs = overBuilder.build() + + Await.result(procClient.putOverrides(Seq(overs)), 5000.milliseconds) + + println("Success") + } +} diff --git a/cli/src/main/scala/io/greenbus/cli/view/AgentView.scala b/cli/src/main/scala/io/greenbus/cli/view/AgentView.scala new file mode 100755 index 0000000..65e80ee --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/view/AgentView.scala @@ -0,0 +1,98 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.view + +import io.greenbus.client.service.proto.Auth.{ Permission, EntitySelector, PermissionSet, Agent } +import scala.collection.JavaConversions._ + +object AgentView extends TableView[Agent] { + def header: List[String] = "Name" :: "Permission Sets" :: Nil + + def row(obj: Agent): List[String] = { + obj.getName :: + obj.getPermissionSetsList.mkString(", ") :: + Nil + } + + def viewAgent(agent: Agent, perms: Seq[PermissionSet]) = { + val agentRows = Seq( + Seq("UUID: ", agent.getUuid.getValue), + Seq("Name: ", agent.getName)) + + Table.renderRows(agentRows) + println() + + val permRows = perms.flatMap(p => p.getPermissionsList.map(PermissionSetView.permissionRowWithSet(_, p.getName))) + Table.printTable(PermissionSetView.permissionHeaderWithSet, permRows) + } +} + +object PermissionSetView extends TableView[PermissionSet] { + def header: List[String] = "Name" :: "Allows" :: "Denies" :: Nil + + def row(obj: PermissionSet): List[String] = { + val (allows, denies) = obj.getPermissionsList.toSeq.partition(_.getAllow) + + obj.getName :: + allows.size.toString :: + denies.size.toString :: + Nil + } + + def viewPermissionSet(a: PermissionSet) = { + + val permissions = a.getPermissionsList.toList + + Table.printTable(permissionHeader, permissions.map(permissionRow)) + } + + def permissionHeader = { + "Allow" :: "Actions" :: "Resources" :: "Selectors" :: Nil + } + + def permissionRow(a: Permission): List[String] = { + a.getAllow.toString :: + a.getActionsList.toList.mkString(",") :: + a.getResourcesList.toList.mkString(",") :: + a.getSelectorsList.toList.map(selectorString).mkString(",") :: + Nil + } + + def permissionHeaderWithSet = { + "Allow" :: "Actions" :: "Resources" :: "Selectors" :: "Permission set" :: Nil + } + + def permissionRowWithSet(a: Permission, setName: String): List[String] = { + a.getAllow.toString :: + a.getActionsList.toList.mkString(",") :: + a.getResourcesList.toList.mkString(",") :: + a.getSelectorsList.toList.map(selectorString).mkString(",") :: + setName :: + Nil + } + + def selectorString(a: EntitySelector): String = { + val args = a.getArgumentsList.toList + val argString = if (args.isEmpty) "" + else args.mkString("(", ",", ")") + a.getStyle + argString + } + +} + diff --git a/cli/src/main/scala/io/greenbus/cli/view/CalculationView.scala b/cli/src/main/scala/io/greenbus/cli/view/CalculationView.scala new file mode 100644 index 0000000..d6b7220 --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/view/CalculationView.scala @@ -0,0 +1,34 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.view + +import io.greenbus.client.service.proto.Calculations.CalculationDescriptor +import io.greenbus.client.service.proto.Model.Entity + +object CalculationView extends TableView[(Entity, Option[CalculationDescriptor])] { + def header: List[String] = "Point Name" :: "Formula" :: "Unit" :: Nil + + def row(obj: (Entity, Option[CalculationDescriptor])): List[String] = { + val (ent, calcOpt) = obj + ent.getName :: + calcOpt.map(_.getFormula).getOrElse("-") :: + "unit" :: + Nil + } +} \ No newline at end of file diff --git a/cli/src/main/scala/io/greenbus/cli/view/CommandView.scala b/cli/src/main/scala/io/greenbus/cli/view/CommandView.scala new file mode 100644 index 0000000..8f127b0 --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/view/CommandView.scala @@ -0,0 +1,56 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.view + +import io.greenbus.client.service.proto.Commands.CommandLock +import java.util.Date + +object CommandLockView extends TableView[(CommandLock, Seq[String], String)] { + + def header: List[String] = "Id" :: "Mode" :: "User" :: "Commands" :: "Expire Time" :: Nil + + def row(obj: (CommandLock, Seq[String], String)): List[String] = { + val (lock, cmds, agent) = obj + + lock.getId.getValue :: + lock.getAccess.toString :: + agent :: + cmds.mkString(", ") :: + lockTimeString(lock) :: + Nil + } + + def printInspect(item: (CommandLock, Seq[String], String)) = { + val (lock, cmds, agent) = item + + val rows: List[List[String]] = ("ID:" :: lock.getId.getValue :: Nil) :: + ("Mode:" :: lock.getAccess.toString :: Nil) :: + ("User:" :: agent :: Nil) :: + ("Expires:" :: lockTimeString(lock) :: Nil) :: + ("Commands:" :: cmds.head :: Nil) :: Nil + + val cmdRows = cmds.tail.map(cmd => "" :: cmd :: Nil) + + Table.renderRows(rows ::: cmdRows.toList, " ") + } + + private def lockTimeString(lock: CommandLock): String = { + if (lock.hasExpireTime) new Date(lock.getExpireTime).toString else "" + } +} diff --git a/cli/src/main/scala/io/greenbus/cli/view/EntityView.scala b/cli/src/main/scala/io/greenbus/cli/view/EntityView.scala new file mode 100755 index 0000000..6b45574 --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/view/EntityView.scala @@ -0,0 +1,42 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.view + +import io.greenbus.client.service.proto.Model.Entity +import scala.collection.JavaConversions._ + +object EntityView extends TableView[Entity] { + def header: List[String] = "Name" :: "Types" :: Nil + + def row(obj: Entity): List[String] = { + obj.getName :: + "(" + obj.getTypesList.toVector.sorted.mkString(", ") + ")" :: + Nil + } + + def printInspect(ent: Entity) = { + val lines = + ("id" :: ent.getUuid.getValue :: Nil) :: + ("name" :: ent.getName :: Nil) :: + ("types" :: "(" + ent.getTypesList.toVector.sorted.mkString(", ") + ")" :: Nil) :: + Nil + + Table.renderRows(lines, " | ") + } +} diff --git a/cli/src/main/scala/io/greenbus/cli/view/EventView.scala b/cli/src/main/scala/io/greenbus/cli/view/EventView.scala new file mode 100644 index 0000000..bdee962 --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/view/EventView.scala @@ -0,0 +1,67 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.view + +import io.greenbus.client.service.proto.Events.{ Event, Alarm, EventConfig } + +object EventConfigView extends TableView[EventConfig] { + def header: List[String] = "EventType" :: "Dest" :: "Sev" :: "Audible" :: "Resources" :: "BuiltIn" :: Nil + + def row(obj: EventConfig): List[String] = { + obj.getEventType :: + obj.getDesignation.toString :: + obj.getSeverity.toString :: + (obj.getAlarmState == Alarm.State.UNACK_AUDIBLE).toString :: + obj.getResource :: + obj.getBuiltIn.toString :: Nil + } +} + +object EventView extends TableView[Event] { + def header: List[String] = "ID" :: "EventType" :: "IsAlarm" :: "Time" :: "Sev" :: "Agent" :: "DeviceTime" :: "Subsystem" :: "Message" :: Nil + + def row(obj: Event): List[String] = { + obj.getId.getValue :: + obj.getEventType :: + obj.getAlarm.toString :: + new java.util.Date(obj.getTime).toString :: + obj.getSeverity.toString :: + obj.getAgentName :: + (if (obj.hasDeviceTime) new java.util.Date(obj.getDeviceTime).toString else "") :: + obj.getSubsystem :: + obj.getRendered :: Nil + } +} + +object AlarmView extends TableView[Alarm] { + def header: List[String] = "ID" :: "State" :: "EventType" :: "Time" :: "Sev" :: "Agent" :: "DeviceTime" :: "Subsystem" :: "Message" :: Nil + + def row(obj: Alarm): List[String] = { + obj.getId.getValue :: + obj.getState.toString :: + obj.getEvent.getEventType :: + new java.util.Date(obj.getEvent.getTime).toString :: + obj.getEvent.getSeverity.toString :: + obj.getEvent.getAgentName :: + (if (obj.getEvent.hasDeviceTime) new java.util.Date(obj.getEvent.getDeviceTime).toString else "") :: + obj.getEvent.getSubsystem :: + obj.getEvent.getRendered :: Nil + } +} + diff --git a/cli/src/main/scala/io/greenbus/cli/view/FrontEndView.scala b/cli/src/main/scala/io/greenbus/cli/view/FrontEndView.scala new file mode 100644 index 0000000..723963e --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/view/FrontEndView.scala @@ -0,0 +1,102 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.view + +import java.util.Date + +import io.greenbus.client.service.proto.FrontEnd.FrontEndConnectionStatus +import io.greenbus.client.service.proto.Model.{ Command, Endpoint, Point } + +import scala.collection.JavaConversions._ + +object EndpointView extends TableView[Endpoint] { + def header: List[String] = "Name" :: "Protocol" :: "Disabled" :: "Types" :: Nil + + def row(obj: Endpoint): List[String] = { + obj.getName :: + obj.getProtocol :: + obj.getDisabled.toString :: + "(" + obj.getTypesList.toVector.sorted.mkString(", ") + ")" :: + Nil + } +} + +object EndpointStatusView extends TableView[(Endpoint, Option[(FrontEndConnectionStatus.Status, Long)])] { + def header: List[String] = "Name" :: "Status " :: "Last Status" :: "Protocol" :: "Disabled" :: "Types" :: Nil + + def row(obj: (Endpoint, Option[(FrontEndConnectionStatus.Status, Long)])): List[String] = { + val (end, (statusOpt)) = obj + end.getName :: + statusOpt.map(_._1.toString).getOrElse("--") :: + statusOpt.map(t => new Date(t._2).toString).getOrElse("--") :: + end.getProtocol :: + end.getDisabled.toString :: + "(" + end.getTypesList.toVector.sorted.mkString(", ") + ")" :: + Nil + } +} + +object PointView extends TableView[Point] { + def header: List[String] = "Name" :: "Category" :: "Unit" :: "Types" :: Nil + + def row(obj: Point): List[String] = { + obj.getName :: + obj.getPointCategory.toString :: + obj.getUnit :: + "(" + obj.getTypesList.toVector.sorted.mkString(", ") + ")" :: + Nil + } +} +object PointWithCommandsView extends TableView[(Point, Seq[String])] { + def header: List[String] = "Name" :: "Category" :: "Unit" :: "Commands" :: "Types" :: Nil + + def row(obj: (Point, Seq[String])): List[String] = { + obj._1.getName :: + obj._1.getPointCategory.toString :: + obj._1.getUnit :: + obj._2.mkString(", ") :: + "(" + obj._1.getTypesList.toVector.sorted.mkString(", ") + ")" :: + Nil + } +} + +object CommandView extends TableView[Command] { + def header: List[String] = "Name" :: "DisplayName" :: "Category" :: "Types" :: Nil + + def row(obj: Command): List[String] = { + obj.getName :: + obj.getDisplayName :: + obj.getCommandCategory.toString :: + "(" + obj.getTypesList.toVector.sorted.mkString(", ") + ")" :: + Nil + } +} + +object CommandWithPointsView extends TableView[(Command, Seq[String])] { + def header: List[String] = "Name" :: "DisplayName" :: "Category" :: "Points" :: "Types" :: Nil + + def row(obj: (Command, Seq[String])): List[String] = { + obj._1.getName :: + obj._1.getDisplayName :: + obj._1.getCommandCategory.toString :: + obj._2.mkString(", ") :: + "(" + obj._1.getTypesList.toVector.sorted.mkString(", ") + ")" :: + Nil + } +} diff --git a/cli/src/main/scala/io/greenbus/cli/view/KvView.scala b/cli/src/main/scala/io/greenbus/cli/view/KvView.scala new file mode 100644 index 0000000..1f882aa --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/view/KvView.scala @@ -0,0 +1,66 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.view + +import io.greenbus.client.service.proto.Model.{ EntityKeyValue, StoredValue } + +object KvView extends TableView[(String, EntityKeyValue)] { + + def storedValueRow(v: StoredValue): String = { + if (v.hasBoolValue) { + v.getBoolValue.toString + } else if (v.hasInt32Value) { + v.getInt32Value.toString + } else if (v.hasInt64Value) { + v.getInt64Value.toString + } else if (v.hasUint32Value) { + v.getUint32Value.toString + } else if (v.hasUint64Value) { + v.getUint64Value.toString + } else if (v.hasDoubleValue) { + v.getDoubleValue.toString + } else if (v.hasStringValue) { + "\"" + v.getStringValue + "\"" + } else if (v.hasByteArrayValue) { + val size = v.getByteArrayValue.size() + s"[bytes] ($size)" + } else { + "?" + } + } + + def header: List[String] = "Name" :: "Key" :: "Value" :: Nil + + def row(obj: (String, EntityKeyValue)): List[String] = { + obj._1 :: + obj._2.getKey :: + storedValueRow(obj._2.getValue) :: + Nil + } + + def printInspect(v: EntityKeyValue) = { + val lines = + ("uuid" :: v.getUuid.getValue :: Nil) :: + ("key" :: v.getKey :: Nil) :: + ("value" :: storedValueRow(v.getValue) :: Nil) :: + Nil + + Table.renderRows(lines, " | ") + } +} diff --git a/cli/src/main/scala/io/greenbus/cli/view/MeasView.scala b/cli/src/main/scala/io/greenbus/cli/view/MeasView.scala new file mode 100755 index 0000000..c1655d2 --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/view/MeasView.scala @@ -0,0 +1,150 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.view + +import io.greenbus.client.service.proto.Measurements.{ Measurement, PointMeasurementValue, Quality } +import io.greenbus.client.service.proto.Model.Point + +object MeasurementHistoryView extends TableView[Measurement] { + import io.greenbus.cli.view.MeasViewCommon._ + def header: List[String] = "Time" :: "Value" :: "Type" :: "Q" :: "Off" :: Nil + + def row(m: Measurement): List[String] = { + val (value, typ) = valueAndType(m) + timeString(m) :: value.toString :: typ :: shortQuality(m) :: offset(m) :: Nil + } + +} + +object MeasWithEmptiesView extends TableView[(Point, Option[Measurement])] { + import io.greenbus.cli.view.MeasViewCommon._ + + def header: List[String] = "Name" :: "Value" :: "Type" :: "Unit" :: "Q" :: "Time" :: "Off" :: Nil + + def row(obj: (Point, Option[Measurement])): List[String] = { + + obj._2 match { + case None => obj._1.getName :: "--" :: "--" :: "--" :: "--" :: "--" :: "--" :: Nil + case Some(m) => + val (value, typ) = valueAndType(m) + obj._1.getName :: value.toString :: typ :: unit(obj._1) :: shortQuality(m) :: timeString(m) :: offset(m) :: Nil + } + } + +} + +object SimpleMeasurementView extends TableView[(String, Measurement, String)] { + import io.greenbus.cli.view.MeasViewCommon._ + + def header: List[String] = "Name" :: "Value" :: "Type" :: "Unit" :: "Q" :: "Time" :: "Off" :: Nil + + def row(obj: (String, Measurement, String)): List[String] = { + val (name, m, unit) = obj + val (value, typ) = valueAndType(m) + name :: value.toString :: typ :: unit :: shortQuality(m) :: timeString(m) :: offset(m) :: Nil + } + +} + +object MeasViewCommon { + def valueAndType(m: Measurement): (Any, String) = { + if (m.getType == Measurement.Type.BOOL) { + val repr = if (m.getBoolVal) "HIGH" else "LOW" + (repr, "Binary") + } else if (m.getType == Measurement.Type.DOUBLE) { + (String.format("%.3f", m.getDoubleVal.asInstanceOf[AnyRef]), "Analog") + } else if (m.getType == Measurement.Type.INT) { + (m.getIntVal.toString, "Analog") + } else if (m.getType == Measurement.Type.STRING) { + (m.getStringVal, "String") + } else { + ("(unknown)", "(unknown)") + } + } + + def value(m: Measurement): Any = { + val (value, typ) = valueAndType(m) + value + } + + def unit(p: Point) = if (p.hasUnit) p.getUnit else "" + + def shortQuality(m: Measurement) = { + val q = m.getQuality + + if (q.getSource == Quality.Source.SUBSTITUTED) { + "R" + } else if (q.getOperatorBlocked) { + "N" + } else if (q.getTest) { + "T" + } else if (q.getDetailQual.getOldData) { + "O" + } else if (q.getValidity == Quality.Validity.QUESTIONABLE) { + "A" + } else if (q.getValidity != Quality.Validity.GOOD) { + "B" + } else { + "" + } + } + + def longQuality(m: Measurement): String = { + val q = m.getQuality + longQuality(q) + } + + def longQuality(q: Quality): String = { + val dq = q.getDetailQual + + var list = List.empty[String] + if (q.getOperatorBlocked) list ::= "NIS" + if (q.getSource == Quality.Source.SUBSTITUTED) list ::= "replaced" + if (q.getTest) list ::= "test" + if (dq.getOverflow) list ::= "overflow" + if (dq.getOutOfRange) list ::= "out of range" + if (dq.getBadReference) list ::= "bad reference" + if (dq.getOscillatory) list ::= "oscillatory" + if (dq.getFailure) list ::= "failure" + if (dq.getOldData) list ::= "old" + if (dq.getInconsistent) list ::= "inconsistent" + if (dq.getInaccurate) list ::= "inaccurate" + + val overall = q.getValidity match { + case Quality.Validity.GOOD => "Good" + case Quality.Validity.INVALID => "Invalid" + case Quality.Validity.QUESTIONABLE => "Questionable" + } + + overall + " (" + list.reverse.mkString("; ") + ")" + } + + def timeString(m: Measurement): String = { + (if (m.hasIsDeviceTime && m.getIsDeviceTime) "~" else "") + new java.util.Date(m.getTime).toString + } + def systemTimeString(m: Measurement): String = { + if (m.hasSystemTime) new java.util.Date(m.getSystemTime).toString + else "--" + } + + def offset(m: Measurement): String = { + if (m.hasSystemTime) (m.getSystemTime - m.getTime).toString + else "--" + } +} \ No newline at end of file diff --git a/cli/src/main/scala/io/greenbus/cli/view/Table.scala b/cli/src/main/scala/io/greenbus/cli/view/Table.scala new file mode 100755 index 0000000..30654d4 --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/view/Table.scala @@ -0,0 +1,79 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.view + +import java.io.PrintStream + +object Table { + + val colSeparator = " | " + + def normalizeNumCols(rows: Seq[Seq[String]]) = { + val max = rows.map(_.length).max + rows.map(row => row.padTo(max, "")) + } + + def getWidths(rows: Seq[Seq[String]], minWidths: Seq[Int] = List.empty[Int]): Seq[Int] = { + rows.foldLeft(minWidths) { + case (widths, row) => + val w = if (widths.length < row.length) widths.padTo(row.length, 0) else widths + row.map(_.length).zip(w).map { case (a, b) => if (a > b) a else b } + } + } + + def justifyColumns(rows: Seq[Seq[String]], widths: Seq[Int]) = { + rows.map { row => + justifyColumnsInRow(row, widths) + } + } + + def justifyColumnsInRow(row: Seq[String], widths: Seq[Int]) = { + row.zip(widths).map { + case (str, width) => + str.padTo(width, " ").mkString + } + } + + def rowLength(line: Seq[String]) = { + line.foldLeft(0)(_ + _.length) + } + + def printTable(header: Seq[String], rows: Seq[Seq[String]], minWidths: Seq[Int] = Seq.empty[Int], stream: PrintStream = Console.out): Seq[Int] = { + val overallList = normalizeNumCols(Seq(header) ++ rows) + val widths = getWidths(overallList, minWidths) + val just = justifyColumns(overallList, widths) + val headStr = just.head.mkString(" ") + //stream.println("Found: " + rows.size) + stream.println(headStr) + stream.println("".padTo(headStr.length, "-").mkString) + just.tail.foreach(line => stream.println(line.mkString(colSeparator))) + widths + } + + def renderRows(rows: Seq[Seq[String]], sep: String = "", minWidths: Seq[Int] = List.empty[Int], stream: PrintStream = Console.out): Seq[Int] = { + val normalizedRows = normalizeNumCols(rows) + val widths = getWidths(normalizedRows, minWidths) + Table.justifyColumns(normalizedRows, widths).foreach(line => stream.println(line.mkString(sep))) + widths + } + + def renderTableRow(row: Seq[String], widths: Seq[Int], sep: String = colSeparator, stream: PrintStream = Console.out) = { + stream.println(Table.justifyColumnsInRow(row, widths).mkString(sep)) + } +} \ No newline at end of file diff --git a/cli/src/main/scala/io/greenbus/cli/view/TableView.scala b/cli/src/main/scala/io/greenbus/cli/view/TableView.scala new file mode 100644 index 0000000..e800f86 --- /dev/null +++ b/cli/src/main/scala/io/greenbus/cli/view/TableView.scala @@ -0,0 +1,32 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli.view + +trait TableView[A] { + def printTable(objs: Seq[A], minColWidths: Seq[Int] = List.empty[Int]): Seq[Int] = { + Table.printTable(header, objs map row) + } + + def printRows(objs: Seq[A], minColWidths: Seq[Int] = List.empty[Int]): Seq[Int] = { + Table.renderRows(objs map row, sep = Table.colSeparator, minWidths = minColWidths) + } + + def header: Seq[String] + def row(obj: A): Seq[String] +} diff --git a/cli/src/test/scala/io/greenbus/cli/Documenter.scala b/cli/src/test/scala/io/greenbus/cli/Documenter.scala new file mode 100644 index 0000000..82ebefd --- /dev/null +++ b/cli/src/test/scala/io/greenbus/cli/Documenter.scala @@ -0,0 +1,69 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli + +object Documenter { + + def main(args: Array[String]): Unit = { + val commands = CliMain.commandList.map(f => f()) + + val sorted = commands.sortBy(_.commandName) + + val namePrefix = "#### " + val usagePrefix = "\t" + + val descriptions = sorted.map { cmd => + val sb = new StringBuilder + + sb.append(namePrefix + cmd.commandName + "\n") + sb.append("\n") + sb.append(cmd.description + "\n") + sb.append("\n") + sb.append("Usage:\n") + sb.append("\n") + sb.append(usagePrefix + cmd.syntax + "\n") + sb.append("\n") + + val args = cmd.argumentDescriptions + + if (args.nonEmpty) { + sb.append("Arguments:\n") + sb.append("\n") + args.foreach { arg => + sb.append("`" + arg.name + "`" + " - " + arg.displayName + "\n\n") + } + } + + val options = cmd.optionDescriptions + + if (options.nonEmpty) { + sb.append("Options:\n") + sb.append("\n") + options.foreach { opt => + sb.append("\t" + Command.optionString(opt) + "\n\n") + sb.append(opt.desc + "\n\n") + } + } + + sb.result() + } + + println(descriptions.mkString("\n")) + } +} diff --git a/cli/src/test/scala/io/greenbus/cli/ParserTest.scala b/cli/src/test/scala/io/greenbus/cli/ParserTest.scala new file mode 100644 index 0000000..85ef7cb --- /dev/null +++ b/cli/src/test/scala/io/greenbus/cli/ParserTest.scala @@ -0,0 +1,66 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli + +import org.junit.runner.RunWith +import org.scalatest.FunSuite +import org.scalatest.junit.JUnitRunner +import org.scalatest.matchers.ShouldMatchers + +@RunWith(classOf[JUnitRunner]) +class ParserTest extends FunSuite with ShouldMatchers { + + class TestAll extends Command[Boolean] { + + val commandName = "Test01" + val description = "Test01" + + val name = strings.arg("entity name", "Entity name") + val uuid = strings.argOptional("entity uuid", "Entity uuid") + + val displayName = strings.option(Some("d"), Some("display"), "Display name") + val types = strings.optionRepeated(Some("t"), Some("type"), "Entity type") + + protected def execute(context: Boolean) { + } + } + + test("multi") { + val cmd = new TestAll + + cmd.run(SearchingTokenizer.tokenize("-t type01 --type type02 -d displayName01 name01 uuid01").toArray, false) + + cmd.name.value should equal("name01") + cmd.uuid.value should equal(Some("uuid01")) + cmd.displayName.value should equal(Some("displayName01")) + cmd.types.value should equal(Seq("type01", "type02")) + } + + test("after string encapsulated") { + val cmd = new TestAll + + cmd.run(SearchingTokenizer.tokenize("-t type01 --type \"type 02\" -d displayName01 name01 uuid01").toArray, false) + + cmd.name.value should equal("name01") + cmd.uuid.value should equal(Some("uuid01")) + cmd.displayName.value should equal(Some("displayName01")) + cmd.types.value should equal(Seq("type01", "type 02")) + } + +} diff --git a/cli/src/test/scala/io/greenbus/cli/TokenizerTest.scala b/cli/src/test/scala/io/greenbus/cli/TokenizerTest.scala new file mode 100644 index 0000000..fb15d6f --- /dev/null +++ b/cli/src/test/scala/io/greenbus/cli/TokenizerTest.scala @@ -0,0 +1,43 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.cli + +import org.junit.runner.RunWith +import org.scalatest.FunSuite +import org.scalatest.junit.JUnitRunner +import org.scalatest.matchers.ShouldMatchers + +@RunWith(classOf[JUnitRunner]) +class TokenizerTest extends FunSuite with ShouldMatchers { + + test("line tokenizing with quotes") { + val input = """ a thing is "something good" I think """ + val input2 = """the quote is " on the end of the line """" + + SearchingTokenizer.tokenize(input) should equal(List("a", "thing", "is", "something good", "I", "think")) + SearchingTokenizer.tokenize(input2) should equal(List("the", "quote", "is", " on the end of the line ")) + } + + test("line tokenizing with quotes realistic") { + val input = """ -o "a value" -b "another value" -d -v 5 """ + + SearchingTokenizer.tokenize(input) should equal(List("-o", "a value", "-b", "another value", "-d", "-v", "5")) + } + +} diff --git a/client/pom.xml b/client/pom.xml new file mode 100755 index 0000000..2e8423f --- /dev/null +++ b/client/pom.xml @@ -0,0 +1,214 @@ + + 4.0.0 + + + io.greenbus + greenbus-scala-base + 3.0.0 + ../scala-base + + + greenbus-client + jar + + + + Apache 2 + http://www.apache.org/licenses/LICENSE-2.0.txt + A business-friendly OSS license + + + + + + + + com.mycila.maven-license-plugin + maven-license-plugin + +
../APACHE_FILE_HEADER
+ + JAVADOC_STYLE + + + proto-include/google/** + +
+
+ + org.totalgrid.maven + maven-protoc-plugin + 0.1.11 + + src/main/proto + true + ${project.build.directory}/generated-sources/java + + + proto-include/ + + + + + msgscala + ${project.build.directory}/generated-sources/scala + protoc-gen-msgscala + + + msgjava + ${project.build.directory}/generated-sources/java + protoc-gen-msgjava + + + + + + + compile + testCompile + + + + + + + pl.project13.maven + git-commit-id-plugin + 1.9 + + + + initialize + + revision + + + + + ${project.basedir}/.git + + + + com.google.code.maven-replacer-plugin + maven-replacer-plugin + 1.3.9 + + + generate-sources + + replace + + + + + + target/generated-sources/scala/io/greenbus/client/version/Version.scala + + + src/main/resources/Version.scala.template + + + + PROJECT_VERSION + ${project.version} + + + GIT_COMMIT_ID + ${git.commit.id} + + + + + + maven-assembly-plugin + + + jar-with-dependencies + + + + + make-assembly + package + + single + + + + +
+
+ + + + + doc + + + + org.totalgrid.maven + maven-protoc-plugin + 0.1.11 + + + + msgscala + ${project.build.directory}/generated-sources/scala + protoc-gen-msgscala + + + msgjava + ${project.build.directory}/generated-sources/java + protoc-gen-msgjava + + + msgdoc + ${project.build.directory}/greenbus-msg-proto-docs + protoc-gen-msgdoc + + + + + + + + + + + + io.greenbus.msg + greenbus-msg-amqp + 1.0.0 + + + io.greenbus.msg + greenbus-msg-amqp-java + 1.0.0 + + + io.greenbus.msg + greenbus-msg-messaging + 1.0.0 + + + io.greenbus.msg + greenbus-msg-messaging-java + 1.0.0 + + + io.greenbus.msg + greenbus-msg-proto-ext + 1.0.0 + + + io.greenbus.msg + greenbus-msg-compiler + 1.0.0 + jar-with-dependencies + provided + + + + +
diff --git a/client/proto-include/CompilerExtensions.proto b/client/proto-include/CompilerExtensions.proto new file mode 100644 index 0000000..1bb3598 --- /dev/null +++ b/client/proto-include/CompilerExtensions.proto @@ -0,0 +1,43 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +option java_package = "io.greenbus.msg.compiler.proto"; +option java_outer_classname = "CompilerExtensions"; +option java_generic_services = false; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + optional bool field_optional = 50002; +} + +enum ServiceAddressing { + NEVER = 1; + OPTIONALLY = 2; + ALWAYS = 3; +} + +extend google.protobuf.MethodOptions { + optional ServiceAddressing addressed = 50006; + optional string subscription_type = 50007; +} + +extend google.protobuf.ServiceOptions { + optional string scala_package = 50011; + optional string java_package = 50012; +} diff --git a/client/proto-include/google/protobuf/descriptor.proto b/client/proto-include/google/protobuf/descriptor.proto new file mode 100644 index 0000000..cc04aa8 --- /dev/null +++ b/client/proto-include/google/protobuf/descriptor.proto @@ -0,0 +1,433 @@ +// Protocol Buffers - Google's data interchange format +// Copyright 2008 Google Inc. All rights reserved. +// http://code.google.com/p/protobuf/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +// Author: kenton@google.com (Kenton Varda) +// Based on original Protocol Buffers design by +// Sanjay Ghemawat, Jeff Dean, and others. +// +// The messages in this file describe the definitions found in .proto files. +// A valid .proto file can be translated directly to a FileDescriptorProto +// without any other information (e.g. without reading its imports). + + + +package google.protobuf; +option java_package = "com.google.protobuf"; +option java_outer_classname = "DescriptorProtos"; + +// descriptor.proto must be optimized for speed because reflection-based +// algorithms don't work during bootstrapping. +option optimize_for = SPEED; + +// The protocol compiler can output a FileDescriptorSet containing the .proto +// files it parses. +message FileDescriptorSet { + repeated FileDescriptorProto file = 1; +} + +// Describes a complete .proto file. +message FileDescriptorProto { + optional string name = 1; // file name, relative to root of source tree + optional string package = 2; // e.g. "foo", "foo.bar", etc. + + // Names of files imported by this file. + repeated string dependency = 3; + + // All top-level definitions in this file. + repeated DescriptorProto message_type = 4; + repeated EnumDescriptorProto enum_type = 5; + repeated ServiceDescriptorProto service = 6; + repeated FieldDescriptorProto extension = 7; + + optional FileOptions options = 8; +} + +// Describes a message type. +message DescriptorProto { + optional string name = 1; + + repeated FieldDescriptorProto field = 2; + repeated FieldDescriptorProto extension = 6; + + repeated DescriptorProto nested_type = 3; + repeated EnumDescriptorProto enum_type = 4; + + message ExtensionRange { + optional int32 start = 1; + optional int32 end = 2; + } + repeated ExtensionRange extension_range = 5; + + optional MessageOptions options = 7; +} + +// Describes a field within a message. +message FieldDescriptorProto { + enum Type { + // 0 is reserved for errors. + // Order is weird for historical reasons. + TYPE_DOUBLE = 1; + TYPE_FLOAT = 2; + TYPE_INT64 = 3; // Not ZigZag encoded. Negative numbers + // take 10 bytes. Use TYPE_SINT64 if negative + // values are likely. + TYPE_UINT64 = 4; + TYPE_INT32 = 5; // Not ZigZag encoded. Negative numbers + // take 10 bytes. Use TYPE_SINT32 if negative + // values are likely. + TYPE_FIXED64 = 6; + TYPE_FIXED32 = 7; + TYPE_BOOL = 8; + TYPE_STRING = 9; + TYPE_GROUP = 10; // Tag-delimited aggregate. + TYPE_MESSAGE = 11; // Length-delimited aggregate. + + // New in version 2. + TYPE_BYTES = 12; + TYPE_UINT32 = 13; + TYPE_ENUM = 14; + TYPE_SFIXED32 = 15; + TYPE_SFIXED64 = 16; + TYPE_SINT32 = 17; // Uses ZigZag encoding. + TYPE_SINT64 = 18; // Uses ZigZag encoding. + }; + + enum Label { + // 0 is reserved for errors + LABEL_OPTIONAL = 1; + LABEL_REQUIRED = 2; + LABEL_REPEATED = 3; + // TODO(sanjay): Should we add LABEL_MAP? + }; + + optional string name = 1; + optional int32 number = 3; + optional Label label = 4; + + // If type_name is set, this need not be set. If both this and type_name + // are set, this must be either TYPE_ENUM or TYPE_MESSAGE. + optional Type type = 5; + + // For message and enum types, this is the name of the type. If the name + // starts with a '.', it is fully-qualified. Otherwise, C++-like scoping + // rules are used to find the type (i.e. first the nested types within this + // message are searched, then within the parent, on up to the root + // namespace). + optional string type_name = 6; + + // For extensions, this is the name of the type being extended. It is + // resolved in the same manner as type_name. + optional string extendee = 2; + + // For numeric types, contains the original text representation of the value. + // For booleans, "true" or "false". + // For strings, contains the default text contents (not escaped in any way). + // For bytes, contains the C escaped value. All bytes >= 128 are escaped. + // TODO(kenton): Base-64 encode? + optional string default_value = 7; + + optional FieldOptions options = 8; +} + +// Describes an enum type. +message EnumDescriptorProto { + optional string name = 1; + + repeated EnumValueDescriptorProto value = 2; + + optional EnumOptions options = 3; +} + +// Describes a value within an enum. +message EnumValueDescriptorProto { + optional string name = 1; + optional int32 number = 2; + + optional EnumValueOptions options = 3; +} + +// Describes a service. +message ServiceDescriptorProto { + optional string name = 1; + repeated MethodDescriptorProto method = 2; + + optional ServiceOptions options = 3; +} + +// Describes a method of a service. +message MethodDescriptorProto { + optional string name = 1; + + // Input and output type names. These are resolved in the same way as + // FieldDescriptorProto.type_name, but must refer to a message type. + optional string input_type = 2; + optional string output_type = 3; + + optional MethodOptions options = 4; +} + +// =================================================================== +// Options + +// Each of the definitions above may have "options" attached. These are +// just annotations which may cause code to be generated slightly differently +// or may contain hints for code that manipulates protocol messages. +// +// Clients may define custom options as extensions of the *Options messages. +// These extensions may not yet be known at parsing time, so the parser cannot +// store the values in them. Instead it stores them in a field in the *Options +// message called uninterpreted_option. This field must have the same name +// across all *Options messages. We then use this field to populate the +// extensions when we build a descriptor, at which point all protos have been +// parsed and so all extensions are known. +// +// Extension numbers for custom options may be chosen as follows: +// * For options which will only be used within a single application or +// organization, or for experimental options, use field numbers 50000 +// through 99999. It is up to you to ensure that you do not use the +// same number for multiple options. +// * For options which will be published and used publicly by multiple +// independent entities, e-mail kenton@google.com to reserve extension +// numbers. Simply tell me how many you need and I'll send you back a +// set of numbers to use -- there's no need to explain how you intend to +// use them. If this turns out to be popular, a web service will be set up +// to automatically assign option numbers. + + +message FileOptions { + + // Sets the Java package where classes generated from this .proto will be + // placed. By default, the proto package is used, but this is often + // inappropriate because proto packages do not normally start with backwards + // domain names. + optional string java_package = 1; + + + // If set, all the classes from the .proto file are wrapped in a single + // outer class with the given name. This applies to both Proto1 + // (equivalent to the old "--one_java_file" option) and Proto2 (where + // a .proto always translates to a single class, but you may want to + // explicitly choose the class name). + optional string java_outer_classname = 8; + + // If set true, then the Java code generator will generate a separate .java + // file for each top-level message, enum, and service defined in the .proto + // file. Thus, these types will *not* be nested inside the outer class + // named by java_outer_classname. However, the outer class will still be + // generated to contain the file's getDescriptor() method as well as any + // top-level extensions defined in the file. + optional bool java_multiple_files = 10 [default=false]; + + // Generated classes can be optimized for speed or code size. + enum OptimizeMode { + SPEED = 1; // Generate complete code for parsing, serialization, + // etc. + CODE_SIZE = 2; // Use ReflectionOps to implement these methods. + LITE_RUNTIME = 3; // Generate code using MessageLite and the lite runtime. + } + optional OptimizeMode optimize_for = 9 [default=SPEED]; + + + + + // Should generic services be generated in each language? "Generic" services + // are not specific to any particular RPC system. They are generated by the + // main code generators in each language (without additional plugins). + // Generic services were the only kind of service generation supported by + // early versions of proto2. + // + // Generic services are now considered deprecated in favor of using plugins + // that generate code specific to your particular RPC system. If you are + // using such a plugin, set these to false. In the future, we may change + // the default to false, so if you explicitly want generic services, you + // should explicitly set these to true. + optional bool cc_generic_services = 16 [default=true]; + optional bool java_generic_services = 17 [default=true]; + optional bool py_generic_services = 18 [default=true]; + + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +message MessageOptions { + // Set true to use the old proto1 MessageSet wire format for extensions. + // This is provided for backwards-compatibility with the MessageSet wire + // format. You should not use this for any other reason: It's less + // efficient, has fewer features, and is more complicated. + // + // The message must be defined exactly as follows: + // message Foo { + // option message_set_wire_format = true; + // extensions 4 to max; + // } + // Note that the message cannot have any defined fields; MessageSets only + // have extensions. + // + // All extensions of your type must be singular messages; e.g. they cannot + // be int32s, enums, or repeated messages. + // + // Because this is an option, the above two restrictions are not enforced by + // the protocol compiler. + optional bool message_set_wire_format = 1 [default=false]; + + // Disables the generation of the standard "descriptor()" accessor, which can + // conflict with a field of the same name. This is meant to make migration + // from proto1 easier; new code should avoid fields named "descriptor". + optional bool no_standard_descriptor_accessor = 2 [default=false]; + + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +message FieldOptions { + // The ctype option instructs the C++ code generator to use a different + // representation of the field than it normally would. See the specific + // options below. This option is not yet implemented in the open source + // release -- sorry, we'll try to include it in a future version! + optional CType ctype = 1 [default = STRING]; + enum CType { + // Default mode. + STRING = 0; + + CORD = 1; + + STRING_PIECE = 2; + } + // The packed option can be enabled for repeated primitive fields to enable + // a more efficient representation on the wire. Rather than repeatedly + // writing the tag and type for each element, the entire array is encoded as + // a single length-delimited blob. + optional bool packed = 2; + + + // Is this field deprecated? + // Depending on the target platform, this can emit Deprecated annotations + // for accessors, or it will be completely ignored; in the very least, this + // is a formalization for deprecating fields. + optional bool deprecated = 3 [default=false]; + + // EXPERIMENTAL. DO NOT USE. + // For "map" fields, the name of the field in the enclosed type that + // is the key for this map. For example, suppose we have: + // message Item { + // required string name = 1; + // required string value = 2; + // } + // message Config { + // repeated Item items = 1 [experimental_map_key="name"]; + // } + // In this situation, the map key for Item will be set to "name". + // TODO: Fully-implement this, then remove the "experimental_" prefix. + optional string experimental_map_key = 9; + + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +message EnumOptions { + + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +message EnumValueOptions { + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +message ServiceOptions { + + // Note: Field numbers 1 through 32 are reserved for Google's internal RPC + // framework. We apologize for hoarding these numbers to ourselves, but + // we were already using them long before we decided to release Protocol + // Buffers. + + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +message MethodOptions { + + // Note: Field numbers 1 through 32 are reserved for Google's internal RPC + // framework. We apologize for hoarding these numbers to ourselves, but + // we were already using them long before we decided to release Protocol + // Buffers. + + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +// A message representing a option the parser does not recognize. This only +// appears in options protos created by the compiler::Parser class. +// DescriptorPool resolves these when building Descriptor objects. Therefore, +// options protos in descriptor objects (e.g. returned by Descriptor::options(), +// or produced by Descriptor::CopyTo()) will never have UninterpretedOptions +// in them. +message UninterpretedOption { + // The name of the uninterpreted option. Each string represents a segment in + // a dot-separated name. is_extension is true iff a segment represents an + // extension (denoted with parentheses in options specs in .proto files). + // E.g.,{ ["foo", false], ["bar.baz", true], ["qux", false] } represents + // "foo.(bar.baz).qux". + message NamePart { + required string name_part = 1; + required bool is_extension = 2; + } + repeated NamePart name = 2; + + // The value of the uninterpreted option, in whatever type the tokenizer + // identified it as during parsing. Exactly one of these should be set. + optional string identifier_value = 3; + optional uint64 positive_int_value = 4; + optional int64 negative_int_value = 5; + optional double double_value = 6; + optional bytes string_value = 7; +} diff --git a/client/src/main/java/io/greenbus/japi/client/ServiceConnection.java b/client/src/main/java/io/greenbus/japi/client/ServiceConnection.java new file mode 100644 index 0000000..82d2cef --- /dev/null +++ b/client/src/main/java/io/greenbus/japi/client/ServiceConnection.java @@ -0,0 +1,41 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.japi.client; + +import com.google.common.util.concurrent.ListenableFuture; +import io.greenbus.msg.japi.ConnectionCloseListener; +import io.greenbus.msg.japi.Session; +import io.greenbus.msg.japi.amqp.AmqpServiceOperations; + +public interface ServiceConnection +{ + + void addConnectionCloseListener( ConnectionCloseListener listener ); + + void removeConnectionCloseListener( ConnectionCloseListener listener ); + + void disconnect(); + + ListenableFuture login( String user, String password ); + + Session createSession(); + + AmqpServiceOperations getServiceOperations(); + +} diff --git a/client/src/main/java/io/greenbus/japi/client/ServiceConnectionFactory.java b/client/src/main/java/io/greenbus/japi/client/ServiceConnectionFactory.java new file mode 100644 index 0000000..e73725e --- /dev/null +++ b/client/src/main/java/io/greenbus/japi/client/ServiceConnectionFactory.java @@ -0,0 +1,128 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.japi.client; + +import com.google.common.util.concurrent.AsyncFunction; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import io.greenbus.msg.amqp.AmqpBroker; +import io.greenbus.msg.amqp.japi.AmqpConnection; +import io.greenbus.msg.amqp.japi.AmqpConnectionFactory; +import io.greenbus.msg.amqp.japi.AmqpSettings; +import io.greenbus.msg.japi.ConnectionCloseListener; +import io.greenbus.msg.japi.Session; +import io.greenbus.msg.japi.amqp.AmqpServiceOperations; +import io.greenbus.client.ServiceHeaders; +import io.greenbus.client.ServiceMessagingCodec; +import io.greenbus.client.exception.InternalServiceException; +import io.greenbus.client.service.proto.LoginRequests; +import io.greenbus.client.version.Version; +import io.greenbus.japi.client.service.LoginService; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +public class ServiceConnectionFactory +{ + + public static ServiceConnection create( AmqpSettings amqpSettings, AmqpBroker broker, long timeoutMs ) + { + + final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool( 5 ); + + final AmqpConnectionFactory amqpConnectionFactory = new AmqpConnectionFactory( amqpSettings, broker, timeoutMs, scheduledExecutorService ); + + final AmqpConnection amqpConnection = amqpConnectionFactory.connect(); + + return new DefaultConnection( amqpConnection, scheduledExecutorService ); + } + + + private static class DefaultConnection implements ServiceConnection + { + + private final AmqpConnection connection; + private final ScheduledExecutorService executorService; + + public DefaultConnection( AmqpConnection connection, ScheduledExecutorService executorService ) + { + this.connection = connection; + this.executorService = executorService; + } + + public void addConnectionCloseListener( ConnectionCloseListener listener ) + { + connection.addConnectionCloseListener( listener ); + } + + public void removeConnectionCloseListener( ConnectionCloseListener listener ) + { + connection.removeConnectionCloseListener( listener ); + } + + public void disconnect() + { + connection.disconnect(); + executorService.shutdown(); + } + + @Override + public ListenableFuture login( String user, String password ) + { + + final Session session = createSession(); + + final LoginService.Client client = LoginService.client( session ); + + final LoginRequests.LoginRequest request = + LoginRequests.LoginRequest.newBuilder().setName( user ).setPassword( password ).setLoginLocation( "" ).setClientVersion( + Version.clientVersion() ).build(); + + final ListenableFuture loginFuture = client.login( request ); + + return Futures.transform( loginFuture, new AsyncFunction() { + @Override + public ListenableFuture apply( LoginRequests.LoginResponse input ) throws Exception + { + try + { + session.addHeader( ServiceHeaders.tokenHeader(), input.getToken() ); + return Futures.immediateFuture( session ); + } + catch ( Throwable ex ) + { + return Futures.immediateFailedFuture( new InternalServiceException( "Problem handling login response: " + ex.getMessage() ) ); + } + } + } ); + } + + public Session createSession() + { + return connection.createSession( ServiceMessagingCodec.codec() ); + } + + public AmqpServiceOperations getServiceOperations() + { + return connection.getServiceOperations(); + } + } + + +} diff --git a/client/src/main/proto/Auth.proto b/client/src/main/proto/Auth.proto new file mode 100755 index 0000000..c811d6c --- /dev/null +++ b/client/src/main/proto/Auth.proto @@ -0,0 +1,196 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package greenbus.client; + +option java_package = "io.greenbus.client.service.proto"; +option java_outer_classname = "Auth"; + + +import "Model.proto"; + +/* + Represents an actor in the system to be authenticated and authorized to perform + service requests. + + Agents have an associated password not featured in the resource view. +*/ +message Agent { + + /* + Unique identifier across all Agents. + */ + optional ModelUUID uuid = 1; + + /* + Unique name across all Agents. + */ + optional string name = 2; + + /* + Names of PermissionSets associated with this Agent. + */ + repeated string permission_sets = 4; +} + +/* + Narrows the system model to a more specific set of Entities relevant to an authorization Permission. +*/ +message EntitySelector { + + /* + Specifies the _style_, or type, of selector. Options are: + + - `*`: Selects all Entities. Equivalent to not having specified an EntitySelector. No arguments are required. + - `self`: Selects only the Agent associated with the auth token. An example would be to provide rename and password change + privileges for a user. No arguments are required. + - `type`: Selects Entities that have at least one of the specified types. Arguments are the relevant type list. + Must provide at least one argument. + - `parent`: Selects Entities by hierarchical relationship. Arguments are a list of Entity names that form the parent + set. All Entities that are children (immediate or derived) of one of the parents through an `owns` relationship will + be selected. Must provide at least one argument. + + */ + optional string style = 2; + + /* + Arguments for the selector whose meaning depend on the selector style. + + @optional + */ + repeated string arguments = 3; +} + +/* + Represents allowing or disallowing a set of actions across a set of resources, with + an optional filter for a set of Entities. + + When multiple resources and multiple actions are specified, the authorization is applied for all combinations + of resources and actions. + + Permissions for all resources default to denial. If allows and denies overlap, the resource/action + will be denied. +*/ +message Permission { + + /* + Whether the Permission allows (true) or disallows (false) the actions on the resource. + */ + optional bool allow = 2; + + /* + Resources (e.g. `entity`, `point`, `agent`) the permission applies to. + + Using `*` will specify all resources. + */ + repeated string resources = 3; + + /* + Actions (e.g. `create`, `read`, `update`, `delete`) the permission allows or disallows. + + Using `*` will specify all actions. + */ + repeated string actions = 4; + + /* + When provided, narrows the specified resources to apply only to a set of Entities in the system + model. + + @optional + */ + repeated EntitySelector selectors = 5; +} + +/* + Represents a set of Permissions authorizing an Agent to perform service requests. +*/ +message PermissionSet { + + /* + Unique identifier across all PermissionSets. + */ + optional ModelID id = 1; + + /* + Unique name across all PermissionSets. + */ + optional string name = 2; + + /* + Descriptions of the allowed and disallowed actions that can be taken on resources. + */ + repeated Permission permissions = 4; +} + + +/* + Represents an auth token entry created as a result of authentication on user login. +*/ +message AuthToken { + + /* + ID of this AuthToken. + */ + optional ModelID id = 1; + + /* + UUID of the Agent whose credentials were used to create this token. + */ + optional ModelUUID agent_uuid = 2; + + /* + Contextual information about the user or process obtaining the auth token. + */ + optional string login_location = 3; + + /* + PermissionSets granted by this auth token. + */ + repeated PermissionSet permission_sets = 4; + + /* + Auth token resulting from a successful login. Can be included in subsequent service requests in order to + authorize them. + */ + optional string token = 5; + + /* + Expiration time for this auth token. After this time the auth token will be rejected and a new token + will need to be acquired. + + Time is measured in milliseconds since midnight, January 1, 1970 UTC. + */ + optional uint64 expiration_time = 6; + + /* + Provides contextual information about the client software version. + */ + optional string client_version = 7; + + /* + Whether the auth token has been revoked by a user logout or another process. + */ + optional bool revoked = 8; + + /* + Record of the time the auth token was issued. + + Time is measured in milliseconds since midnight, January 1, 1970 UTC. + */ + optional uint64 issue_time = 9; +} diff --git a/client/src/main/proto/Calculations.proto b/client/src/main/proto/Calculations.proto new file mode 100755 index 0000000..174ed53 --- /dev/null +++ b/client/src/main/proto/Calculations.proto @@ -0,0 +1,290 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package greenbus.client; + +option java_package = "io.greenbus.client.service.proto"; +option java_outer_classname = "Calculations"; + +import "ServiceEnvelopes.proto"; +import "Model.proto"; +import "Measurements.proto"; + + +/* + Describes how and why evaluation of the calculation is initiated. Calculations can be evaluated periodically or whenever notified + of a change to an input. + + One field must be provided. +*/ +message TriggerStrategy { + + /* + Specifies that the calculation is to be evaluated periodically. The value represents the period in milliseconds + between evaluations. + + @optional + */ + optional uint64 period_ms = 1; + + /* + If `true`, specifies that any change to an input should trigger the evaluation of the calculation. A value of + `false` has no meaning. + + @optional + */ + optional bool update_any = 3; +} + + +/* + Describes the range of time-series values used as inputs for the calculation. +*/ +message MeasurementRange { + + /* + If set to `true`, the input does not retrieve Measurement history and instead begins accumulating + time-series values from the time the calculation endpoint came online. + + @optional + */ + optional bool since_last = 4; + + /* + Specifies an offset in milliseconds to the beginning of a time window of Measurement history to use + for this input. An offset to past times should be negative. For example, a value of `-5000` will create + a range that includes all Measurements since five seconds before the current time. + + If the number of Measurements in that time window exceeds the `limit` field, the latest Measurements + will be used. + + @optional + */ + optional uint64 from_ms = 1; + + /* + Provides a limit on the size of the Measurement sequence retained for the calculation input. The + limit will also apply to the request for Measurement history when the calculation is initialized. + + If not specified, the measurement calculator will use a default value. + + @optional + */ + optional uint32 limit = 3; +} + +/* + Describes the strategy for calculation inputs that are a single value. +*/ +message SingleMeasurement { + + /* + Enumeration of strategies. + */ + enum MeasurementStrategy{ + MOST_RECENT = 1; // Use the most recent value. + } + + /* + Specifies the strategy for interpreting Measurements as calculation inputs. + + @required + */ + optional MeasurementStrategy strategy = 1; +} + +/* + Describes an input to a calculation. Contains a reference to another Point in the system, its mapping to a + variable in the calculation formula, and the range of Measurement values relevant to the calculation. + + One of `range` or `single` must be specified. +*/ +message CalculationInput { + + /* + UUID of the Point for the input. + + @required + */ + optional ModelUUID point_uuid = 1; + + /* + Variable name of this input, to be referenced in the formula. + + @required + */ + optional string variable_name = 2; + + /* + Specifies parameters for inputs that include multiple time-series Measurement values. + + @optional + */ + optional MeasurementRange range = 3; + + /* + Specifies parameters for inputs that are singular. + + @optional + */ + optional SingleMeasurement single = 4; +} + +/* + Describes how the quality of inputs should affect the calculation. +*/ +message InputQuality { + + /* + Enumerates the strategies for handling input quality. + */ + enum Strategy { + + /* + Measurements of any quality will be used as inputs. + */ + ACCEPT_ALL = 1; + + /* + Only evaluate the calculation when all input Measurements have `GOOD` quality. + */ + ONLY_WHEN_ALL_OK = 2; + + /* + Removes bad quality Measurements from the inputs and evaluates the calculation. For singular inputs, + this will result in an input not being present and the calculation not being evaluated. For ranged inputs, + only the poor quality Measurements in the range will be filtered out, and the calculation will proceed. + */ + REMOVE_BAD_AND_CALC = 3; + } + + /* + Specifies the strategy for handling input quality. + */ + optional Strategy strategy = 1; +} + +/* + Contains the strategy for determining the quality of the Measurement generated by the calculation. +*/ +message OutputQuality { + + /* + Enumeration for the strategies for determining the quality of the Measurement generated by the calculation. + */ + enum Strategy{ + WORST_QUALITY = 1; // Uses the "worst" quality of the input Measurements. + ALWAYS_OK = 2; // Calculated Measurements always have good quality. + } + + /* + Specifies the strategy for determining the quality of the Measurement generated by the calculation. + + @required + */ + optional Strategy strategy = 1; +} + +/* + Describes the inputs, the conditions for evaluation, the formula to be evaluated, and the output format + of a calculation. +*/ +message CalculationDescriptor { + + /* + Specifies a unit to be attached to the resulting Measurement. If not provided, no unit will be attached. + + @optional + */ + optional string unit = 2; + + /* + Determines how and why evaluation of the calculation is initiated. + + @required + */ + optional TriggerStrategy triggering = 4; + + /* + Specifies the inputs to the calculation, including the mapping to a variable in the formula and strategies for + acquiring and interpreting Measurement values. + + @required + */ + repeated CalculationInput calc_inputs = 5; + + /* + Specifies the strategy for handling the quality of input Measurements. + + @required + */ + optional InputQuality triggering_quality = 6; + + /* + Specifies the strategy for determining the quality of the Measurement generated by the calculation. + + @required + */ + optional OutputQuality quality_output = 7; + + /* + Specifies the formula to be evaluated. + + @required + */ + optional string formula = 9; + +} + +/* + Association between a Point and a description of a calculation that generates Measurements for it. +*/ +message Calculation { + + /* + UUID of the Point this Calculation is associated with. + + @required + */ + optional ModelUUID point_uuid = 1; + + /* + Description of the calculation to be performed. + + @required + */ + optional CalculationDescriptor calculation = 8; +} + + +/* + Notification of a change to the Calculation for a Point. +*/ +message CalculationNotification { + + /* + Whether object was added, removed, or modified. + */ + optional greenbus.client.envelope.SubscriptionEventType event_type = 1; + + /* + Updated state of the object. + */ + optional Calculation value = 2; +} + diff --git a/client/src/main/proto/Commands.proto b/client/src/main/proto/Commands.proto new file mode 100755 index 0000000..9bddcbc --- /dev/null +++ b/client/src/main/proto/Commands.proto @@ -0,0 +1,174 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package greenbus.client; + +option java_package = "io.greenbus.client.service.proto"; +option java_outer_classname = "Commands"; + +import "Model.proto"; + + +/* + Represents the access table for the Command system. CommandLock entries have one or two + modes, _allowed_ and _blocked_. CommandRequests cannot be issued unless they have an + _allowed_ entry. This selects the command for operation by a single user, for + as long as the lock is held. Blocking a Command makes it so no CommandRequests can be + issued and no other CommandLocks can be acquired. + + Multiple Commands can be referenced in the same CommandLock. The Agent + associated with the CommandLock is determined by the request header. +*/ +message CommandLock { + + /* + Enumerates the effect of a CommandLock. + */ + enum AccessMode { + ALLOWED = 1; // The Agent may issue a CommandRequest for the Commands. + BLOCKED = 2; // No other Agents may lock or issue the Commands. + } + + /* + ID for the CommandLock entry. + */ + optional ModelID id = 1; + + /* + Command UUIDs the CommandLock pertains to. + */ + repeated ModelUUID command_uuids = 2; + + /* + The type of AccessMode the CommandLock represents. + */ + optional AccessMode access = 3; + + /* + The time when the CommandLock will no longer be valid. + + CommandLocks with the AccessMode `BLOCKED` have no expiration time. + + Time is measured in milliseconds since midnight, January 1, 1970 UTC. + + @optional + */ + optional uint64 expire_time = 4; + + /* + The UUID of the Agent that created the CommandLock. + */ + optional ModelUUID agent_uuid = 5; +} + +/* + Represents a request for a front-end to perform a control operation. + + CommandRequests may optionally contain a value. +*/ +message CommandRequest { + + /* + Enumeration of the possible value types a CommandRequest could hold. + */ + enum ValType { + NONE = 0; + INT = 1; + DOUBLE = 2; + STRING = 4; + } + + /* + UUID of the Command this request is for. + + @required + */ + optional ModelUUID command_uuid = 1; + + /* + Specifies the type of value the CommandRequest contains. + + Defaults to `NONE`. + + @optional + */ + optional ValType type = 3; + + /* + Specifies an integer value. If present, `type` must be set to `INT`. + + @optional + */ + optional sint64 int_val = 4; + + /* + Specifies a double floating-point value. If present, `type` must be set to `DOUBLE`. + + @optional + */ + optional double double_val = 5; + + /* + Specifies a string value. If present, `type` must be set to `STRING`. + + @optional + */ + optional string string_val = 6; + + /* + Flag set to prevent events from being logged when command request is issued. Requires special authorization. + + @optional + */ + optional bool unevented = 16; +} + +/* + Represents the result of a front-end receiving a CommandRequest. +*/ +message CommandResult { + + /* + Used by the front-end to describe the result of the CommandRequest. + */ + optional CommandStatus status = 1; + + /* + If the CommandStatus was not `SUCCESS`, may optionally hold additional information. + + @optional + */ + optional string error_message = 2; +} + +/* + Enumeration of Command statuses/results. +*/ +enum CommandStatus { + SUCCESS = 1; // Command was a success. + TIMEOUT = 2; // A timeout occurred to a remote device. + NO_SELECT = 3; // The remote device reports there was no select before operate. (Separate from CommandLock.) + FORMAT_ERROR = 4; // The remote device does not recognize the control or data format. + NOT_SUPPORTED = 5; // The operation was not supported. + ALREADY_ACTIVE = 6; // An operation is already in progress. + HARDWARE_ERROR = 7; // The remote device reports a hardware error. + LOCAL = 8; // The remote device is only allowing local control. + TOO_MANY_OPS = 9; // The remote device rejects the operation because it has too many already in progress. + NOT_AUTHORIZED = 10; // The remote device rejects the authorization of the front-end. + UNDEFINED = 11; // The error is unrecognized. +} diff --git a/client/src/main/proto/Events.proto b/client/src/main/proto/Events.proto new file mode 100755 index 0000000..f00ecf0 --- /dev/null +++ b/client/src/main/proto/Events.proto @@ -0,0 +1,279 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package greenbus.client; + +option java_package = "io.greenbus.client.service.proto"; +option java_outer_classname = "Events"; + +import "Model.proto"; + +import "ServiceEnvelopes.proto"; + + +/* + Represents a key-value pair, where the key is a string and the value is one of + multiple data types. + + One of the data types must be specified. +*/ +message Attribute { + + /* + Specifies the name of the attribute. + + @required + */ + optional string name = 1; + + /* + May contain the string value of the Attribute. + + @optional + */ + optional string value_string = 10; + + /* + May contain the integer value of the Attribute. + + @optional + */ + optional sint64 value_sint64 = 11; + + /* + May contain the double floating-point value of the Attribute. + + @optional + */ + optional double value_double = 12; + + /* + May contain the boolean value of the Attribute. + + @optional + */ + optional bool value_bool = 13; +} + +/* + Represents a record of a notable occurrence in the system at a particular time. + + The message content and severity of an Event, as well as whether the Event also represents an Alarm, are determined + through an EventConfig corresponding to the `event_type` field. Users and applications raise events by specifying the + type of Event and the interpretation of that type is defined by the system configuration. + + Publishing an Event may also raise an Alarm; if so the Alarm points to the Event to provide contextual information. +*/ +message Event { + + /* + Unique ID for this Event. + */ + optional ModelID id = 1; + + /* + Event type, corresponding to a registered type in the event configuration. + */ + optional string event_type = 2; + + /* + Specifies whether this Event has a corresponding Alarm. + */ + optional bool alarm = 3 [default = false]; + + /* + Time the Event was entered into the system. + + Time is measured in milliseconds since midnight, January 1, 1970 UTC. + */ + optional uint64 time = 4; + + /* + Time of occurrence for the underlying condition that caused the Event to be published. + + Time is measured in milliseconds since midnight, January 1, 1970 UTC. + + @optional + */ + optional uint64 device_time = 5; + + /* + Severity ranking for the Event. `1` is the most severe. Number of severity levels is configurable (default is 1-8). + */ + optional uint32 severity = 6; + + /* + Subsystem that authored this event. + */ + optional string subsystem = 7; + + /* + Name of the Agent whose credentials were used to publish this Event. + */ + optional string agent_name = 8; + + /* + UUID of the object in the system model associated with this Event. + + @optional + */ + optional ModelUUID entity_uuid = 9; + + /* + Name of the object in the system model associated with this Event. + + @optional + */ + optional string entity_name = 10; + + /* + ID of the segment of the model this Event is associated with. + + @optional + */ + optional string model_group = 12; + + /* + The rendered message for the Event. + */ + optional string rendered = 11; +} + +/* + Represents a condition that may require operator attention. Alarms are created from particular types of + Events, and contain the additional state and workflow for operator management. + + The Event that caused the Alarm to be raised is referenced to provide contextual information. +*/ +message Alarm { + + /* + Enumerates the possible states of an Alarm. + */ + enum State { + UNACK_AUDIBLE = 1; // Audible alarm not acknowledged by an operator. + UNACK_SILENT = 2; // Silent alarm not acknowledged by an operator. + ACKNOWLEDGED = 3; // Alarm acknowledged by operator. + REMOVED = 4; // Alarm removed from the active set. + } + + /* + Unique ID for the Alarm. This is a different ID than the Event ID. + */ + optional ModelID id = 1; + + /* + The current state of the Alarm. State may be modified by service requests on behalf of user actions. + */ + optional State state = 2; + + /* + The Event associated with this Alarm, provided for context. + */ + optional Event event = 3; +} + + +/* + Represents instructions for generate Events corresponding to a particular type. +*/ +message EventConfig { + + /* + Enumerates what form the prospective Event will take. + */ + enum Designation { + ALARM = 1; // Should publish an Event and a corresponding Alarm. + EVENT = 2; // Should publish an Event. + LOG = 3; // Should not publish an Event and instead write an entry to the system log. + } + + /* + Event type this EventConfig configures. + */ + optional string event_type = 1; + + /* + Severity ranking for the prospective Event. `1` is the most severe. Number of severity levels is + configurable (default is 1-8). + */ + optional uint32 severity = 2; + + /* + Determines whether Events published with this type will be just Events, Events and Alarms, or will be demoted + to log entries. + */ + optional Designation designation = 3; + + /* + If `designation` is `ALARM`, specifies the initial state of the raised Alarm. + + Irrelevant if an Alarm is not raised. + + @optional + */ + optional Alarm.State alarm_state = 4; + + /* + Specifies the template for Event messages. Parameters are specified by enclosing the name of the key in the Attribute + list in braces. + + Example: + +

+        Resource: "The {subject} jumped over the {object}."
+        Attributes: ["subject -> "cow", "object" -> "moon"]
+        
+ + If an Attribute is not present, the key name itself will appear in the message. + */ + optional string resource = 5; + + /* + Specifies whether this Event type is used by core system services and cannot be deleted. + */ + optional bool built_in = 6; +} + + +/* + Notification of a change to an Event. +*/ +message EventNotification { + + /* + Updated state of the object. + */ + optional Event value = 2; +} + +/* + Notification of a change to an Alarm. +*/ +message AlarmNotification { + + /* + Whether object was added, removed, or modified. + */ + optional greenbus.client.envelope.SubscriptionEventType event_type = 1; + + /* + Updated state of the object. + */ + optional Alarm value = 2; +} \ No newline at end of file diff --git a/client/src/main/proto/FrontEnd.proto b/client/src/main/proto/FrontEnd.proto new file mode 100755 index 0000000..8db8028 --- /dev/null +++ b/client/src/main/proto/FrontEnd.proto @@ -0,0 +1,105 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package greenbus.client; + +option java_package = "io.greenbus.client.service.proto"; +option java_outer_classname = "FrontEnd"; + +import "ServiceEnvelopes.proto"; +import "Model.proto"; + + + +/* + Represents an instance of a front-end having registered to provide services for and Endpoint. +*/ +message FrontEndRegistration { + + /* + The UUID for the Endpoint the front-end is servicing. + */ + optional ModelUUID endpoint_uuid = 1; + + /* + The service address the front-end may use for publishing Measurements to a measurement processor. + */ + optional string input_address = 2; + + /* + The service address the front-end subscribes to for receiving CommandRequests. + + @optional + */ + optional string command_address = 3; +} + +/* + Represents the status of a front-end that is serving an Endpoint. Allows front-ends to report both their own + presence and the state of any remote connection. +*/ +message FrontEndConnectionStatus { + + /* + Enumerates the possible statuses of a front-end connection. + */ + enum Status { + COMMS_UP = 1; // Nominal status. + COMMS_DOWN = 2; // A communications failure. + UNKNOWN = 3; // Unknown status. + ERROR = 4; // An error status, likely requiring administrator intervention. + } + + /* + The UUID of the Endpoint served by this front-end. + */ + optional ModelUUID endpoint_uuid = 1; + + /* + The name of the Endpoint served by this front-end. + */ + optional string endpoint_name = 2; + + /* + The current reported status of the front-end connection. + */ + optional Status state = 3; + + /* + The timestamp of the most recent status update. + + Time is measured in milliseconds since midnight, January 1, 1970 UTC. + */ + optional uint64 update_time = 4; +} + +/* + Notification of a change to a FrontEndConnectionStatus. +*/ +message FrontEndConnectionStatusNotification { + + /* + Whether object was added, removed, or modified. + */ + optional greenbus.client.envelope.SubscriptionEventType event_type = 1; + + /* + Updated state of the object. + */ + optional FrontEndConnectionStatus value = 2; +} diff --git a/client/src/main/proto/Measurements.proto b/client/src/main/proto/Measurements.proto new file mode 100755 index 0000000..2fb60e8 --- /dev/null +++ b/client/src/main/proto/Measurements.proto @@ -0,0 +1,360 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package greenbus.client; + +option java_package = "io.greenbus.client.service.proto"; +option java_outer_classname = "Measurements"; + +import "Model.proto"; + + +/* + Detailed quality flags, based on the notion of quality in IEC 61850. +*/ +message DetailQual { + + /* + Representation overflow, value can't be trusted. + + @optional + */ + optional bool overflow = 1 [default = false]; + + /* + Occurs when value is outside a predefined range. + + @optional + */ + optional bool out_of_range = 2 [default = false]; + + /* + Value may not be correct due to a reference being out of calibration. + + @optional + */ + optional bool bad_reference = 3 [default = false]; + + /* + Value is rapidly changing and some values may have be suppressed. + + @optional + */ + optional bool oscillatory = 4 [default = false]; + + /* + A supervision function has detected an internal or external failure. + + @optional + */ + optional bool failure = 5 [default = false]; + + /* + An update was not made during a specific time internal. + + @optional + */ + optional bool old_data = 6 [default = false]; + + /* + An evaluation function has detected an inconsistency. + + @optional + */ + optional bool inconsistent = 7 [default = false]; + + /* + Value does not meet the stated accuracy of the source. + + @optional + */ + optional bool inaccurate = 8 [default = false]; +} + + +/* + Quality for a Measurement value. Based on IEC 61850. +*/ +message Quality { + + /* + Enumerates possible validities for a Measurement. + */ + enum Validity { + GOOD = 0; // No abnormal condition of the acquisition function or the information source is detected. + INVALID = 1; // Abnormal condition. + QUESTIONABLE = 2; // Supervision function detects abnormal behavior, however value could still be valid. Up to client how to interpret. + } + + /* + Enumeration to distinguish between values part of the normal system process and those substituted. + (e.g. not in service or a measurement override). + */ + enum Source { + PROCESS = 0; // Value is provided by an input function from the process I/O or calculated by an application. + SUBSTITUTED = 1; // Value is provided by input on an operator or by an automatic source. + } + + /* + Overall validity of the Measurement value. + + Default is `GOOD`. + + @optional + */ + optional Validity validity = 1 [default = GOOD]; + + /* + Distinguishes between values resulting from normal system processes and those substituted. + + Default is `PROCESS`. + + @optional. + */ + optional Source source = 2 [default = PROCESS]; + + /* + Provides extra information when `validity` is `INVALID` or `QUESTIONABLE`. + + @optional + */ + optional DetailQual detail_qual = 3; + + /* + Classifies a value as a test value, not to be used for operational purposes. + + Default is `false`. + + @optional + */ + optional bool test = 4 [default = false]; + + /* + Further update of the value has been blocked by an operator. If set, `DetailQual.old_data` should be set to true. + + Default is `false`. + + @optional + */ + optional bool operator_blocked = 5 [default = false]; +} + + +/* + Represents a value at a particular point in time, with annotations for quality and unit of measurement. +*/ +message Measurement { + + /* + Enumeration of possible data types stored in the Measurement. + */ + enum Type { + INT = 0; // Corresponds to `int_val` in Measurement. + DOUBLE = 1; // Corresponds to `double_val` in Measurement. + BOOL = 2; // Corresponds to `bool_val` in Measurement. + STRING = 3; // Corresponds to `string_val` in Measurement. + NONE = 4; // No data type. + } + + /* + Determines the data type of the Measurement. The values of the enumeration correspond to the + `int_val`, `double_val`, `bool_val` and `string_val` fields. The selected field type must be + present. + + More than one of these fields may be present (possibly due to a transformation); if so this type + field specifies to primary data type. + + @required + */ + required Type type = 2; + + /* + Integer (64-bit) data value. + + @optional + */ + optional sint64 int_val = 3; + + /* + Double (64-bit) floating-point data value. + + @optional + */ + optional double double_val = 4; + + /* + Boolean data value. + + @optional + */ + optional bool bool_val = 5; + + /* + String data value. + + @optional + */ + optional string string_val = 6; + + /* + Annotation of health/validity of this value. + + Not specifying a quality is generally equivalent to good quality. + + @optional + */ + optional Quality quality = 7; + + // measurement time of occurrence information, both times are unix style time stamps + // "best known time" of occurrence, this should be measured as close to the field as possible. If the protocol + // is able to collect or assign a "good field time" to the measurement then is_device_time should be set, this + // indicates that the time should be considered to be accurate (but possibly in a slightly different stream from + // measurements that are not device_time) + + /* + Time of occurrence for the measurement value. + + Time is measured in milliseconds since midnight, January 1, 1970 UTC. + + @required + */ + optional uint64 time = 9 [default = 0]; + + /* + True if `time` field is a device timestamp, false or unset if it represents wall time. + + @optional + */ + optional bool is_device_time = 10 [default = false]; + + /* + The time when the measurement was processed into the system. The difference this and the `time` field + may be an approximate measurement of delay in the measurement stream or a measure of clock differences between + the field devices and the system. + + Time is measured in milliseconds since midnight, January 1, 1970 UTC. + + @optional + */ + optional uint64 system_time = 11 [default = 0]; +} + +/* + Associates a Measurement value with a Point identified by name. +*/ +message NamedMeasurementValue { + optional string point_name = 1; // Point name. + optional Measurement value = 2; // Measurement value. +} + +/* + Associates a sequence of Measurement values with a Point identified by name. +*/ +message NamedMeasurementValues { + optional string point_name = 1; // Point name. + repeated Measurement value = 2; // Sequence of Measurement values. +} + +/* + Associates a Measurement value with a Point identified by UUID. +*/ +message PointMeasurementValue { + optional ModelUUID point_uuid = 1; // Point UUID. + optional Measurement value = 2; // Measurement value. +} + +/* + Associates a sequence of Measurement values with a Point identified by UUID. +*/ +message PointMeasurementValues { + optional ModelUUID point_uuid = 1; // Point UUID. + repeated Measurement value = 2; // Sequence of Measurement values. +} + +/* + Batch of Measurements to be published simultaneously. Measurements in the batch are associated with + Points identified by UUID or name. + + At least one Measurement must be included. +*/ +message MeasurementBatch { + + /* + Measurements associated with Points by UUID. + + @optional + */ + repeated PointMeasurementValue point_measurements = 1; + + /* + Measurements associated with Points by name. + + @optional + */ + repeated NamedMeasurementValue named_measurements = 2; + + /* + A collective timestamp for Measurements that are bundled in the batch without individual timestamps. For example, this may be + the time that the values were collected from the field. + + If no wall time is specified, the measurement processor will use the time at which it received the batch. + + Time is measured in milliseconds since midnight, January 1, 1970 UTC. + + @optional + */ + optional uint64 wall_time = 3; +} + +/* + Notification of a new Measurement value for a particular Point. The UUID and name of the Point are provided. +*/ +message MeasurementNotification { + + /* + Point UUID associated with this Measurement. + */ + optional ModelUUID point_uuid = 1; + + /* + Point name associated with this Measurement. + */ + optional string point_name = 2; + + /* + Measurement value. + */ + optional Measurement value = 3; +} + +/* + Notification of batch of Measurements published for a particular Endpoint. The UUID and name of the Point for each Measurement are provided. +*/ +message MeasurementBatchNotification { + + /* + Endpoint UUID associated with this batch of Measurements. + */ + optional ModelUUID endpoint_uuid = 1; + + /* + Set of Measurements published in this batch. + */ + repeated MeasurementNotification values = 3; +} + diff --git a/client/src/main/proto/Model.proto b/client/src/main/proto/Model.proto new file mode 100755 index 0000000..e205f23 --- /dev/null +++ b/client/src/main/proto/Model.proto @@ -0,0 +1,436 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package greenbus.client; + +option java_package = "io.greenbus.client.service.proto"; +option java_outer_classname = "Model"; + +import "ServiceEnvelopes.proto"; + +/* + Represents the universally unique identifier for a modeled service object. UUIDs + are intended to be unique across all model objects in the system regardless of type + (Point, Endpoint, etc.) and barring the importing of pre-existing UUID values, will + be unique across multiple systems as well. + + @simple +*/ +message ModelUUID { + required string value = 1; // Opaque value, should not be interpreted by the client. +} + +/* + Used to identify individual records for service objects not part of the model (i.e. + Events). Not unique across different types of service objects. + + @simple +*/ +message ModelID { + required string value = 1; // Opaque value, should not be interpreted by the client. +} + +/* + Represents a component of the system model. + + Entities fall into two categories: those that are user-defined, and those with more + specific built-in representations (i.e. Points, Commands, Endpoints). Built-in Entities + are viewable using Entity queries but must be modified through their specific service interfaces. + User-defined Entities can be used to provide structure to the otherwise flat list of inputs + and outputs (Points and Commands) in the telemetry model. + + The meaning of an Entity can be defined by its name, a list of types that + categorize it, or its relationships to other Entities and modeled objects in + the system model graph. +*/ +message Entity { + + /* + Unique identifier across all Entities. + */ + optional ModelUUID uuid = 1; + + /* + Unique name across all Entities. + */ + optional string name = 2; + + /* + Set of types to provide semantic meaning for role of Entity. + */ + repeated string types = 3; +} + +/* + Represents a directed edge between Entities. Together, Entities and edges form a graph + data structure with the following constraints: + + - There are no cycles (following parent to child edges will never arrive back at a previous parent). + - There is only one path between any two nodes (there are no "diamonds"). + + Note that unlike simple trees, Entities may have multiple parents, as long as those parents do not + have a common ancestor. + + Edges are additionally partitioned by relationship type. Edges of different relationships have no + effect on each other and thus are entirely separate graphs/trees, but exist potentially on the same + set of nodes (Entities). The name of the relationship is used to provide semantic meaning for + these structures. + + To enable querying for children of children without multiple queries, derived edges (`distance > 1`) + are stored with immediate edges (`distance = 1`). Service requests only interact (put, delete) + immediate edges; derived edges are maintained by the system. +*/ +message EntityEdge { + + /* + ID for this edge, for quick identification. + */ + optional ModelID id = 1; + + /* + Parent Entity UUID. + */ + optional ModelUUID parent = 2; + + /* + Child Entity UUID. + */ + optional ModelUUID child = 3; + + /* + Specific relationship structure this edge is a part of. + */ + optional string relationship = 4; + + /* + Entities this edge spans; immediate edges have `distance = 1`. + */ + optional uint32 distance = 5; +} + +/* + Enumerates how the value of Measurements associated with a Point should be interpreted. +*/ +enum PointCategory { + ANALOG = 1; // Point is a scalar value. + COUNTER = 2; // Point is an integer value representing an accumulation. + STATUS = 3; // Point is one of an enumeration of defined value states (e.g. a boolean value). +} + +/* + Enumerates whether CommandRequests associated with a Command contain a value, and what data type the value is. + + Commands without values are referred to as _controls_ and Commands with values are referred to as _setpoints_. +*/ +enum CommandCategory { + CONTROL = 1; // CommandRequests contain no values. + SETPOINT_INT = 2; // CommandRequests contain an integer value. + SETPOINT_DOUBLE = 3; // CommandRequests contain a double floating-point value. + SETPOINT_STRING = 4; // CommandRequests contain a string value. +} + +/* + Represents a data input for the system. +*/ +message Point { + + /* + Unique identifier across all Entities. + */ + optional ModelUUID uuid = 1; + + /* + Unique name across all Entities. + */ + optional string name = 2; + + /* + Set of types to provide semantic meaning for role of a Point. + */ + repeated string types = 3; + + /* + A high-level category for the data of Measurements associated with this Point + */ + optional PointCategory point_category = 4; + + /* + The unit of Measurements associated with this Point. + */ + optional string unit = 5; + + /* + Endpoint this Point is associated with. + + @optional + */ + optional ModelUUID endpoint_uuid = 10; +} + +/* + Represents a registered control or output for the system. +*/ +message Command { + + /* + Unique identifier across all Entities. + */ + optional ModelUUID uuid = 1; + + /* + Unique name across all Entities. + */ + optional string name = 2; + + /* + Set of types to provide semantic meaning for role of a Command. + */ + repeated string types = 3; + + /* + An explicative name that does not need to be globally unique. + */ + optional string display_name = 5; + + /* + Specifies whether CommandRequests associated with a Command contain a value, and what data type the value is. + */ + optional CommandCategory command_category = 7; + + /* + Endpoint this Command is associated with. + + @optional + */ + optional ModelUUID endpoint_uuid = 10; +} + +/* + Represents a source of Commands and Points. Endpoints divide the input and output of the system into + groups by front-end type (`protocol`) and ultimate source. +*/ +message Endpoint { + + /* + Unique identifier across all Entities. + */ + optional ModelUUID uuid = 1; + + /* + Unique name across all Entities. + */ + optional string name = 2; + + /* + Set of types to provide semantic meaning for role of an Endpoint. + */ + repeated string types = 3; + + /* + Identifies the type of front-end (the _protocol_) that is intended to service the Points and Commands + associated with this Endpoint. + */ + optional string protocol = 5; + + /* + Specifies whether an Endpoint has been disabled, so that Measurements will not be published and Commands + will not be processed for it. + */ + optional bool disabled = 6; +} + +/* + Holds a value of some specified type. Only one of the value types should be present. +*/ +message StoredValue { + + /* + Boolean value. + + @optional + */ + optional bool bool_value = 5; + + /* + 32-bit signed integer value. + + @optional + */ + optional int32 int32_value = 6; + + /* + 64-bit signed integer value. + + @optional + */ + optional int64 int64_value = 7; + + /* + 32-bit unsigned integer value. + + @optional + */ + optional uint32 uint32_value = 8; + + /* + 64-bit unsigned integer value. + + @optional + */ + optional uint64 uint64_value = 9; + + /* + 64-bit floating point value. + + @optional + */ + optional double double_value = 10; + + /* + String value. + + @optional + */ + optional string string_value = 11; + + /* + Byte array value. + + @optional + */ + optional bytes byte_array_value = 12; +} + +/* + Represents a key-value pair associated with a model object's UUID. Each combination of UUIDs + and keys are unique. +*/ +message EntityKeyValue { + + /* + UUID of the model object the key-value is associated with. + */ + optional ModelUUID uuid = 1; + + /* + Key string for the key-value. + */ + optional string key = 2; + + /* + Holds value for the key-value, may be one of several data types. + */ + optional StoredValue value = 3; +} + +/* + Notification of a change to an EntityKeyValue. +*/ +message EntityKeyValueNotification { + + /* + Whether object was added, removed, or modified. + */ + optional greenbus.client.envelope.SubscriptionEventType event_type = 1; + + /* + Updated state of the object. + */ + optional EntityKeyValue value= 3; +} + + +/* + Notification of a change to an Entity. +*/ +message EntityNotification { + + /* + Whether object was added, removed, or modified. + */ + optional greenbus.client.envelope.SubscriptionEventType event_type = 1; + + /* + Updated state of the object. + */ + optional Entity value = 2; +} + +/* + Notification of a change to an EntityEdge. +*/ +message EntityEdgeNotification { + + /* + Whether object was added, removed, or modified. + */ + optional greenbus.client.envelope.SubscriptionEventType event_type = 1; + + /* + Updated state of the object. + */ + optional EntityEdge value = 2; +} + +/* + Notification of a change to an Endpoint. +*/ +message EndpointNotification { + + /* + Whether object was added, removed, or modified. + */ + optional greenbus.client.envelope.SubscriptionEventType event_type = 1; + + /* + Updated state of the object. + */ + optional Endpoint value = 2; +} + +/* + Notification of a change to a Point. +*/ +message PointNotification { + + /* + Whether object was added, removed, or modified. + */ + optional greenbus.client.envelope.SubscriptionEventType event_type = 1; + + /* + Updated state of the object. + */ + optional Point value = 2; +} + +/* + Notification of a change to a Command. +*/ +message CommandNotification { + + /* + Whether object was added, removed, or modified. + */ + optional greenbus.client.envelope.SubscriptionEventType event_type = 1; + + /* + Updated state of the object. + */ + optional Command value = 2; +} \ No newline at end of file diff --git a/client/src/main/proto/Processing.proto b/client/src/main/proto/Processing.proto new file mode 100755 index 0000000..5ab476b --- /dev/null +++ b/client/src/main/proto/Processing.proto @@ -0,0 +1,461 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package greenbus.client; + +option java_package = "io.greenbus.client.service.proto"; +option java_outer_classname = "Processing"; + +import "ServiceEnvelopes.proto"; +import "Model.proto"; +import "Measurements.proto"; + + +/* + Provide the ability to block new Measurements from being published, and to optionally + replace the latest value provided by a front end with a user-specified override value. Blocked (not in service) + Measurements will not be stored in the current value and historian databases, and will not be published to the + Measurement notification stream. +*/ +message MeasOverride { + + /* + UUID for the Point to be overridden. + + @required + */ + optional ModelUUID point_uuid = 1; + + /* + If provided, will appear as the latest Measurement value for the point. + + @optional + */ + optional Measurement measurement = 2; + + /* + If a Measurement is present, specifies that the replacement value should be + processed as if it were a communications value. + + Does nothing if a Measurement value is not provided. + + @optional + */ + optional bool test_value = 3; +} + +/* + Describes both the current and previous value of a condition necessary to take some action. +*/ +enum ActivationType { + HIGH = 1; // Activated whenever the condition `true`. + LOW = 2; // Activated whenever the condition is `false`. + RISING = 3; // Activated whenever the condition is `true` _and_ the previous state of the condition was `false`. + FALLING = 4; // Activated whenever the condition is `false` _and_ the previous state of the condition was `true`. + TRANSITION = 5; // Activated whenever the latest condition value does not match the previous state of the condition. +} + +/* + Describes a transformation of the Measurement, or an Event to be generated. + + An Action is only taken when the current value of the Trigger condition and the previous value match the ActivationType. + + One of the action types must be specified. +*/ +message Action { + + /* + Name of the action, for logging purposes. + + @required + */ + optional string action_name = 1; + + /* + Describes under what conditions (current and previous) the action is to be taken. + + @required + */ + optional ActivationType type = 2; + + /* + Specifies whether the Action is temporarily disabled. + + @optional + */ + optional bool disabled = 3; + + /* + Type of action that prevents the Measurement from being stored or published. + + @optional + */ + optional bool suppress = 5; + + /* + Type of action that performs a linear transformation on the analog value of the Measurement. + + @optional + */ + optional LinearTransform linear_transform = 10; + + /* + Type of action that gives the Measurement the specified Quality. + + @optional + */ + optional Quality quality_annotation = 11; + + /* + Type of action that strips the value of the Measurement before it is stored/published. + + @optional + */ + optional bool strip_value = 12; + + /* + Type of action that sets the Measurement value to the specified boolean value. + + @optional + */ + optional bool set_bool = 13; + + /* + Type of action that publishes an Event related to the Measurement's Point. + + @optional + */ + optional EventGeneration event = 15; + + /* + Type of action that transforms a boolean Measurement value to a string value. + + @optional + */ + optional BoolEnumTransform bool_transform = 16; + + /* + Type of action that transforms an integer Measurement value to a string value. + + @optional + */ + optional IntEnumTransform int_transform = 17; +} + +/* + Describes a linear transformation on an analog value. + + The transformation, where `x` is the original value: + + `trans(x) = scale * x + offset` +*/ +message LinearTransform { + + /* + Scaling factor of the input. + + @required + */ + optional double scale = 2; + + /* + Addition operation performed after the scaling. + + @required + */ + optional double offset = 3; + + /* + Specifies whether integer analog values should be converted to floating point values in the resulting Measurement. + + Defaults to `false`. + + @optional + */ + optional bool forceToDouble = 4 [default = false]; +} + +/* + Describes an Event to generate from a Measurement. +*/ +message EventGeneration { + + /* + Specifies the Event type. Corresponds to the types configured in the Event/Alarm subsystem. + + @required + */ + optional string event_type = 1; +} + +/* + Describes the transformation of boolean values to string values by specifying strings for both `true` and `false`. +*/ +message BoolEnumTransform { + + /* + String value to attach to the Measurement when its boolean value is `true`. + + @required + */ + optional string true_string = 1; + + /* + String value to attach to the Measurement when its boolean value is `false`. + + @required + */ + optional string false_string = 2; +} + +/* + Describes the transformation of integer values to string values by specifying a set of mappings between integers and strings. + + If a mapping does not exist for the Measurement's integer value, the Measurement will not be changed. +*/ +message IntEnumTransform { + + /* + Associations between integers and strings. + + @required + */ + repeated IntToStringMapping mappings = 1; + + /* + Value to use if no mappings match. + + @optional + */ + optional string default_value = 2; +} + +message IntToStringMapping { + + /* + Integer input value. + + @required + */ + optional sint64 value = 1; + + /* + Resulting string value. + + @required + */ + optional string string = 2; +} + +/* + Description of a condition and a set of Actions. + + For every new Measurement value, the current boolean value of the condition is evaluated. The current value + and the previous value are compared to the ActivationType of each Action to determine if the Action should + be taken. + + When multiple condition types are specified, the boolean value is the logical _and_ of all conditions. +*/ +message Trigger { + + /* + Name that identifies the Trigger uniquely in the TriggerSet + + @required + */ + optional string trigger_name = 2; + + /* + If the activation type is matched no further Triggers in the TriggerSet will be evaluated. + + @optional + */ + optional ActivationType stop_processing_when = 3; + + /* + Sequence of Actions to be evaluated. + + @required + */ + repeated Action actions = 9; + + /* + Condition describing boundaries for analog values in Measurements. + + @optional + */ + optional AnalogLimit analog_limit = 10; + + /* + Condition matching Quality annotations of Measurements. Will be triggered if any fields set in the Trigger + Quality match the values of the same field set in the Measurement Quality. + + @optional + */ + optional Quality quality = 12; + + /* + Condition triggered when the Measurement value type enumeration matches this value. + + @optional + */ + optional Measurement.Type value_type = 14; + + /* + Condition triggered when the Measurement has a boolean value that matches this value. + + @optional + */ + optional bool bool_value = 15; + + /* + Condition triggered when the Measurement has a string value that matches this value. + + @optional + */ + optional string string_value = 16; + + /* + Condition triggered when the Measurement has an integer value that matches this value. + + @optional + */ + optional sint64 int_value = 17; + + /* + Condition triggered when the Measurement value and quality are not the same as the + previously published/stored Measurement. + + @optional + */ + optional Filter filter = 18; +} + +/* + Associates a set of Triggers with a Point. The sequence of Triggers describe the conditional evaluation of + transformations of Measurement value and quality, as well as the generation of Events. + + The sequence of Triggers are evaluated in order. Triggers may have as one of their side effects that no further + Triggers will be processed, at which point the rest of the sequence is ignored. +*/ +message TriggerSet { + + /* + Sequence of Triggers to evaluate for Measurements associated with the Point. + + @required + */ + repeated Trigger triggers = 2; +} + +/* + Describes boundary conditions for analog values. Can include both upper and lower limits, or just one + of either. + + When the condition had been previously triggered (the value was outside of the nominal range), the optional deadband + field describes how far the value must return within the nominal range to no longer be triggering the condition. This + can be used to prevent a value oscillating near the limit from repeatedly triggering the condition. +*/ +message AnalogLimit { + + /* + Upper limit; the nominal range is defined to be below this value. + + @optional + */ + optional double upper_limit = 1; + + /* + Lower limit; the nominal range is defined to be above this value. + + @optional + */ + optional double lower_limit = 2; + + /* + Once the condition was triggered, distance the value must return within the nominal range for the condition to + no longer be triggered. + + @optional + */ + optional double deadband = 3; +} + +/* + Describes a condition that triggers when a new Measurement has the same value and quality as the most recent + Measurement that did _not_ trigger the condition. + + Optionally, a deadband can be provided to suppress minor changes in analog values. +*/ +message Filter { + + /* + Enumeration for whether the condition triggers for exact duplicates or contains a deadband. + */ + enum FilterType { + DUPLICATES_ONLY = 1; // Recognize exact duplicates. + DEADBAND = 2; // Recognize values that have changed within a deadband. + } + + /* + Specifies whether the condition triggers for exact duplicates or contains a deadband. + + @required + */ + optional FilterType type = 1; + + /* + If set, the condition will only be triggered if a Measurement has an analog value where the difference between it and + the previously stored value exceeds the deadband. + + @optional + */ + optional double deadband_value = 2; +} + +/* + Notification of a change to the MeasOverride of a Point. +*/ +message OverrideNotification { + + /* + Whether object was added, removed, or modified. + */ + optional greenbus.client.envelope.SubscriptionEventType event_type = 1; + + /* + Updated state of the object. + */ + optional MeasOverride value = 2; +} + +/* + Notification of a change to the TriggerSet of a Point. +*/ +message TriggerSetNotification { + + /* + Whether object was added, removed, or modified. + */ + optional greenbus.client.envelope.SubscriptionEventType event_type = 1; + + /* + Updated state of the object. + */ + optional TriggerSet value = 2; +} \ No newline at end of file diff --git a/client/src/main/proto/ServiceEnvelopes.proto b/client/src/main/proto/ServiceEnvelopes.proto new file mode 100755 index 0000000..a5a9ae0 --- /dev/null +++ b/client/src/main/proto/ServiceEnvelopes.proto @@ -0,0 +1,95 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package greenbus.client.envelope; + +option java_package = "io.greenbus.client.proto"; +option java_outer_classname = "Envelope"; + +/* + Common classifier for notifications of changes in service objects. +*/ +enum SubscriptionEventType{ + ADDED = 1; + MODIFIED = 2; + REMOVED = 3; +} + +/* + Represents the status of service responses. Modeled on HTTP status codes. + + - The 200 range are successful responses. + - The 400 range represent request rejections. + - The 500 range represent internal errors. + + For more information on the HTTP status codes: + + http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html +*/ +enum Status { + OK = 200; + CREATED = 201; + UPDATED = 202; + DELETED = 204; + NOT_MODIFIED = 205; + BAD_REQUEST = 400; + UNAUTHORIZED = 401; // No valid user authentication provided. + FORBIDDEN = 403; // A valid user has attempted to exceed their privileges. + LOCKED = 423; + INTERNAL_ERROR = 500; + LOCAL_ERROR = 501; + UNEXPECTED_RESPONSE = 502; + RESPONSE_TIMEOUT = 503; + BUS_UNAVAILABLE = 504; +} + +/* + Header structure for providing extra information in service requests. Uses a + key-value format. +*/ +message RequestHeader{ + optional string key = 1; + optional string value = 2; +} + +/* + Outermost envelope for service requests. Contains request headers and an opaque + payload. + + Relies on out-of-band information (the communication channel, a header) to + determine the format of the payload. +*/ +message ServiceRequest{ + repeated RequestHeader headers = 1; + optional bytes payload = 2; +} + +/* + Outermost envelope for service responses. Status may be a success or an error. + If the response is an error, the error_message field will contain additional + information. If the status is a success, the payload field contains the + response object(s). + + Relies on out-of-band information (the communication channel, a header) to + determine the format of the payload. +*/ +message ServiceResponse{ + optional Status status = 1; // Status code, check for 2-- or print error message. + optional string error_message = 2; // Error message if applicable. + optional bytes payload = 3; // Can be one or many depending on request. +} diff --git a/client/src/main/proto/services/AuthRequests.proto b/client/src/main/proto/services/AuthRequests.proto new file mode 100644 index 0000000..6c6ee36 --- /dev/null +++ b/client/src/main/proto/services/AuthRequests.proto @@ -0,0 +1,341 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package greenbus.client; + +option java_package = "io.greenbus.client.service.proto"; +option java_outer_classname = "AuthRequests"; +option java_generic_services = false; + +import "google/protobuf/descriptor.proto"; +import "CompilerExtensions.proto"; + +import "Auth.proto"; +import "Model.proto"; +import "services/ModelRequests.proto"; + +/* + Description used to create or modify an Agent. + +*/ +message AgentTemplate { + + /* + Identifies a pre-existing Agent, or provides a pre-determined UUID for a new Agent. + + @optional + */ + optional ModelUUID uuid = 1; + + /* + Identifies a pre-existing Agent, provides a name for a created Agent, or a new name for an existing Agent. + + - If a UUID is not provided and the Agent does not exist an Agent will be created with this name. + - If a UUID is not provided and the Agent exists, the Agent will be identified and the non-name fields will be be modified. + - If a UUID is provided and the Agent does not exist, an Agent will be created with this name. + - If a UUID is provided and the Agent already exists, the Agent will be renamed to this name. + + @required + */ + optional string name = 2; + + /* + Password for the Agent. + + - If creating a new Agent, a password is required. + - If updating an Agent, a password is not required. Hence updating other fields in the Agent is possible without knowing the original password or changing to a new one. + + @optional + */ + optional string password = 3; + + /* + Names of PermissionSets to be assigned to this Agent. The PermissionSets must already exist. + + After modifying an existing Agent, these will be the full set of PermissionSets for that Agent. Any pre-existing PermissionSet assignments not in this list will be removed. + + Any empty list (including if the field is uninitialized) will result in the Agent having no PermissionSets. + + @required + */ + repeated string permission_sets = 4; +} + +/* + Description of an update to an Agent's password. +*/ +message AgentPasswordUpdate { + + /* + Identifies the Agent. + + @required + */ + optional ModelUUID uuid = 1; + + /* + New password for the Agent. + + @required + */ + optional string password = 2; +} + +/* + Collection of keys that identify a set of Agents. +*/ +message AgentKeySet { + + /* + UUIDs of Agents. + + @optional + */ + repeated ModelUUID uuids = 1; + + /* + Names of Agents. + + @optional + */ + repeated string names = 2; +} + +/* + Query parameters to select a set of Agents. + + Agents can be matched by their types or their permission sets. Including no search parameters will return + all Agents in the system. + + Uses paging to handle large result sets. +*/ +message AgentQuery { + + /* + Parameters to match Agents based on the names of their permission sets. + + @optional + */ + repeated string permission_sets = 4; + + /* + Parameters to narrow the result sets for queries. + + @optional + @nested + */ + optional EntityPagingParams paging_params = 14; +} + +/* + Description used to create or modify an PermissionSet. + +*/ +message PermissionSetTemplate { + + /* + Identifies a pre-existing PermissionSet. + + @optional + */ + optional ModelID id = 1; + + /* + Name of a new PermissionSet or name to change a PermissionSet to if an ID is also provided. + + @required + */ + optional string name = 2; + + /* + When modifying the PermissionSet, this field will be the Permissions for this PermissionSet. + Any pre-existing Permissions not in this list will be removed, and new Permissions will be added. + + @required + */ + repeated Permission permissions = 4; +} + + +/* + Collection of keys that identify a set of PermissionSets. +*/ +message PermissionSetKeySet { + + /* + IDs of PermissionSets. + + @optional + */ + repeated ModelID ids = 1; + + /* + Names of PermissionSets. + + @optional + */ + repeated string names = 2; +} + +/* + Query parameters to select a set of PermissionSets. + + PermissionSets can be matched by their types. Including no search parameters will return + all PermissionSets in the system. + + Uses paging to handle large result sets. + +*/ +message PermissionSetQuery { + + /* + Last UUID (in the returned sorted order) of a previous result set. This result set will be the + page beginning from the next UUID. + + @optional + */ + optional ModelID last_id = 14; + + /* + The number of elements to return. Used to limit the size of result sets in large models. If not set, + a server-determined page size will be used. + + If the size of the result set equals the page size, there may be more data available. + + @optional + */ + optional uint32 page_size = 15; +} + + +message GetAgentsRequest { + optional AgentKeySet request = 1; +} +message GetAgentsResponse { + repeated Agent results = 1; +} +message AgentQueryRequest { + optional AgentQuery request = 1; +} +message AgentQueryResponse { + repeated Agent results = 1; +} +message PutAgentsRequest { + repeated AgentTemplate templates = 1; +} +message PutAgentsResponse { + repeated Agent results = 1; +} +message PutAgentPasswordsRequest { + repeated AgentPasswordUpdate updates = 1; +} +message PutAgentPasswordsResponse { + repeated Agent results = 1; +} +message DeleteAgentsRequest { + repeated ModelUUID agent_uuids = 1; +} +message DeleteAgentsResponse { + repeated Agent results = 1; +} + +message GetPermissionSetsRequest { + optional PermissionSetKeySet request = 1; +} +message GetPermissionSetsResponse { + repeated PermissionSet results = 1; +} +message PermissionSetQueryRequest { + optional PermissionSetQuery request = 1; +} +message PermissionSetQueryResponse { + repeated PermissionSet results = 1; +} +message PutPermissionSetsRequest { + repeated PermissionSetTemplate templates = 1; +} +message PutPermissionSetsResponse { + repeated PermissionSet results = 1; +} +message DeletePermissionSetsRequest { + repeated ModelID permission_set_ids = 1; +} +message DeletePermissionSetsResponse { + repeated PermissionSet results = 1; +} + + + +/* + Service for the authorization management system. Enables querying and updating + the set of Agents and PermissionSets. + + See the LoginService for login/logout methods. +*/ +service AuthService { + option (scala_package) = "io.greenbus.client.service"; + option (java_package) = "io.greenbus.japi.client.service"; + + /* + Returns a set of Agents identified specifically by UUID or name. + */ + rpc get_agents(GetAgentsRequest) returns (GetAgentsResponse); + + /* + Returns all Agents or those matching query parameters. Uses paging to + handle large result sets. + */ + rpc agent_query(AgentQueryRequest) returns (AgentQueryResponse); + + /* + Creates or modifies a set of Agents. + */ + rpc put_agents(PutAgentsRequest) returns (PutAgentsResponse); + + /* + Updates the password of a set of Agents. + */ + rpc put_agent_passwords(PutAgentPasswordsRequest) returns (PutAgentPasswordsResponse); + + /* + Deletes a set of Agents identified by UUID. Returns the final state of the deleted Agents. + */ + rpc delete_agents(DeleteAgentsRequest) returns (DeleteAgentsResponse); + + /* + Returns a set of PermissionSets identified specifically by UUID or name. + */ + rpc get_permission_sets(GetPermissionSetsRequest) returns (GetPermissionSetsResponse); + + /* + Returns all PermissionSets or those matching query parameters. Uses paging to + handle large result sets. + */ + rpc permission_set_query(PermissionSetQueryRequest) returns (PermissionSetQueryResponse); + + /* + Creates or modifies a set of PermissionSets. + */ + rpc put_permission_sets(PutPermissionSetsRequest) returns (PutPermissionSetsResponse); + + /* + Deletes a set of PermissionSets identified by UUID. Returns the final state of the deleted PermissionSets. + */ + rpc delete_permission_sets(DeletePermissionSetsRequest) returns (DeletePermissionSetsResponse); + +} diff --git a/client/src/main/proto/services/CommandRequests.proto b/client/src/main/proto/services/CommandRequests.proto new file mode 100644 index 0000000..bb5a2ca --- /dev/null +++ b/client/src/main/proto/services/CommandRequests.proto @@ -0,0 +1,205 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package greenbus.client; + +option java_package = "io.greenbus.client.service.proto"; +option java_outer_classname = "CommandRequests"; +option java_generic_services = false; + +import "google/protobuf/descriptor.proto"; +import "CompilerExtensions.proto"; + +import "Commands.proto"; +import "Model.proto"; +import "ServiceEnvelopes.proto"; +import "services/ModelRequests.proto"; + +/* + Represents a request to acquire a CommandLock in order to issue CommandRequests for a + set of Commands. +*/ +message CommandSelect { + + /* + Specifies the Commands the CommandSelect will affect, by UUID. + + Must include one or more Commands. + + @required + */ + repeated ModelUUID command_uuids = 1; + + /* + Duration in milliseconds the CommandLock should last before expiring. + + If not set, a server-determined default will be used. + + @optional + */ + optional uint64 expire_duration = 2; +} + +/* + Represents a request to acquire a CommandLock in order to block CommandRequests from being + issued and CommandSelects succeeding for a set of Commands. +*/ +message CommandBlock { + + /* + Specifies the Commands the CommandBlock will affect, by UUID. + + Must include one or more Commands. + + @required + */ + repeated ModelUUID command_uuids = 1; +} + +/* + Query parameters to select a set of CommandLocks. + + CommandLocks can be matched by the Commands they refer to, their AccessMode, or the Agents that acquired them. + Including no search parameters will return all CommandLocks in the system. + + Uses paging to handle large result sets. +*/ +message CommandLockQuery { + + /* + Parameters to match CommandLocks by the UUIDs of the Commands they pertain to. All CommandLocks that + refer to at least one of the specified Commands will be returned. + + @optional + */ + repeated ModelUUID command_uuids = 1; + + /* + Parameters to filter CommandLocks to those matching a particular AccessMode. + + @optional + */ + optional CommandLock.AccessMode access = 2; + + + /* + Parameters to match CommandLocks by the UUIDs of the Agents that acquired them. + + @optional + */ + repeated ModelUUID agent_uuids = 4; + + /* + Last ID (in the returned sorted order) of a previous result set. This result set will be the + page beginning from the next ID. + + @optional + */ + optional ModelID last_id = 14; + + /* + The number of elements to return. Used to limit the size of result sets in large models. If not set, + a server-determined page size will be used. + + If the size of the result set equals the page size, there may be more data available. + + @optional + */ + optional uint32 page_size = 15; +} + +message PostCommandRequestRequest { + optional CommandRequest request = 1; +} +message PostCommandRequestResponse { + optional CommandResult result = 1; +} + +message GetCommandLockRequest { + repeated ModelID lock_ids = 1; +} +message GetCommandLockResponse { + repeated CommandLock results = 1; +} +message CommandLockQueryRequest { + optional CommandLockQuery request = 1; +} +message CommandLockQueryResponse { + repeated CommandLock results = 1; +} +message PostCommandSelectRequest { + optional CommandSelect request = 1; +} +message PostCommandSelectResponse { + optional CommandLock result = 1; +} +message PostCommandBlockRequest { + optional CommandBlock request = 1; +} +message PostCommandBlockResponse { + optional CommandLock result = 1; +} + +message DeleteCommandLockRequest { + repeated ModelID lock_ids = 1; +} +message DeleteCommandLockResponse { + repeated CommandLock results = 1; +} + +/* + Service for issuing CommandRequests and acquiring and managing CommandLocks. +*/ +service CommandService { + option (scala_package) = "io.greenbus.client.service"; + option (java_package) = "io.greenbus.japi.client.service"; + + /* + Issues a CommandRequest. + */ + rpc issue_command_request(PostCommandRequestRequest) returns (PostCommandRequestResponse) { + option (addressed) = OPTIONALLY; + } + + /* + Returns a set of CommandLocks specified by ID. + */ + rpc get_command_locks(GetCommandLockRequest) returns (GetCommandLockResponse); + + /* + Returns all CommandLocks or those matching query parameters. Uses paging to + handle large result sets. + */ + rpc command_lock_query(CommandLockQueryRequest) returns (CommandLockQueryResponse); + + /* + Selects a set of Commands for exclusive access in order to issue CommandRequests. + */ + rpc select_commands(PostCommandSelectRequest) returns (PostCommandSelectResponse); + + /* + Blocks a set of Commands to prevent CommandRequests from being issued and CommandSelects from being + acquired by other Agents. + */ + rpc block_commands(PostCommandBlockRequest) returns (PostCommandBlockResponse); + + /* + Deletes a set of CommandLocks specified by ID. + */ + rpc delete_command_locks(DeleteCommandLockRequest) returns (DeleteCommandLockResponse); +} diff --git a/client/src/main/proto/services/EventRequests.proto b/client/src/main/proto/services/EventRequests.proto new file mode 100644 index 0000000..6424577 --- /dev/null +++ b/client/src/main/proto/services/EventRequests.proto @@ -0,0 +1,680 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package greenbus.client; + +option java_package = "io.greenbus.client.service.proto"; +option java_outer_classname = "EventRequests"; +option java_generic_services = false; + +import "google/protobuf/descriptor.proto"; +import "CompilerExtensions.proto"; + +import "Events.proto"; +import "Model.proto"; +import "services/ModelRequests.proto"; + +/* + Query parameters to select a set of Events. +*/ +message EventQueryParams { + + /* + Specifies a set of Event types to be directly matched. Events with one of any of the included Event types will + be returned. + + @optional + */ + repeated string event_type = 1; + + /* + Searches for Events with a time greater than or equal to this value. + + Time is measured in milliseconds since midnight, January 1, 1970 UTC. + + @optional + */ + optional uint64 time_from = 2; + + /* + Searches for Events with a time less than or equal to this value. + + Time is measured in milliseconds since midnight, January 1, 1970 UTC. + + @optional + */ + optional uint64 time_to = 3; + + /* + Specifies a set of Event severities to be directly matched. Events with one of any of the included Event severities will + be returned. + + @optional + */ + repeated uint32 severity = 4; + + /* + Specifies a lower bound on severities. Any Event at this severity or higher (numerically lower) will + be returned. + + If present, overrides the `severity` parameter. + + @optional + */ + optional uint32 severity_or_higher = 5; + + /* + Specifies a set of Event subystem fields to be directly matched. Events with one of any of the included subystems will + be returned. + + @optional + */ + repeated string subsystem = 6; + + /* + Specifies a set of Event agent names to be directly matched. Events with one of any of the included agent names will + be returned. + + @optional + */ + repeated string agent = 7; + + /* + Specifies a set of Entities by keys. Events associated with any of the specified Entities will be returned. + + @nested + @optional + */ + optional EntityKeySet entities = 8; + + /* + Specifies a set of model segments. Events associated with any of the specified model segments will be returned. + + @optional + */ + repeated string model_group = 10; + + /* + If `true`, only returns Events that caused an Alarm to be raised. If `false`, only returns Events that did not + raise an Alarm. + + @optional + */ + optional bool is_alarm = 9; + + + /* + Specifies which side of a page window to return Events for. + + If the number of Events in the time window exceeds the limit on the number of Events to be + returned by the query, the sequence of Events will be justified to either the lower or higher + boundary of the window. + + - If `false`, the returned Events will begin with the first in the time window and proceed until the time window + ends or the limit is met. Paging using this method would move forward in time. + - If `true`, the sequence of returned Events begins at the `limit`th value below the high boundary of the time window, + and proceeds to the high boundary. Paging using this method would move backward in time. + + In both cases, the sequence of Events is returned in ascending time-order (oldest to newest). + + The default value is `true`. + + @optional + */ + optional bool latest = 16; + +} + +/* + Query for Events. + + Including no search parameters will return all Events in the system. + + Paging by ID is used to handle large result sets. Results will be returned sorted first by the requested time + ordering and then by ID to distinguish between Events that are recorded in the same millisecond. + + - When paging "forward" (`latest = false`), the `last_id` field should be set to the last value in the result order. + - When paging "backward" (`latest = true`), the `last_id` field should be set to the first value in the result order. +*/ +message EventQuery { + + /* + The query parameters to find a set of Events. + + @optional + @nested + */ + optional EventQueryParams query_params = 1; + + /* + Last ID (in the returned sorted order) of a previous result set. This result set will be the + page beginning from the next ID. + + @optional + */ + optional ModelID last_id = 25; + + /* + The number of elements to return. Used to limit the size of result sets in large models. If not set, + a server-determined limit will be used. + + If the size of the result set equals the limit, there may be more data available. + + @optional + */ + optional uint32 page_size = 26; +} + +/* + Subscription parameters for Events. + + Including no subscription parameters will subscribe to all future Event notifications. + + Subscribes and returns an initial result set. The initial result set is governed by the `time_from` and `limit` + fields, which put a bound on the number of the _latest_ Events to return that also match the subscription parameters. + Not specifying these parameters will use a default `limit`; setting `limit = 0` will result in only subscribing to + future Events. +*/ +message EventSubscriptionQuery { + + /* + Specifies a set of Event types to be directly matched. Events with one of any of the included Event types will + be returned and notifications for Events with these types will be delivered. + + @optional + */ + repeated string event_type = 1; + + /* + Specifies a set of Event severities to be directly matched. Events with one of any of the included Event severities will + be returned and notifications for Events with these severities will be delivered. + + @optional + */ + repeated uint32 severity = 2; + + /* + Specifies a set of subsystems to be directly matched. Events with one of any of the included subsystems will + be returned and notifications for Events with these subsystems will be delivered. + + @optional + */ + repeated string subsystem = 3; + + /* + Specifies a set of agent names to be directly matched. Events with one of any of the included agent names will + be returned and notifications for Events with these agent names will be delivered. + + @optional + */ + repeated string agent = 4; + + /* + Specifies a set of Event types to be directly matched. Events with one of any of the included Event types will + be returned and notifications for Events with these types will be delivered. + + __Note__: Event notifications are filtered by Entity UUID, not name. Using a name parameter resolves + the UUID of the Entity associated with that name at the time of the request, and is + equivalent to the client resolving the UUID before making the request. As a result, if the + Entity a particular name refers to changes (due to a deletion/addition or two renames) Event notifications will be delivered + for only for Events pertaining to the original Entity. + + @nested + @optional + */ + optional EntityKeySet entities = 5; + + /* + If, `true`, only returns and subscribes to notifications for Events that raised an Alarm. If `false`, only returns + and subscribes to notifications for Events that did not raise an Alarm. + + @optional + */ + optional bool is_alarm = 6; + + /* + Searches for Events with a time greater than or equal to this value. + + Only relevant to the initial result set. Does not affect the subscription itself. + + Time is measured in milliseconds since midnight, January 1, 1970 UTC. + + @optional + */ + optional uint64 time_from = 14; + + /* + The number of elements to return. If not set, a server-determined limit will be used. + + Only relevant to the initial result set. Does not affect the subscription itself. + + @optional + */ + optional uint32 limit = 15; +} + +/* + Description used to create an Event. +*/ +message EventTemplate { + + /* + Event type, corresponding to a registered type in the event configuration. + + @required + */ + optional string event_type = 1; + + /* + Subsystem that authored this event. + + @optional + */ + optional string subsystem = 2; + + /* + Time of occurrence for the underlying condition that caused the Event to be published. + + Time is measured in milliseconds since midnight, January 1, 1970 UTC. + + @optional + */ + optional uint64 device_time = 5; + + /* + UUID of the object in the system model associated with this Event. + + @optional + */ + optional ModelUUID entity_uuid = 9; + + /* + ID of the segment of the model this Event is associated with. + + @optional + */ + optional string model_group = 11; + + /* + Specifies key-value pairs that correspond to parameters in the resource string + for the EventConfig associated with `event_type`. + + @optional + */ + repeated Attribute args = 10; +} + +/* + Query parameters to select a set of Alarms. Relies on the EventQuery to search by the Events associated + with Alarms, adding Alarm-specific query parameters. + + Including no search parameters will return all Alarms in the system. + + Paging by ID is used to handle large result sets. Results will be returned sorted first by the requested time + ordering and then by ID to distinguish between Events that are recorded in the same millisecond. + + - When paging "forward" (`latest = false`), the `last_id` field should be set to the last value in the result order. + - When paging "backward" (`latest = true`), the `last_id` field should be set to the first value in the result order. +*/ +message AlarmQuery { + + /* + Parameters describing the Event associated with the Alarm. + + @nested + @optional + */ + optional EventQueryParams event_query_params = 1; + + /* + Specifies a set of Alarm states to be directly matched. Alarms with one of any of the included Alarm states will + be returned. + */ + repeated Alarm.State alarm_states = 2; + + /* + Last ID (in the returned sorted order) of a previous result set. This result set will be the + page beginning from the next ID. + + The ID used should be for the Alarm, not an associated Event. + + @optional + */ + optional ModelID last_id = 25; + + /* + The number of elements to return. Used to limit the size of result sets in large models. If not set, + a server-determined limit will be used. + + If the size of the result set equals the limit, there may be more data available. + + @optional + */ + optional uint32 page_size = 26; +} + +/* + Subscription parameters for Alarms. Relies on the EventSubscriptionQuery to define the Events associated + with Alarms, adding Alarm-specific subscription parameters. + + Including no subscription parameters will subscribe to all future Event notifications. +*/ +message AlarmSubscriptionQuery { + + /* + Parameters describing the Event associated with the Alarm. + + @nested + @optional + */ + optional EventSubscriptionQuery event_query = 1; + + /* + + Specifies a set of Alarm states to be directly matched. Alarms with one of any of the included Alarm states will + be returned and notifications for Alarms with these states will be delivered. + + @optional + */ + repeated Alarm.State alarm_states = 2; +} + +/* + Represents a modification to the state of an existing Alarm. +*/ +message AlarmStateUpdate { + + /* + ID of the Alarm to be updated. + + @required + */ + optional ModelID alarm_id = 1; + + /* + New state the Alarm should be in. + + @required + */ + optional Alarm.State alarm_state = 2; +} + +/* + Description used to create or modify an EventConfig. +*/ +message EventConfigTemplate { + + /* + Event type this EventConfig configures. + + @required + */ + optional string event_type = 1; + + /* + Severity ranking for the prospective Event. `1` is the most severe. Number of severity levels is + configurable (default is 1-8). + + @required + */ + optional uint32 severity = 2; + + /* + Determines whether Events published with this type will be just Events, Events and Alarms, or will be demoted + to log entries. + + @required + */ + optional EventConfig.Designation designation = 3; + + /* + If `designation` is `ALARM`, specifies the initial state of the raised Alarm. + + Defaults to `UNACK_SILENT`. Irrelevant if an Alarm is not raised. + + @optional + */ + optional Alarm.State alarm_state = 4; + + /* + Specifies the template for Event messages. Parameters are specified by enclosing the index into the Attribute + list in braces. + + Example: + +

+        Resource: "The {subject} jumped over the {object}."
+        Attributes: ["subject -> "cow", "object" -> "moon"]
+        
+ + If an Attribute is not present, the key name itself will appear in the message. + + @required + */ + optional string resource = 5; +} + +/* + Query parameters to select a set of EventConfigs. EventConfigs can be filtered by severity, designation, + or the initial state of Alarms. + + Including no search parameters will return all EventConfigs in the system. + + Paging by the `event_type` field is used to handle large result sets. +*/ +message EventConfigQuery { + + /* + Specifies a set of severities to be directly matched. EventConfigs with one of any of the included severities will + be returned. + + @optional + */ + repeated uint32 severity = 1; + + /* + Specifies a lower bound on severities. Any EventConfig at this severity or higher (numerically lower) will + be returned. + + If present, overrides the `severity` parameter. + + @optional + */ + optional uint32 severity_or_higher = 2; + + /* + Specifies a set of designations to be directly matched. EventConfigs with one of any of the included designations + will be returned. + + @optional + */ + repeated EventConfig.Designation designation = 3; + + /* + Specifies a set of Alarm states to be directly matched. EventConfigs with one of any of the included Alarm states + will be returned. + + @optional + */ + repeated Alarm.State alarm_state = 4; + + /* + Last Event type (in the returned sorted order) of a previous result set. This result set will be the + page beginning from the next Event type. + + @optional + */ + optional string last_event_type = 14; + + /* + The number of elements to return. Used to limit the size of result sets in large models. If not set, + a server-determined page size will be used. + + If the size of the result set equals the page size, there may be more data available. + + @optional + */ + optional uint32 page_size = 15; +} + + +message GetEventsRequest { + repeated ModelID event_id = 1; +} +message GetEventsResponse { + repeated Event results = 1; +} +message EventQueryRequest { + optional EventQuery request = 1; +} +message EventQueryResponse { + repeated Event results = 1; +} +message PostEventsRequest { + repeated EventTemplate request = 1; +} +message PostEventsResponse { + repeated Event results = 1; +} +message SubscribeEventsRequest { + optional EventSubscriptionQuery request = 1; +} +message SubscribeEventsResponse { + repeated Event results = 1; +} + +message AlarmQueryRequest { + optional AlarmQuery request = 1; +} +message AlarmQueryResponse { + repeated Alarm results = 1; +} +message PutAlarmStateRequest { + repeated AlarmStateUpdate request = 1; +} +message PutAlarmStateResponse { + repeated Alarm results = 1; +} +message SubscribeAlarmsRequest { + optional AlarmSubscriptionQuery request = 1; +} +message SubscribeAlarmsResponse { + repeated Alarm results = 1; +} + + +message EventConfigQueryRequest { + optional EventConfigQuery request = 1; +} +message EventConfigQueryResponse { + repeated EventConfig results = 1; +} +message GetEventConfigRequest { + repeated string event_type = 1; +} +message GetEventConfigResponse { + repeated EventConfig results = 1; +} +message PutEventConfigRequest { + repeated EventConfigTemplate request = 1; +} +message PutEventConfigResponse { + repeated EventConfig results = 1; +} +message DeleteEventConfigRequest { + repeated string event_type = 1; +} +message DeleteEventConfigResponse { + repeated EventConfig results = 1; +} + + +service EventService { + option (scala_package) = "io.greenbus.client.service"; + option (java_package) = "io.greenbus.japi.client.service"; + + /* + Returns Events identified by ID. + */ + rpc get_events(GetEventsRequest) returns (GetEventsResponse); + + /* + Returns all Events or those matching query parameters. + */ + rpc event_query(EventQueryRequest) returns (EventQueryResponse); + + /* + Requests Events be created. + */ + rpc post_events(PostEventsRequest) returns (PostEventsResponse); + + /* + Subscribes to Event notifications, and can return the latest Events. + */ + rpc subscribe_to_events(SubscribeEventsRequest) returns (SubscribeEventsResponse) { + option (subscription_type) = "greenbus.client.EventNotification"; + } + + /* + Returns all Alarms or those matching query parameters. + */ + rpc alarm_query(AlarmQueryRequest) returns (AlarmQueryResponse); + + /* + Updates the state of a set of Alarms. + + The valid Alarm state transitions are: + + - `UNACK_AUDIBLE` -> ( `UNACK_SILENT`, `ACKNOWLEDGED` ) + - `UNACK_SILENT` -> ( `ACKNOWLEDGED` ) + - `ACKNOWLEDGED` -> ( `REMOVED` ) + + Additionally, an update that does not change the state of the Alarm is always legal. + + */ + rpc put_alarm_state(PutAlarmStateRequest) returns (PutAlarmStateResponse); + + /* + Subscribes to Alarm notifications, and can return the latest Alarm. + */ + rpc subscribe_to_alarms(SubscribeAlarmsRequest) returns (SubscribeAlarmsResponse) { + option (subscription_type) = "greenbus.client.AlarmNotification"; + } + + /* + Returns all EventConfigs or those matching query parameters. + */ + rpc event_config_query(EventConfigQueryRequest) returns (EventConfigQueryResponse); + + /* + Returns EventConfigs identified by Event type. + */ + rpc get_event_configs(GetEventConfigRequest) returns (GetEventConfigResponse); + + /* + Creates or modifies a set of EventConfigs. + */ + rpc put_event_configs(PutEventConfigRequest) returns (PutEventConfigResponse); + + /* + Deletes a set of EventConfigs identified by Event type. Returns the final state of the deleted EventConfigs. + + Will fail if specified EventConfigs are built-in. + */ + rpc delete_event_configs(DeleteEventConfigRequest) returns (DeleteEventConfigResponse); +} diff --git a/client/src/main/proto/services/FrontEndRequests.proto b/client/src/main/proto/services/FrontEndRequests.proto new file mode 100644 index 0000000..0fa68db --- /dev/null +++ b/client/src/main/proto/services/FrontEndRequests.proto @@ -0,0 +1,156 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package greenbus.client; + +option java_package = "io.greenbus.client.service.proto"; +option java_outer_classname = "FrontEndRequests"; +option java_generic_services = false; + +import "google/protobuf/descriptor.proto"; +import "CompilerExtensions.proto"; + +import "FrontEnd.proto"; +import "Model.proto"; +import "services/ModelRequests.proto"; + + +/* + Represents a front-end's registration to provide service for an Endpoint. + + An address may be provided where CommandRequests for this Endpoint should be sent. If no address is included, the + Endpoint will not support CommandRequests. +*/ +message FrontEndRegistrationTemplate { + + /* + UUID of the Endpoint the front-end is registering for. + + @required + */ + optional ModelUUID endpoint_uuid = 1; + + /* + Service address that should be used to send CommandRequests to this front-end for this Endpoint. + + @optional + */ + optional string command_address = 2; + + /* + Allows the front-end instance to associate the registration with a unique persistent identity. If the front-end loses + the registration information (perhaps due to a restart) re-registration with the same node ID may be + accepted even if the registration has not timed out. + + Node IDs should be unique for all front-ends and only re-used by the same logical process on restarts. + + @optional + */ + optional string fep_node_id = 5; + + /* + Used by the front-end to indicate that it is currently excluding other front-ends from make a connection for this Endpoint + (perhaps by holding open a connection that will only accept a single client). Registrations using this flag + take precedence and will abort the registrations of other front-ends (which presumably are unable to connect). + + This is intended to allow front-end instances that were separated from the network for long enough for a fail-over to + occur to reclaim their registration instead of releasing an expensive external connection. + + @optional + */ + optional bool holding_lock = 6; +} + +/* + Used by a front-end to notify the system of updates to the status of itself or the external source it communicates with. +*/ +message FrontEndStatusUpdate { + + /* + UUID of the Endpoint the front-end is registering for. + + @required + */ + optional ModelUUID endpoint_uuid = 1; + + /* + UUID of the Endpoint the front-end is registering for. + + @required + */ + optional FrontEndConnectionStatus.Status status = 2; +} + + +message PutFrontEndRegistrationRequest { + optional FrontEndRegistrationTemplate request = 1; +} +message PutFrontEndRegistrationResponse { + optional FrontEndRegistration results = 1; +} + +message GetFrontEndConnectionStatusRequest { + optional EntityKeySet endpoints = 1; +} +message GetFrontEndConnectionStatusResponse { + repeated FrontEndConnectionStatus results = 1; +} +message SubscribeFrontEndConnectionStatusRequest { + optional EndpointSubscriptionQuery endpoints = 1; +} +message SubscribeFrontEndConnectionStatusResponse { + repeated FrontEndConnectionStatus results = 1; +} +message PutFrontEndConnectionStatusRequest { + repeated FrontEndStatusUpdate updates = 1; +} +message PutFrontEndConnectionStatusResponse { + repeated FrontEndConnectionStatus results = 1; +} + +service FrontEndService { + option (scala_package) = "io.greenbus.client.service"; + option (java_package) = "io.greenbus.japi.client.service"; + + /* + Registers a set of front-ends to provide services for a set of Endpoints. + */ + rpc put_front_end_registration(PutFrontEndRegistrationRequest) returns (PutFrontEndRegistrationResponse) { + option (addressed) = OPTIONALLY; + } + + /* + Gets front-end connection statuses by Endpoint UUIDs and names. + */ + rpc get_front_end_connection_statuses(GetFrontEndConnectionStatusRequest) returns (GetFrontEndConnectionStatusResponse); + + /* + Notifies the system of updates to the status of front-end services for a set of Endpoints. + */ + rpc put_front_end_connection_statuses(PutFrontEndConnectionStatusRequest) returns (PutFrontEndConnectionStatusResponse) { + option (addressed) = OPTIONALLY; + } + + /* + Subscribes to notifications for changes to the statuses of front-end connections for a particular set of Endpoints. + */ + rpc subscribe_to_front_end_connection_statuses(SubscribeFrontEndConnectionStatusRequest) returns (SubscribeFrontEndConnectionStatusResponse) { + option (subscription_type) = "greenbus.client.FrontEndConnectionStatusNotification"; + } + +} diff --git a/client/src/main/proto/services/LoginRequests.proto b/client/src/main/proto/services/LoginRequests.proto new file mode 100644 index 0000000..c99f822 --- /dev/null +++ b/client/src/main/proto/services/LoginRequests.proto @@ -0,0 +1,158 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package greenbus.client; + +option java_package = "io.greenbus.client.service.proto"; +option java_outer_classname = "LoginRequests"; +option java_generic_services = false; + +import "google/protobuf/descriptor.proto"; +import "CompilerExtensions.proto"; + +import "Auth.proto"; + +/* + Request for an auth token, specifying name and password, expiration time, and contextual information. +*/ +message LoginRequest { + + /* + Name associated with credentials. Will be the name of an Agent modeled in the system. + + @required + */ + optional string name = 2; + + /* + Password associated with credentials. + + @required + */ + optional string password = 3; + + /* + Provides contextual information about the user or process obtaining the auth token. + + @optional + */ + optional string login_location = 4; + + /* + Time for the resulting auth token. If not specified, a default value will be provided. + + Time is measured in milliseconds since midnight, January 1, 1970 UTC. + + @optional + */ + optional uint64 expiration_time = 5; + + /* + Provides contextual information about the client software version. + + @optional + */ + optional string client_version = 6; +} + +/* + Provides an auth token, expiration time, and contextual information as a result of a successful login. +*/ +message LoginResponse { + + /* + Auth token resulting from a successful login. Can be included in subsequent service requests in order to + authorize them. + */ + optional string token = 2; + + /* + Expiration time for this auth token. After this time the auth token will be rejected and a new token + will need to be acquired. + + Time is measured in milliseconds since midnight, January 1, 1970 UTC. + */ + optional uint64 expiration_time = 3; + + /* + Contextual information about the server software version. + */ + optional string server_version = 4; +} + +/* + Request to invalidate and remove an auth token no longer being used. +*/ +message LogoutRequest { + + /* + Auth token to be invalidated. + + @required + */ + optional string token = 2; +} + + + +message PostLoginRequest { + optional LoginRequest request = 1; +} +message PostLoginResponse { + optional LoginResponse response = 1; +} + +message PostLogoutRequest { + optional LogoutRequest request = 1; +} +message PostLogoutResponse { + +} + +message ValidateAuthTokenRequest { + optional string token = 1; +} +message ValidateAuthTokenResponse { +} + + +service LoginService { + option (scala_package) = "io.greenbus.client.service"; + option (java_package) = "io.greenbus.japi.client.service"; + + /* + Provides user credentials in exchange for an auth token. + + Request does not require authorization. + */ + rpc login(PostLoginRequest) returns (PostLoginResponse); + + /* + Logs out an auth token, causing subsequent authorizations using it to fail. + */ + rpc logout(PostLogoutRequest) returns (PostLogoutResponse); + + /* + Verifies an auth token is valid. Fails if the auth token is not authorized. + + Used in situations where a process is managing requests on behalf of a client that has provided it + a pre-obtained auth token. + */ + rpc validate(ValidateAuthTokenRequest) returns (ValidateAuthTokenResponse); + +} diff --git a/client/src/main/proto/services/MeasurementRequests.proto b/client/src/main/proto/services/MeasurementRequests.proto new file mode 100644 index 0000000..8c70492 --- /dev/null +++ b/client/src/main/proto/services/MeasurementRequests.proto @@ -0,0 +1,184 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package greenbus.client; + +option java_package = "io.greenbus.client.service.proto"; +option java_outer_classname = "MeasurementRequests"; +option java_generic_services = false; + +import "google/protobuf/descriptor.proto"; +import "CompilerExtensions.proto"; + +import "Measurements.proto"; +import "Model.proto"; +import "services/ModelRequests.proto"; + + +/* + Specifies a sequence of historical Measurement values for a Point by time-order, with constraints. + + The resulting sequence of Measurements is always returned in ascending time-order (oldest to newest). +*/ +message MeasurementHistoryQuery { + + /* + UUID of Point to find Measurements for. + + @required + */ + optional ModelUUID point_uuid = 1; + + /* + Boundary of earliest time, exclusive, in the window of Measurements to return. + + If not included, the beginning of the time window is unbounded, or practically, bounded to the + first Measurement published. + + Time is measured in milliseconds since midnight, January 1, 1970 UTC. + + @optional + */ + optional uint64 time_from = 2; + + /* + Boundary of latest time, inclusive, in the window of Measurements to return. + + If not included, the end of the time window is unbounded, or practically, bounded to the current time. + + Time is measured in milliseconds since midnight, January 1, 1970 UTC. + + @optional + */ + optional uint64 time_to = 3; + + /* + Maximum number of Measurement values to return. + + If not specified, the server provides a default value. + + @optional + */ + optional uint32 limit = 4; + + /* + If the number of Measurements in the time window exceeds the limit on the number of Measurements to be + returned by the query, the sequence of Measurements will be justified to either the lower or higher + boundary of the window. + + - If `false`, the returned Measurements will begin with the first in the time window and proceed until the time window + ends or the limit is met. Paging using this method would move forward in time. + - If `true`, the sequence of returned Measurements begins at the `limit`th value below the high boundary of the time window, + and proceeds to the high boundary. Paging using this method would move backward in time. + + In both cases, the sequence of Measurements is returned in ascending time-order (oldest to newest). + + The default value is `false`. + + @optional + */ + optional bool latest = 5; +} + +/* + Selects Measurement batches to subscribe to. Can be narrowed by the UUIDs of Endpoints. If no Endpoints are + specified, subscribes to all notifications. +*/ +message MeasurementBatchSubscriptionQuery { + + /* + UUIDs of Endpoint to receive batch notifications for. + + @required + */ + repeated ModelUUID endpoint_uuids = 1; +} + + +message GetCurrentValuesRequest { + repeated ModelUUID point_uuids = 1; +} +message GetCurrentValuesResponse { + repeated PointMeasurementValue results = 1; +} + +message GetMeasurementHistoryRequest { + optional MeasurementHistoryQuery request = 1; +} +message GetMeasurementHistoryResponse { + optional PointMeasurementValues result = 2; +} + +message GetCurrentValuesAndSubscribeRequest { + repeated ModelUUID point_uuids = 1; +} +message GetCurrentValuesAndSubscribeResponse { + repeated PointMeasurementValue results = 1; +} + +message SubscribeToBatchesRequest { + optional MeasurementBatchSubscriptionQuery query = 1; +} +message SubscribeToBatchesResponse { +} + +message PostMeasurementsRequest { + optional MeasurementBatch batch = 1; +} +message PostMeasurementsResponse { +} + +service MeasurementService { + option (scala_package) = "io.greenbus.client.service"; + option (java_package) = "io.greenbus.japi.client.service"; + + /* + Gets current Measurement values for Points specified by UUID. + */ + rpc get_current_values(GetCurrentValuesRequest) returns (GetCurrentValuesResponse); + + /* + Queries for a time series of previous Measurement values. + */ + rpc get_history(GetMeasurementHistoryRequest) returns (GetMeasurementHistoryResponse); + + /* + Gets current Measurement values for Points specified by UUID, and subscribes to subsequent Measurements for + those Points. + */ + rpc get_current_values_and_subscribe(GetCurrentValuesAndSubscribeRequest) returns (GetCurrentValuesAndSubscribeResponse) { + option (subscription_type) = "greenbus.client.MeasurementNotification"; + } + + /* + Subscribes to notifications for batches of Measurements. Measurements are in the batches they were published + to the measurement processor. + + The subscription can be filtered by the UUIDs of the Endpoints they are associated with. + */ + rpc subscribe_to_batches(SubscribeToBatchesRequest) returns (SubscribeToBatchesResponse) { + option (subscription_type) = "greenbus.client.MeasurementBatchNotification"; + } + + /* + Posts a batch of Measurements to a measurement processor. + */ + rpc post_measurements(PostMeasurementsRequest) returns (PostMeasurementsResponse) { + option (addressed) = ALWAYS; + } +} diff --git a/client/src/main/proto/services/ModelRequests.proto b/client/src/main/proto/services/ModelRequests.proto new file mode 100644 index 0000000..9ea5731 --- /dev/null +++ b/client/src/main/proto/services/ModelRequests.proto @@ -0,0 +1,1288 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package greenbus.client; + +option java_package = "io.greenbus.client.service.proto"; +option java_outer_classname = "ModelRequests"; +option java_generic_services = false; + +import "google/protobuf/descriptor.proto"; +import "CompilerExtensions.proto"; + +import "Model.proto"; +import "ServiceEnvelopes.proto"; + +/* + Contains common parameters for specifying paging in model queries. +*/ +message EntityPagingParams { + + /* + If `true` results are sorted by name. If `false` results are sorted by UUID. + + Default is `true`. + + @optional + */ + optional bool page_by_name = 12; + + /* + Last UUID (in the returned sorted order) of a previous result set. This result set will be the + page beginning from the next UUID. + + @optional + */ + optional ModelUUID last_uuid = 14; + + /* + Last name (in the returned sorted order) of a previous result set. This result set will be the + page beginning from the next name. + + @optional + */ + optional string last_name = 13; + + /* + The number of elements to return. Used to limit the size of result sets in large models. If not set, + a server-determined page size will be used. + + If the size of the result set equals the page size, there may be more data available. + + @optional + */ + optional uint32 page_size = 15; +} + +/* + Description used to create or modify an Entity. + + New Entities are created by using a template with no UUID, identified by name and with a set of types. The UUID for the Entity + will be generated on the server. + + Including a UUID will either create a new Entity with a pre-defined UUID (e.g. imported), or modify the name and types + of an existing Entity. +*/ +message EntityTemplate { + + /* + Identifies a pre-existing Entity, or provides a pre-determined UUID for a new Entity. + + @optional + */ + optional ModelUUID uuid = 1; + + /* + Identifies a pre-existing Entity, provides a name for a created Entity, or a new name for an existing Entity. + + - If a UUID is not provided and the Entity does not exist an Entity will be created with this name. + - If a UUID is not provided and the Entity exists, the Entity will be identified and only the types will be (potentially) modified. + - If a UUID is provided and the Entity does not exist, an Entity will be created with this name. + - If a UUID is provided and the Entity already exists, the Entity will be renamed to this name. + + @required + */ + optional string name = 2; + + /* + The set of types for the Entity. + + - If the Entity is being created, all types will be added. + - If the Entity is being modified, types not in this field will be removed and types in this field and not previously in the Entity will be added. + - An empty set of types (including if the field is uninitialized) will result in the Entity containing no types. + + @required + */ + repeated string types = 3; +} + +/* + Collection of keys that identify a set of Entities. +*/ +message EntityKeySet { + + /* + UUIDs of Entities. + + @optional + */ + repeated ModelUUID uuids = 1; + + /* + Names of Entities. + + @optional + */ + repeated string names = 2; +} + +/* + Parameters to match Entities based on their types. +*/ +message EntityTypeParams { + + /* + Any Entity that has a type in this set will be included. + + @optional + */ + repeated string include_types = 2; + + /* + If non-empty, an Entity must have all types in this set to be included. + + @optional + */ + repeated string match_types = 5; + + /* + Entities that have a type in this set will be excluded. + + @optional + */ + repeated string filter_out_types = 6; +} + +/* + Query parameters to select a set of Entities. + + Including no search parameters will return all Entities in the system. + + Uses paging to handle large result sets. +*/ +message EntityQuery { + + /* + Parameters to match Entities based on their types. + + @optional + @nested + */ + optional EntityTypeParams type_params = 1; + + /* + Parameters to narrow the result sets for queries. + + @optional + @nested + */ + optional EntityPagingParams paging_params = 14; +} + +/* + Subscription parameters for Entities. Entity notifications can be filtered by key. + + - Including key parameters will return the current state of those Entities and subscribe to notifications for them. + - Including no parameters will subscribe to all Entity notifications, but return no immediate results. + + __Note__: Entity notifications are filtered by UUID, not name. Using a name parameter resolves + the UUID of the Entity associated with that name at the time of the request, and is + equivalent to the client resolving the UUID before making the request. As a result, if the + Entity a particular name refers to changes (due to a deletion/addition or two renames) notifications will be delivered + for only for the original Entity. + +*/ +message EntitySubscriptionQuery { + + /* + UUIDs of Entities. + + @optional + */ + repeated ModelUUID uuids = 1; + + /* + Names of Entities. + + @optional + */ + repeated string names = 2; +} + +/* + Queries for a result set of Entities based on their relationship in the graph model to a "start" set. + The start set can include multiple Entities, including those only specified by reference to their + type, and the query can reach across derived edges to return Entities connected through transitive + relationships. However, the service returns a flat result set, so the mapping between Entities in the result + set and Entities in the start set is not preserved. To get information about the relationships between + parents and children in the graph model, see *ModelService.edge_query*. + + The query may be directed in either direction; from parents to children or vice versa. + + The cost of the query does not increase with depth. + + Uses paging to handle large result sets. + +*/ +message EntityRelationshipFlatQuery { + + /* + Specifies UUIDs of Entities to include in the start set. + + One or more of start_uuids, start_names, or start_types must be specified. + + @optional + */ + repeated ModelUUID start_uuids = 1; + + /* + Specifies names of Entities to include in the start set. + + One or more of start_uuids, start_names, or start_types must be specified. + + @optional + */ + repeated string start_names = 2; + + /* + Entities having one or more of the specified types will be included in the start set. Note that Entities + with the same type may appear at different levels in the same hierachy, and so may also be in the + result set. + + Entities specified by UUID or name do not have to include these types; the start set is the union of + the result of all three sets of parameters. + + One or more of start_uuids, start_names, or start_types must be specified. + + @optional + */ + repeated string start_types = 3; + + /* + Relationship type between parents and children. + + @required + */ + optional string relationship = 10; + + /* + Specifies the direction of the query. + + - If true, the Entities in the start set are the parents and the result set are the children. + - If false, the Entities in the start set are the children and the result set are the parents. + + @required + */ + optional bool descendant_of = 11; + + /* + Narrows the result set of Entities to only those with one of the specified types. + + - If types are specified, only Entities with one or more of the types in the list will be included in the result set. + - If no types are specified, all Entities will be included in the result set. + + @optional + */ + repeated string end_types = 12; + + /* + Provides an optional limit to the depth of the query. Relationships in the graph are transitive, and + by default the query will return all Entities with immediate and derived edges for the given relationship. + Setting a depth limit will only return Entities at a distance up to and including that depth. + + Example: + +
   
+            Entity tree: A -> B -> C -> D 
+            Start set: ( A )
+
+            No depth limit:     ( B, C, D )
+            Depth limit = 1:    ( B )
+            Depth limit = 2:    ( B, C )
+        
+ + @optional + */ + optional uint32 depth_limit = 13; + + /* + Parameters to narrow the result sets for queries. + + @optional + @nested + */ + optional EntityPagingParams paging_params = 14; +} + +/* + Queries for edges in the graph model. Can search according to parents, children, relationship types, + and depth. + + Including no search parameters will return all edges in the graph model. Including only + `depth_limit = 1` will return all immediate edges, which is sufficient to reconstruct the model. + + Enables querying for derived edges, but the intermediate path will not necessarily be included. + + The cost of the query does not increase with depth. + + Uses paging to handle large result sets. +*/ +message EntityEdgeQuery { + + /* + Filters results by parent UUIDs. + + @optional + */ + repeated ModelUUID parent_uuids = 1; + + /* + Filters results by child UUIDs. + + @optional + */ + repeated ModelUUID child_uuids = 2; + + /* + Filters results by relationships. + + - If not specified, edges of any relationship will be returned. + - If specified, only edges with one of the listed relationships will be returned. + + @optional + */ + repeated string relationships = 3; + + /* + Provides an optional limit to the depth of the query. By default, the query will return any and all + derived edges (`distance > 1`). Setting a depth limit will return only edges up to and including that + depth. + + Example: + +

+            Entity tree: A -> B -> C -> D
+            Parent: ( A )
+
+            No depth limit:     ( (A -> B), (A -> C), (A -> D) )
+            Depth limit = 1:    ( (A -> B) )
+            Depth limit = 2:    ( (A -> B), (A -> C) )
+        
+ + Note that intermediate edges are not necessarily provided (unless they are otherwise part of the result set). + In other words, in the example above, `(B -> C)`, `(B -> D)`, and `(C -> D)` are all unstated. The same result + would hold in an alternate scenario where `C` and `D` were immediate children of `B`. + + @optional + */ + optional uint32 depth_limit = 4; + + /* + Last ID (in the returned sorted order) of a previous result set. This result set will be the + page beginning from the next ID. + + @optional + */ + optional ModelID last_id = 21; + + /* + The number of elements to return. Used to limit the size of result sets in large models. If not set, + a server-determined page size will be used. + + If the size of the result set equals the page size, there may be more data available. + + @optional + */ + optional uint32 page_size = 22; +} + +/* + Describes a filter for EntityEdges. + + An EntityEdge that is added or removed must match all specified fields to be + included by this filter. +*/ +message EntityEdgeFilter { + + /* + Filter EntityEdges with this parent UUID. + + @optional + */ + optional ModelUUID parent_uuid = 1; + + /* + Filter EntityEdges with this child UUID. + + @optional + */ + optional ModelUUID child_uuid = 2; + + /* + Filter EntityEdges with this relationship. + + @optional + */ + optional string relationship = 3; + + /* + Filter EntityEdges at this distance. + + Most likely to be useful for filtering for immediate edges, or `distance = 1`. + + @optional + */ + optional uint32 distance = 4; +} + +/* + Query for subscribing to notifications for a set of EntityEdges. + + Including no parameters will subscribe to all EntityEdge notifications. +*/ +message EntityEdgeSubscriptionQuery { + + /* + A set of filters for EntityEdges for which to be notified of changes. + + Notifications will be sent if an EntityEdge that is added or removed matches one or + more of these filters. + + @optional + @nested + */ + repeated EntityEdgeFilter filters = 2; +} + +/* + Describes an immediate edge in the graph model. All immediate edges are unique for the combined values of these + fields. +*/ +message EntityEdgeDescriptor { + + /* + UUID of parent Entity for this edge. + + @required + */ + optional ModelUUID parent_uuid = 1; + + /* + UUID of child Entity for this edge. + + @required + */ + optional ModelUUID child_uuid = 2; + + /* + Relationship type for this edge. + + @required + */ + optional string relationship = 3; +} + + +/* + Description used to create or modify a Point. +*/ +message PointTemplate { + + /* + Description of the Entity information for this Point. + + @required + @nested + */ + optional EntityTemplate entity_template = 1; + + /* + Specifies a high-level category for the data of Measurements associated with this Point. + + @required + */ + optional PointCategory point_category = 5; + + /* + Specifies the unit of Measurements associated with this Point. + + @required + */ + optional string unit = 6; +} + +/* + Query parameters to select a set of Points. + + Points can be matched by their Entity types or Point-specific fields. + Including no search parameters will return all Points in the system. + + Uses paging to handle large result sets. +*/ +message PointQuery { + + /* + Parameters to match Points based on their Entity types. + + @optional + @nested + */ + optional EntityTypeParams type_params = 1; + + /* + Parameters to match Points based on PointCategory. Points with one of any of the specified PointCategories will be matched. + + @optional + */ + repeated PointCategory point_categories = 5; + + /* + Parameters to match Points based on unit. Points with one of any of the specified units will be matched. + + @optional + */ + repeated string units = 6; + + /* + Parameters to narrow the result sets for queries. + + @optional + @nested + */ + optional EntityPagingParams paging_params = 14; +} + + +/* + Subscription parameters for Points. Notifications can be filtered by key or by Endpoint associations. + + - Including key parameters (and no Endpoint UUIDs) will return the current state of those Points + and subscribe to notifications for them. + - Including Endpoint UUIDs will subscribe to notifications for Points associated with those Endpoints, + but return no immediate results. + - Including no parameters will subscribe to notifications for all Points, but return no immediate results. + + __Note__: Point notifications are filtered by UUID, not name. Using a name parameter resolves + the UUID of the Point associated with that name at the time of the request, and is + equivalent to the client resolving the UUID before making the request. As a result, if the + Point a particular name refers to changes (due to a deletion/addition or two renames) notifications will be delivered + for only for the original Point. + + Endpoint UUIDs are _not_ resolved to a set of Points at request time, and updates in the system model to associations between Points + and Endpoints will affect what notifications are delivered to the subscription. +*/ +message PointSubscriptionQuery { + + /* + UUIDs of Points to subscribe to notifications for. + + @optional + */ + repeated ModelUUID uuids = 1; + + /* + Names of Points to subscribe to notifications for. + + @optional + */ + repeated string names = 2; + + /* + UUIDs of Endpoints whose associated Points should be subscribed to notifications for. + + @optional + */ + repeated ModelUUID endpoint_uuids = 3; +} + +/* + Description used to create or modify a Command. +*/ +message CommandTemplate { + + /* + Description of the Entity information for this Command. + + @required + @nested + */ + optional EntityTemplate entity_template = 1; + + /* + Specifies an explicative name that does not need to be globally unique. + + @required + */ + optional string display_name = 3; + + /* + Specifies whether CommandRequests associated with a Command contain a value, and what data type the value is. + + @required + */ + optional CommandCategory category = 7; +} + +/* + Query parameters to select a set of Commands. + + Commands can be matched by their Entity types or Command-specific fields. + Including no search parameters will return all Commands in the system. + + Uses paging to handle large result sets. +*/ +message CommandQuery { + + /* + Parameters to match Commands based on their Entity types. + + @optional + @nested + */ + optional EntityTypeParams type_params = 1; + + /* + Parameters to match Commands based on CommandCategory. Commands with one of any of the specified CommandCategorys will be matched. + + @optional + */ + repeated CommandCategory command_categories = 5; + + /* + Parameters to narrow the result sets for queries. + + @optional + @nested + */ + optional EntityPagingParams paging_params = 14; +} + +/* + Subscription parameters for Commands. Notifications can be filtered by key or by Endpoint associations. + + - Including key parameters (and no Endpoint UUIDs) will return the current state of those Commands + and subscribe to notifications for them. + - Including Endpoint UUIDs will subscribe to notifications for Commands associated with those Endpoints, + but return no immediate results. + - Including no parameters will subscribe to notifications for all Commands, but return no immediate results. + + __Note__: Command notifications are filtered by UUID, not name. Using a name parameter resolves + the UUID of the Command associated with that name at the time of the request, and is + equivalent to the client resolving the UUID before making the request. As a result, if the + Command a particular name refers to changes (due to a deletion/addition or two renames) notifications will be delivered + for only for the original Command. + + Endpoint UUIDs are _not_ resolved to a set of Commands at request time, and updates in the system model to associations between Commands + and Endpoints will affect what notifications are delivered to the subscription. +*/ +message CommandSubscriptionQuery { + + /* + UUIDs of Commands to subscribe to notifications for. + + @optional + */ + repeated ModelUUID uuids = 1; + + /* + Names of Commands to subscribe to notifications for. + + @optional + */ + repeated string names = 2; + + /* + UUIDs of Endpoints whose associated Commands should be subscribed to notifications for. + + @optional + */ + repeated ModelUUID endpoint_uuids = 3; +} + +/* + Description used to create or modify an Endpoint. +*/ +message EndpointTemplate { + + /* + Description of the Entity information for this Endpoint. + + @required + @nested + */ + optional EntityTemplate entity_template = 1; + + /* + Identifies the type of front-end that is intended to service the Points and Commands + associated with this Endpoint. + + @required + */ + optional string protocol = 3; + + /* + Specifies whether an Endpoint has been disabled, so that Measurements will not be published and Commands + will not be processed for it. + + The default value for new Endpoints is `false`. If not specified, modifications to existing Endpoints will not change the + disabled value. + + @optional + */ + optional bool disabled = 4; +} + +/* + Query parameters to select a set of Endpoints. + + Endpoints can be matched by their Entity types or Endpoint-specific fields. + Including no search parameters will return all Endpoints in the system. + + Uses paging to handle large result sets. +*/ +message EndpointQuery { + + /* + Parameters to match Endpoints based on their Entity types. + + @optional + @nested + */ + optional EntityTypeParams type_params = 1; + + /* + Parameters to match Endpoints based on protocol. Endpoints with one of any of the specified protocols will be matched. + + @optional + */ + repeated string protocols = 4; + + /* + Parameter to filter Endpoints by the state of their `disabled` field. If set to `true`, only matches Endpoints marked as disabled. + If set to `false`, only matches Endpoints not marked as disabled. + + @optional + */ + optional bool disabled = 5; + + /* + Parameters to narrow the result sets for queries. + + @optional + @nested + */ + optional EntityPagingParams paging_params = 14; +} + +/* + Subscription parameters for Endpoints. Notifications can be filtered by key or by protocol. + + - Including key parameters will return the current state of those Endpoints + and subscribe to notifications for them. + - Including protocol parameters will subscribe to notifications for Endpoints associated with those Endpoints, + but return no immediate results. + - Including no parameters will subscribe to notifications for all Endpoints, but return no immediate results. + + __Note__: Endpoint notifications are filtered by UUID, not name. Using a name parameter resolves + the UUID of the Endpoint associated with that name at the time of the request, and is + equivalent to the client resolving the UUID before making the request. As a result, if the + Endpoint a particular name refers to changes (due to a deletion/addition or two renames) notifications will be delivered + for only for the original Endpoint. +*/ +message EndpointSubscriptionQuery { + + /* + UUIDs of Endpoints to subscribe to notifications for. + + @optional + */ + repeated ModelUUID uuids = 1; + + /* + Names of Endpoints to subscribe to notifications for. + + @optional + */ + repeated string names = 2; + + /* + Parameters for subscribing to notifications by Endpoints' protocols. + + @optional + */ + repeated string protocols = 3; +} + +/* + Represents a modification to an Endpoint's enabled/disabled state. +*/ +message EndpointDisabledUpdate { + + /* + UUID of the Endpoint to be modified. + + @required + */ + optional ModelUUID endpoint_uuid = 1; + + /* + Whether the Endpoint's state should be disabled or not. + + - If `true`, the Endpoint will be disabled. + - If `false`, the Endpoint will be enabled. + + @required + */ + optional bool disabled = 2; +} + +/* + Represents the identify of an EntityKeyValue, the combination of the model object's UUID and the key. +*/ +message EntityKeyPair { + + /* + UUID of the model object the key-value is associated with. + + @required + */ + optional ModelUUID uuid = 1; + + /* + Key string for the key-value. + + @required + */ + optional string key = 2; +} + +/* + Subscription parameters for Endpoints. Notifications can be filtered by UUIDs, UUID/key pairs, or the UUIDs of the Endpoints associated + with the model objects. +*/ +message EntityKeyValueSubscriptionQuery { + + /* + UUIDs of EntityKeyValues to subscribe to notifications for. + + @optional + */ + repeated ModelUUID uuids = 1; + + /* + UUID and key combinations to subscribe to notifications for. + + @optional + */ + repeated EntityKeyPair key_pairs = 2; + + /* + UUIDs of Endpoints whose associated EntityKeyValues should be subscribed to notifications for. + + @optional + */ + repeated ModelUUID endpoint_uuids = 3; +} + +message GetEntitiesRequest { + optional EntityKeySet request = 1; +} +message GetEntitiesResponse { + repeated Entity results = 1; +} +message EntityQueryRequest { + optional EntityQuery query = 1; +} +message EntityQueryResponse { + repeated Entity results = 1; +} + +message SubscribeEntitiesRequest { + optional EntitySubscriptionQuery query = 1; +} +message SubscribeEntitiesResponse { + repeated Entity results = 1; +} + + +message EntityRelationshipFlatQueryRequest { + optional EntityRelationshipFlatQuery query = 1; +} +message EntityRelationshipFlatQueryResponse { + repeated Entity results = 1; +} + +message PutEntitiesRequest { + repeated EntityTemplate entities = 1; +} +message PutEntitiesResponse { + repeated Entity results = 1; +} +message DeleteEntitiesRequest { + repeated ModelUUID entity_uuids = 1; +} +message DeleteEntitiesResponse { + repeated Entity results = 1; +} + + +message GetEntityKeyValuesRequest { + repeated EntityKeyPair request = 1; +} +message GetEntityKeyValuesResponse { + repeated EntityKeyValue results = 1; +} + +message GetKeysForEntitiesRequest { + repeated ModelUUID request = 1; +} +message GetKeysForEntitiesResponse { + repeated EntityKeyPair results = 1; +} + +message PutEntityKeyValuesRequest { + repeated EntityKeyValue key_values = 1; +} +message PutEntityKeyValuesResponse { + repeated EntityKeyValue results = 1; +} + +message DeleteEntityKeyValuesRequest { + repeated EntityKeyPair request = 1; +} +message DeleteEntityKeyValuesResponse { + repeated EntityKeyValue results = 1; +} + +message SubscribeEntityKeyValuesRequest { + optional EntityKeyValueSubscriptionQuery query = 1; +} +message SubscribeEntityKeyValuesResponse { + repeated EntityKeyValue results = 1; +} + +message EntityEdgeQueryRequest { + optional EntityEdgeQuery query = 1; +} +message EntityEdgeQueryResponse { + repeated EntityEdge results = 1; +} + +message PutEntityEdgesRequest { + repeated EntityEdgeDescriptor descriptors = 1; +} +message PutEntityEdgesResponse { + repeated EntityEdge results = 1; +} +message DeleteEntityEdgesRequest { + repeated EntityEdgeDescriptor descriptors = 1; +} +message DeleteEntityEdgesResponse { + repeated EntityEdge results = 1; +} +message SubscribeEntityEdgesRequest { + optional EntityEdgeSubscriptionQuery query = 1; +} +message SubscribeEntityEdgesResponse { + repeated EntityEdge results = 1; +} + +message GetPointsRequest { + optional EntityKeySet request = 1; +} +message GetPointsResponse { + repeated Point results = 1; +} +message PointQueryRequest { + optional PointQuery request = 1; +} +message PointQueryResponse { + repeated Point results = 1; +} +message PutPointsRequest { + repeated PointTemplate templates = 1; +} +message PutPointsResponse { + repeated Point results = 1; +} +message DeletePointsRequest { + repeated ModelUUID point_uuids = 1; +} +message DeletePointsResponse { + repeated Point results = 1; +} +message SubscribePointsRequest { + optional PointSubscriptionQuery request = 1; +} +message SubscribePointsResponse { + repeated Point results = 1; +} + +message GetCommandsRequest { + optional EntityKeySet request = 1; +} +message GetCommandsResponse { + repeated Command results = 1; +} +message CommandQueryRequest { + optional CommandQuery request = 1; +} +message CommandQueryResponse { + repeated Command results = 1; +} +message PutCommandsRequest { + repeated CommandTemplate templates = 1; +} +message PutCommandsResponse { + repeated Command results = 1; +} +message DeleteCommandsRequest { + repeated ModelUUID command_uuids = 1; +} +message DeleteCommandsResponse { + repeated Command results = 1; +} +message SubscribeCommandsRequest { + optional CommandSubscriptionQuery request = 1; +} +message SubscribeCommandsResponse { + repeated Command results = 1; +} + +message GetEndpointsRequest { + optional EntityKeySet request = 1; +} +message GetEndpointsResponse { + repeated Endpoint results = 1; +} +message EndpointQueryRequest { + optional EndpointQuery request = 1; +} +message EndpointQueryResponse { + repeated Endpoint results = 1; +} +message PutEndpointsRequest { + repeated EndpointTemplate templates = 1; +} +message PutEndpointsResponse { + repeated Endpoint results = 1; +} +message DeleteEndpointsRequest { + repeated ModelUUID endpoint_uuids = 1; +} +message DeleteEndpointsResponse { + repeated Endpoint results = 1; +} +message SubscribeEndpointsRequest { + optional EndpointSubscriptionQuery request = 1; +} +message SubscribeEndpointsResponse { + repeated Endpoint results = 1; +} +message PutEndpointDisabledRequest { + repeated EndpointDisabledUpdate updates = 1; +} +message PutEndpointDisabledResponse { + repeated Endpoint results = 1; +} + +/* + + +*/ +service ModelService { + option (scala_package) = "io.greenbus.client.service"; + option (java_package) = "io.greenbus.japi.client.service"; + + /* + Returns a set of Entities identified specifically by UUID or name. + */ + rpc get(GetEntitiesRequest) returns (GetEntitiesResponse); + + /* + Returns all Entities or those matching query parameters. Uses paging to + handle large result sets. + */ + rpc entity_query(EntityQueryRequest) returns (EntityQueryResponse); + + /* + Subscribes to Entity notifications, and returns the current state of the Entities. + */ + rpc subscribe(SubscribeEntitiesRequest) returns (SubscribeEntitiesResponse) { + option (subscription_type) = "greenbus.client.EntityNotification"; + } + + /* + Uses the graph model to return a set of Entities specified by their relationships. + */ + rpc relationship_flat_query(EntityRelationshipFlatQueryRequest) returns (EntityRelationshipFlatQueryResponse); + + /* + Creates or modifies a set of Entities. + + May not be used to modify Entities that have a more specific service type (Agent, Point, etc.). See the associated + service for those types. + */ + rpc put(PutEntitiesRequest) returns (PutEntitiesResponse); + + /* + Deletes a set of Entities specified by UUID. Returns the final state of the deleted Entities. + */ + rpc delete(DeleteEntitiesRequest) returns (DeleteEntitiesResponse); + + /* + Returns a set of EntityEdges that represents some subset of the graph model. + */ + rpc edge_query(EntityEdgeQueryRequest) returns (EntityEdgeQueryResponse); + + /* + Creates a set of EntityEdges. EntityEdges are value types and cannot be modified, only deleted. + Putting already existing EntityEdges will succeed with no effect. + + Only immediate edges (`distance = 1`) can be added by clients. Derived edges are maintained + automatically. + */ + rpc put_edges(PutEntityEdgesRequest) returns (PutEntityEdgesResponse); + + /* + Deletes a set of EntityEdges. + + Only immediate edges (`distance = 1`) can be removed by clients. Derived edges are removed + automatically. + */ + rpc delete_edges(DeleteEntityEdgesRequest) returns (DeleteEntityEdgesResponse); + + /* + Subscribes to notifications for a set of EntityEdges. + + Because the result set cannot be constrained, no current values of EntityEdges are returned. + */ + rpc subscribe_to_edges(SubscribeEntityEdgesRequest) returns (SubscribeEntityEdgesResponse) { + option (subscription_type) = "greenbus.client.EntityEdgeNotification"; + } + + + /* + Returns a set of Points identified specifically by UUID or name. + */ + rpc get_points(GetPointsRequest) returns (GetPointsResponse); + + /* + Returns all Points or those matching query parameters. Uses paging to + handle large result sets. + */ + rpc point_query(PointQueryRequest) returns (PointQueryResponse); + + /* + Creates or modifies a set of Points. + */ + rpc put_points(PutPointsRequest) returns (PutPointsResponse); + + /* + Deletes a set of Points identified by UUID. Returns the final state of the deleted Points. + */ + rpc delete_points(DeletePointsRequest) returns (DeletePointsResponse); + + /* + Subscribes to Point notifications, and returns the current state of the Points. + */ + rpc subscribe_to_points(SubscribePointsRequest) returns (SubscribePointsResponse) { + option (subscription_type) = "greenbus.client.PointNotification"; + } + + + /* + Returns a set of Commands identified specifically by UUID or name. + */ + rpc get_commands(GetCommandsRequest) returns (GetCommandsResponse); + + /* + Returns all Commands or those matching query parameters. Uses paging to + handle large result sets. + */ + rpc command_query(CommandQueryRequest) returns (CommandQueryResponse); + + /* + Creates or modifies a set of Commands. + */ + rpc put_commands(PutCommandsRequest) returns (PutCommandsResponse); + + /* + Deletes a set of Commands identified by UUID. Returns the final state of the deleted Commands. + */ + rpc delete_commands(DeleteCommandsRequest) returns (DeleteCommandsResponse); + + /* + Subscribes to Command notifications, and returns the current state of the Commands. + */ + rpc subscribe_to_commands(SubscribeCommandsRequest) returns (SubscribeCommandsResponse) { + option (subscription_type) = "greenbus.client.CommandNotification"; + } + + + /* + Returns a set of Endpoints identified specifically by UUID or name. + */ + rpc get_endpoints(GetEndpointsRequest) returns (GetEndpointsResponse); + + /* + Returns all Endpoints or those matching query parameters. Uses paging to + handle large result sets. + */ + rpc endpoint_query(EndpointQueryRequest) returns (EndpointQueryResponse); + + /* + Creates or modifies a set of Endpoints. + */ + rpc put_endpoints(PutEndpointsRequest) returns (PutEndpointsResponse); + + /* + Deletes a set of Endpoints identified by UUID. Returns the final state of the deleted Endpoints. + */ + rpc delete_endpoints(DeleteEndpointsRequest) returns (DeleteEndpointsResponse); + + /* + Subscribes to Endpoint notifications, and returns the current state of the Endpoints. + */ + rpc subscribe_to_endpoints(SubscribeEndpointsRequest) returns (SubscribeEndpointsResponse) { + option (subscription_type) = "greenbus.client.EndpointNotification"; + } + + /* + Sets whether a set of Endpoints are enabled or disabled. + */ + rpc put_endpoint_disabled(PutEndpointDisabledRequest) returns (PutEndpointDisabledResponse); + + /* + Returns a set of Entity key values identified by UUID and key. + */ + rpc get_entity_key_values(GetEntityKeyValuesRequest) returns (GetEntityKeyValuesResponse); + + /* + Returns a set of Entity keys associated with the specified UUIDs. + */ + rpc get_entity_keys(GetKeysForEntitiesRequest) returns (GetKeysForEntitiesResponse); + + /* + Creates or modifies a set of Entity key values. + */ + rpc put_entity_key_values(PutEntityKeyValuesRequest) returns (PutEntityKeyValuesResponse); + + /* + Deletes a set of Entity key values identified by UUID and key. Returns the final state of the deleted values. + */ + rpc delete_entity_key_values(DeleteEntityKeyValuesRequest) returns (DeleteEntityKeyValuesResponse); + + /* + Subscribes to Entity key value notifications, and returns the current state of the Entity key values. + */ + rpc subscribe_to_entity_key_values(SubscribeEntityKeyValuesRequest) returns (SubscribeEntityKeyValuesResponse) { + option (subscription_type) = "greenbus.client.EntityKeyValueNotification"; + } + +} diff --git a/client/src/main/proto/services/ProcessingRequests.proto b/client/src/main/proto/services/ProcessingRequests.proto new file mode 100644 index 0000000..6f946cf --- /dev/null +++ b/client/src/main/proto/services/ProcessingRequests.proto @@ -0,0 +1,142 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package greenbus.client; + +option java_package = "io.greenbus.client.service.proto"; +option java_outer_classname = "ProcessingRequests"; +option java_generic_services = false; + +import "google/protobuf/descriptor.proto"; +import "CompilerExtensions.proto"; + +import "Processing.proto"; +import "Model.proto"; +import "ServiceEnvelopes.proto"; +import "services/ModelRequests.proto"; + +/* + Subscription parameters for MeasOverrides. Notifications can be filtered by Point or Endpoint. + + - Including key parameters (and no Endpoint UUIDs) will return the current state of the MeasOverrides for those Points + and subscribe to notifications for them. + - Including Endpoint UUIDs will subscribe to MeasOverride notifications for Points associated with those Endpoints, + but return no immediate results. + - Including no parameters will subscribe to MeasOverride notifications for all Points, but return no immediate results. + + __Note__: MeasOverride notifications are filtered by Point UUID, not Point name. Using a name parameter resolves + the UUID of the Point associated with that name at the time of the request, and is + equivalent to the client resolving the UUID before making the request. As a result, if the + Point a particular name refers to changes (due to a deletion/addition or two renames) notifications will be delivered + for only for the original Point. + + Endpoint UUIDs are _not_ resolved to a set of Points at request time, and updates in the system model to associations between Points + and Endpoints will affect what notifications are delivered to the subscription. +*/ +message OverrideSubscriptionQuery { + + /* + UUIDs of Points to subscribe to MeasOverrides for. + + @optional + @optional + */ + repeated ModelUUID point_uuids = 1; + + /* + Names of Points to subscribe to MeasOverrides for. + + @optional + */ + repeated string point_names = 2; + + + /* + UUIDs of Endpoints to subscribe to MeasOverrides for. + + @optional + */ + repeated ModelUUID endpoint_uuids = 3; +} + + +message GetOverridesRequest { + optional EntityKeySet request = 1; +} +message GetOverridesResponse { + repeated MeasOverride results = 1; +} +message SubscribeOverridesRequest { + optional OverrideSubscriptionQuery query = 1; +} +message SubscribeOverridesResponse { + repeated MeasOverride results = 1; +} +message PutOverridesRequest { + repeated MeasOverride overrides = 1; +} +message PutOverridesResponse { + repeated MeasOverride results = 1; +} +message DeleteOverridesRequest { + repeated ModelUUID point_uuids = 1; +} +message DeleteOverridesResponse { + repeated MeasOverride results = 1; +} + + +/* + Service for configuring the behavior of the measurement processor. + + MeasOverride objects provide the ability to block new Measurements from being published, and to optionally + replace the latest value provided by the front-end with a user-specified override value. Blocked (not in service) + Measurements will not be stored in the current value and historian stores, and will not be published to the + Measurement notification stream. + + TriggerSet objects describe the transformations and event generation to be performed on Measurements in the + measurement processor before they are published to the stores and notification stream. +*/ +service ProcessingService { + option (scala_package) = "io.greenbus.client.service"; + option (java_package) = "io.greenbus.japi.client.service"; + + /* + Returns a set of MeasOverrides associated with Points specified by an EntityKeySet. + */ + rpc get_overrides(GetOverridesRequest) returns (GetOverridesResponse); + + /* + Creates or modifies the MeasOverrides associated with Points. + + Only one MeasOverride can exist for any given Point. Putting a new value will replace a previous one. + */ + rpc put_overrides(PutOverridesRequest) returns (PutOverridesResponse); + + /* + Delete MeasOverrides associated with Points by specifying the Point UUIDs. + */ + rpc delete_overrides(DeleteOverridesRequest) returns (DeleteOverridesResponse); + + /* + Subscribes to notifications for changes to MeasOverrides by either Point or Endpoint. + */ + rpc subscribe_to_overrides(SubscribeOverridesRequest) returns (SubscribeOverridesResponse) { + option (subscription_type) = "greenbus.client.OverrideNotification"; + } +} diff --git a/client/src/main/resources/Version.scala.template b/client/src/main/resources/Version.scala.template new file mode 100755 index 0000000..615beca --- /dev/null +++ b/client/src/main/resources/Version.scala.template @@ -0,0 +1,31 @@ +/** + * Copyright 2011 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.client.version + +object Version { + + def projectVersion = "PROJECT_VERSION" + + def gitCommitId = "GIT_COMMIT_ID" + + def isSnapshot = projectVersion.contains("SNAPSHOT") + + def clientVersion = if(isSnapshot) projectVersion + "-" + gitCommitId.slice(0,8) else projectVersion + +} diff --git a/client/src/main/scala/io/greenbus/client/ServiceConnection.scala b/client/src/main/scala/io/greenbus/client/ServiceConnection.scala new file mode 100644 index 0000000..bdfaee8 --- /dev/null +++ b/client/src/main/scala/io/greenbus/client/ServiceConnection.scala @@ -0,0 +1,98 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.client + +import io.greenbus.msg.amqp._ +import io.greenbus.msg.Session +import scala.concurrent.{ ExecutionContext, Await, Future } +import java.util.concurrent.{ ExecutorService, Executors } +import io.greenbus.msg.util.Scheduler +import scala.concurrent.duration.Duration +import io.greenbus.client.service.proto.LoginRequests.LoginRequest +import io.greenbus.client.service.LoginService + +object ServiceHeaders { + def tokenHeader() = "AUTH_TOKEN" +} + +object ServiceConnection { + + def connect(settings: AmqpSettings, broker: AmqpBroker, timeoutMs: Long): ServiceConnection = { + val exe = Executors.newScheduledThreadPool(5) + val factory = new AmqpConnectionFactory(settings, broker, timeoutMs, Scheduler(exe)) + new DefaultConnection(factory.connect, exe) + } + + private class DefaultConnection(conn: AmqpConnection, exe: ExecutorService) extends ServiceConnection { + + def login(user: String, password: String): Future[Session] = { + import ExecutionContext.Implicits.global + val sess = session + val loginClient = LoginService.client(sess) + + val loginRequest = LoginRequest.newBuilder() + .setName(user) + .setPassword(password) + .setLoginLocation("") + .setClientVersion(version.Version.clientVersion) + .build() + + loginClient.login(loginRequest).map { result => + sess.addHeader(ServiceHeaders.tokenHeader(), result.getToken) + sess + } + } + def login(user: String, password: String, timeout: Duration): Session = { + Await.result(login(user, password), timeout) + } + + def session: Session = conn.createSession(ServiceMessagingCodec.codec) + + def serviceOperations: AmqpServiceOperations = conn.serviceOperations + + def disconnect() { + conn.disconnect() + exe.shutdown() + } + + def addConnectionListener(listener: (Boolean) => Unit) { + conn.addConnectionListener(listener) + } + + def removeConnectionListener(listener: (Boolean) => Unit) { + conn.removeConnectionListener(listener) + } + } + +} + +trait ServiceConnection { + + def addConnectionListener(listener: Boolean => Unit) + def removeConnectionListener(listener: Boolean => Unit) + + def login(user: String, password: String): Future[Session] + def login(user: String, password: String, duration: Duration): Session + + def session: Session + + def serviceOperations: AmqpServiceOperations + + def disconnect() +} diff --git a/client/src/main/scala/io/greenbus/client/ServiceMessagingCodec.scala b/client/src/main/scala/io/greenbus/client/ServiceMessagingCodec.scala new file mode 100644 index 0000000..559d406 --- /dev/null +++ b/client/src/main/scala/io/greenbus/client/ServiceMessagingCodec.scala @@ -0,0 +1,77 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.client + +import com.google.protobuf.ByteString +import scala.util.{ Success, Failure, Try } +import io.greenbus.msg.RequestMessagingCodec +import io.greenbus.client.proto.Envelope.{ ServiceResponse, RequestHeader, ServiceRequest } +import io.greenbus.client.exception.{ RequestException, MalformedResponseException, StatusCodes } + +object ServiceMessagingCodec { + + def codec: RequestMessagingCodec = FullCodec + + private object FullCodec extends RequestMessagingCodec { + + private def requestBuilder(requestId: String, headers: Map[String, String], payload: Array[Byte]): ServiceRequest.Builder = { + val builder = ServiceRequest.newBuilder + .setPayload(ByteString.copyFrom(payload)) + + headers.foreach { case (key, v) => builder.addHeaders(RequestHeader.newBuilder.setKey(key).setValue(v)) } + + builder + } + + def encode(requestId: String, headers: Map[String, String], payload: Array[Byte]): Array[Byte] = { + val requestEnv = requestBuilder(requestId, headers, payload) + requestEnv.build().toByteArray + } + + def encodeSubscription(requestId: String, headers: Map[String, String], payload: Array[Byte], subscriptionId: String): Array[Byte] = { + val requestEnv = requestBuilder(requestId, headers, payload) + requestEnv.addHeaders( + RequestHeader.newBuilder + .setKey("__subscription") + .setValue(subscriptionId)) + + requestEnv.build().toByteArray + } + + def decodeAndProcess(bytes: Array[Byte]): Try[Array[Byte]] = { + try { + val response = ServiceResponse.parseFrom(bytes) + + if (!response.hasStatus) { + Failure(new MalformedResponseException("Service response did not include status")) + } else { + if (StatusCodes.isSuccess(response.getStatus)) { + Success(response.getPayload.toByteArray) + } else { + Failure(StatusCodes.toException(response.getStatus, response.getErrorMessage)) + } + } + } catch { + case ex: Throwable => + Failure(new RequestException("Couldn't parse response envelope", ex)) + } + } + + } +} diff --git a/client/src/main/scala/io/greenbus/client/exception/ServiceException.scala b/client/src/main/scala/io/greenbus/client/exception/ServiceException.scala new file mode 100644 index 0000000..5f6e106 --- /dev/null +++ b/client/src/main/scala/io/greenbus/client/exception/ServiceException.scala @@ -0,0 +1,80 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.client.exception + +import io.greenbus.client.proto.Envelope + +class RequestException(message: String, cause: Throwable) extends Exception(message, cause) { + def this(message: String) { + this(message, null) + } +} + +class MalformedResponseException(message: String) extends RequestException(message) + +class ServiceException(message: String, status: Envelope.Status, cause: Throwable) extends RequestException(message, cause) { + def this(message: String, status: Envelope.Status) = { + this(message, status, null) + } + + def getStatus: Envelope.Status = status +} + +class ReplyException(message: String, status: Envelope.Status, cause: Throwable) extends ServiceException(message, status, cause) { + def this(message: String, status: Envelope.Status) = { + this(message, status, null) + } +} + +class BadRequestException(message: String) extends ReplyException(message, Envelope.Status.BAD_REQUEST) + +class InternalServiceException(message: String) extends ServiceException(message, Envelope.Status.INTERNAL_ERROR) + +class UnauthorizedException(message: String) extends ServiceException(message, Envelope.Status.UNAUTHORIZED) + +class ForbiddenException(message: String) extends ServiceException(message, Envelope.Status.FORBIDDEN) + +class LockedException(message: String) extends ServiceException(message, Envelope.Status.LOCKED) + +class BusUnavailableException(message: String) extends ServiceException(message, Envelope.Status.BUS_UNAVAILABLE) + +object StatusCodes { + + def isSuccess(status: Envelope.Status): Boolean = { + status match { + case Envelope.Status.OK => true + case Envelope.Status.CREATED => true + case Envelope.Status.UPDATED => true + case Envelope.Status.DELETED => true + case Envelope.Status.NOT_MODIFIED => true + case _ => false + } + } + + def toException(status: Envelope.Status, error: String): ServiceException = status match { + case Envelope.Status.BAD_REQUEST => new BadRequestException(error) + case Envelope.Status.UNAUTHORIZED => new UnauthorizedException(error) + case Envelope.Status.FORBIDDEN => new ForbiddenException(error) + case Envelope.Status.INTERNAL_ERROR => new InternalServiceException(error) + case Envelope.Status.LOCKED => new LockedException(error) + case Envelope.Status.BUS_UNAVAILABLE => new BusUnavailableException(error) + case _ => new ServiceException(error, status) + } + +} \ No newline at end of file diff --git a/init_postgres.sql b/init_postgres.sql new file mode 100644 index 0000000..9e35db3 --- /dev/null +++ b/init_postgres.sql @@ -0,0 +1,6 @@ +CREATE USER core WITH PASSWORD 'core'; +CREATE DATABASE greenbus3_d; +CREATE DATABASE greenbus3_t; +GRANT ALL PRIVILEGES ON DATABASE greenbus3_d TO core; +GRANT ALL PRIVILEGES ON DATABASE greenbus3_t TO core; + diff --git a/integration/pom.xml b/integration/pom.xml new file mode 100755 index 0000000..bdc1719 --- /dev/null +++ b/integration/pom.xml @@ -0,0 +1,81 @@ + + 4.0.0 + + + io.greenbus + greenbus-scala-base + 3.0.0 + ../scala-base + + + greenbus-integration + jar + + + + AGPLv3 + http://www.gnu.org/licenses/agpl-3.0.txt + + + + + + + + com.mycila.maven-license-plugin + maven-license-plugin + +
../AGPL_FILE_HEADER
+
+
+ + org.apache.maven.plugins + maven-surefire-plugin + + ../ + + +
+
+ + + + io.greenbus + greenbus-services + 3.0.0 + + + io.greenbus + greenbus-processing + 3.0.0 + + + io.greenbus + greenbus-loader-xml + 3.0.0 + + + io.greenbus + greenbus-client + 3.0.0 + + + io.greenbus + greenbus-util + 3.0.0 + + + io.greenbus.msg + greenbus-msg-qpid + 1.0.0 + + + com.typesafe.akka + akka-actor_2.10 + 2.2.0 + + + + +
diff --git a/integration/src/main/scala/io/greenbus/integration/tools/EventWatcher.scala b/integration/src/main/scala/io/greenbus/integration/tools/EventWatcher.scala new file mode 100644 index 0000000..197030f --- /dev/null +++ b/integration/src/main/scala/io/greenbus/integration/tools/EventWatcher.scala @@ -0,0 +1,61 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.integration.tools + +import scala.concurrent.{ Future, Promise } + +class EventWatcher[A] { + private val mutex = new Object + private var updates = List.empty[A] + private var check = Option.empty[(List[A] => Boolean, Promise[List[A]])] + + def reset() { + mutex.synchronized { + updates = Nil + } + } + + def future(f: List[A] => Boolean): Future[List[A]] = { + mutex.synchronized { + val promise = Promise[List[A]]() + check = Some((f, promise)) + promise.future + } + } + + def currentUpdates(): List[A] = { + mutex.synchronized { + updates.reverse + } + } + + def enqueue(a: A) { + mutex.synchronized { + updates ::= a + check.foreach { + case (fun, promise) => + val reversed = updates.reverse + if (fun(reversed)) { + check = None + promise.success(reversed) + } + } + } + } +} diff --git a/integration/src/main/scala/io/greenbus/integration/tools/PollingUtils.scala b/integration/src/main/scala/io/greenbus/integration/tools/PollingUtils.scala new file mode 100644 index 0000000..1aa3f56 --- /dev/null +++ b/integration/src/main/scala/io/greenbus/integration/tools/PollingUtils.scala @@ -0,0 +1,66 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.integration.tools + +import scala.annotation.tailrec +import java.util.concurrent.TimeoutException +import com.typesafe.scalalogging.slf4j.Logging + +object PollingUtils extends Logging { + + def pollForSuccess[A](delayMs: Long, timeoutMs: Long)(f: => A): A = { + pollForSuccessWithIter(delayMs, timeoutMs) { _ => f } + } + + def pollForSuccessWithIter[A](delayMs: Long, timeoutMs: Long)(f: Int => A): A = { + val start = System.currentTimeMillis() + + @tailrec + def poll(i: Int): A = { + val now = System.currentTimeMillis() + if ((now - start) < timeoutMs) { + + val resultOpt = try { + logger.info("Polling") + Some(f(i)) + } catch { + case ex: Throwable => + logger.info("Missed poll: " + ex) + None + } + + resultOpt match { + case Some(result) => result + case None => + val elapsedInPoll = System.currentTimeMillis() - now + if (elapsedInPoll >= delayMs) { + poll(i + 1) + } else { + Thread.sleep(delayMs - elapsedInPoll) + poll(i + 1) + } + } + } else { + throw new TimeoutException("Failed operation within time limit") + } + } + + poll(1) + } +} diff --git a/integration/src/main/scala/io/greenbus/integration/tools/ServiceWatcher.scala b/integration/src/main/scala/io/greenbus/integration/tools/ServiceWatcher.scala new file mode 100644 index 0000000..c194ff8 --- /dev/null +++ b/integration/src/main/scala/io/greenbus/integration/tools/ServiceWatcher.scala @@ -0,0 +1,80 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.integration.tools + +import io.greenbus.msg.{ Subscription, Session } +import io.greenbus.client.service.proto.FrontEnd.{ FrontEndConnectionStatusNotification, FrontEndConnectionStatus } +import io.greenbus.client.service.proto.Model.ModelUUID +import io.greenbus.client.service.{ FrontEndService, MeasurementService, ModelService } +import io.greenbus.client.service.proto.ModelRequests.EntityRelationshipFlatQuery +import scala.concurrent.Await +import scala.concurrent.duration._ +import io.greenbus.client.service.proto.Measurements.{ MeasurementNotification, PointMeasurementValue } +import io.greenbus.client.service.proto.ModelRequests.EndpointSubscriptionQuery + +object ServiceWatcher { + def getOwnedPoints(session: Session, owner: String): Seq[ModelUUID] = { + val modelClient = ModelService.client(session) + + val relationQuery = + EntityRelationshipFlatQuery.newBuilder() + .addStartNames(owner) + .setDescendantOf(true) + .setRelationship("owns") + .addEndTypes("Point") + .build() + + val pointEntities = Await.result(modelClient.relationshipFlatQuery(relationQuery), 5000.milliseconds) + + pointEntities.map(_.getUuid) + } + + def measurementWatcherForOwner(session: Session, owner: String): (Seq[PointMeasurementValue], ServiceWatcher[MeasurementNotification]) = { + val pointUuids = getOwnedPoints(session, owner) + val measClient = MeasurementService.client(session) + + val subFut = measClient.getCurrentValuesAndSubscribe(pointUuids) + + val (originals, sub) = Await.result(subFut, 5000.milliseconds) + + (originals, new ServiceWatcher(sub)) + } + + def statusWatcher(session: Session, endpointName: String): (Seq[FrontEndConnectionStatus], ServiceWatcher[FrontEndConnectionStatusNotification]) = { + val client = FrontEndService.client(session) + + val (current, sub) = Await.result(client.subscribeToFrontEndConnectionStatuses(EndpointSubscriptionQuery.newBuilder().addNames(endpointName).build()), 5000.milliseconds) + + (current, new ServiceWatcher(sub)) + } + +} + +class ServiceWatcher[A](subscription: Subscription[A]) { + + val watcher = new EventWatcher[A] + + subscription.start { subNot => watcher.enqueue(subNot) } + + def cancel() { + subscription.cancel() + } + +} + diff --git a/integration/src/test/scala/io/greenbus/integration/FailoverTests.scala b/integration/src/test/scala/io/greenbus/integration/FailoverTests.scala new file mode 100644 index 0000000..e63f1c7 --- /dev/null +++ b/integration/src/test/scala/io/greenbus/integration/FailoverTests.scala @@ -0,0 +1,352 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.integration + +import java.util.concurrent.TimeoutException + +import akka.actor.{ ActorRef, ActorSystem, PoisonPill } +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.client.ServiceConnection +import io.greenbus.client.exception.LockedException +import io.greenbus.client.service.proto.FrontEnd.FrontEndConnectionStatus +import io.greenbus.client.service.proto.FrontEndRequests.{ FrontEndRegistrationTemplate, FrontEndStatusUpdate } +import io.greenbus.client.service.proto.Measurements.Quality +import io.greenbus.client.service.proto.Model.{ Endpoint, ModelUUID } +import io.greenbus.client.service.proto.ModelRequests.{ EndpointQuery, EntityKeySet } +import io.greenbus.client.service.{ EventService, FrontEndService, MeasurementService, ModelService } +import io.greenbus.integration.IntegrationConfig._ +import io.greenbus.integration.tools.PollingUtils._ +import io.greenbus.measproc.MeasurementProcessor +import io.greenbus.msg.Session +import io.greenbus.msg.amqp.AmqpSettings +import io.greenbus.msg.qpid.QpidBroker +import io.greenbus.services.{ ServiceManager, CoreServices, ResetDatabase } +import io.greenbus.util.UserSettings +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner +import org.scalatest.matchers.ShouldMatchers +import org.scalatest.{ BeforeAndAfterAll, FunSuite } + +import scala.concurrent.Await +import scala.concurrent.duration._ + +@RunWith(classOf[JUnitRunner]) +class FailoverTests extends FunSuite with ShouldMatchers with Logging with BeforeAndAfterAll { + import IntegrationHelpers._ + + val testConfigPath = "io.greenbus.test.cfg" + val system = ActorSystem("failoverTest") + var services = Option.empty[ActorRef] + var processor = Option.empty[ActorRef] + var processor2 = Option.empty[ActorRef] + var conn = Option.empty[ServiceConnection] + var session = Option.empty[Session] + + var pointAUuid = Option.empty[ModelUUID] + var endpoint1 = Option.empty[Endpoint] + var endpoint1Address1 = Option.empty[String] + var endpoint1Address2 = Option.empty[String] + var endpoint1Address3 = Option.empty[String] + + override protected def beforeAll(): Unit = { + ResetDatabase.reset(testConfigPath) + + logger.info("starting services") + services = Some(system.actorOf(ServiceManager.props(testConfigPath, testConfigPath, CoreServices.runServices))) + + val amqpConfig = AmqpSettings.load(testConfigPath) + val conn = ServiceConnection.connect(amqpConfig, QpidBroker, 5000) + this.conn = Some(conn) + + val userConfig = UserSettings.load(testConfigPath) + + val session = pollForSuccess(500, 5000) { + Await.result(conn.login(userConfig.user, userConfig.password), 500.milliseconds) + } + + this.session = Some(session) + + IntegrationConfig.loadActions(buildConfig("Set1"), session) + + logger.info("starting processor") + processor = Some(system.actorOf(MeasurementProcessor.buildProcessor(testConfigPath, testConfigPath, testConfigPath, 1000, "testNode", + standbyLockRetryPeriodMs = 500, + standbyLockExpiryDurationMs = 1000))) + + val eventClient = EventService.client(session) + + Await.result(eventClient.putEventConfigs(eventConfigs()), 5000.milliseconds) + } + + override protected def afterAll(): Unit = { + system.shutdown() + system.awaitTermination() + this.conn.get.disconnect() + } + + test("registration") { + + val session = this.session.get + + val modelClient = ModelService.client(session) + val frontEndClient = FrontEndService.client(session) + + val points = Await.result(modelClient.getPoints(EntityKeySet.newBuilder().addNames("Set1PointA").addNames("Set1PointB").build()), 5000.milliseconds) + points.size should equal(2) + val pointA = points.find(_.getName == "Set1PointA").get + pointAUuid = Some(pointA.getUuid) + val pointB = points.find(_.getName == "Set1PointB").get + + val endpoints = Await.result(modelClient.endpointQuery(EndpointQuery.newBuilder().addProtocols("test").build()), 5000.milliseconds) + + endpoints.size should equal(1) + val endpoint = endpoints.head + + // let the measproc do its config, just a chance to avoid 2s wasted in the next step + Thread.sleep(500) + + val reg = FrontEndRegistrationTemplate.newBuilder() + .setEndpointUuid(endpoint.getUuid) + .build() + + val registration = retryFutureUntilSuccess(3, 2000) { + frontEndClient.putFrontEndRegistration(reg, endpoint.getUuid.getValue) + } + + // FIRST MEASPROC IS UP, START SECOND + processor2 = Some(system.actorOf(MeasurementProcessor.buildProcessor(testConfigPath, testConfigPath, testConfigPath, 1000, "testNode2", + standbyLockRetryPeriodMs = 500, + standbyLockExpiryDurationMs = 1000))) + // -- CONTINUE + + registration.getEndpointUuid should equal(endpoint.getUuid) + + val address = registration.getInputAddress + this.endpoint1 = Some(endpoint) + this.endpoint1Address1 = Some(address) + + val measClient = MeasurementService.client(session) + + val original = Await.result(measClient.getCurrentValues(List(pointA.getUuid)), 5000.milliseconds) + original.size should equal(0) + + val postTime = System.currentTimeMillis() + postAndGet(session, pointA.getUuid, analogBatch(pointA.getUuid, 1.45, postTime), address) + } + + test("second registration fails") { + val session = this.session.get + val endpointUuid = this.endpoint1.get.getUuid + + val frontEndClient = FrontEndService.client(session) + + val reg = FrontEndRegistrationTemplate.newBuilder() + .setEndpointUuid(endpointUuid) + .setFepNodeId("node2") + .build() + + val regFut = frontEndClient.putFrontEndRegistration(reg, endpointUuid.getValue) + + intercept[LockedException] { + Await.result(regFut, 5000.milliseconds) + } + } + + private def checkMarkedOffline(v: Double): Unit = { + checkMarked(v, Quality.Validity.INVALID) + } + private def checkMarkedOnline(v: Double): Unit = { + checkMarked(v, Quality.Validity.GOOD) + } + + private def checkMarked(v: Double, q: Quality.Validity): Unit = { + val session = this.session.get + val measClient = MeasurementService.client(session) + + val markedOffline = Await.result(measClient.getCurrentValues(List(pointAUuid.get)), 5000.milliseconds) + markedOffline.size should equal(1) + checkAnalog(markedOffline.head.getValue, v, None, q) + } + + test("meas marked offline") { + val session = this.session.get + val pointAUuid = this.pointAUuid.get + + val measClient = MeasurementService.client(session) + + retryUntilSuccess(10, 500) { + checkMarkedOffline(1.45) + } + } + + test("meas publish brings endpoint back online") { + val session = this.session.get + val pointAUuid = this.pointAUuid.get + val endpoint1Address = this.endpoint1Address1.get + + val postTime = System.currentTimeMillis() + postAndGet(session, pointAUuid, analogBatch(pointAUuid, 1.8, postTime), endpoint1Address) + } + + test("go down again; status publish brings back online") { + val session = this.session.get + val endpointUuid = this.endpoint1.get.getUuid + val endpoint1Address = this.endpoint1Address1.get + + val frontEndClient = FrontEndService.client(session) + + retryUntilSuccess(10, 500) { + checkMarkedOffline(1.8) + } + + val status = FrontEndStatusUpdate.newBuilder().setEndpointUuid(endpointUuid).setStatus(FrontEndConnectionStatus.Status.COMMS_UP).build() + val updateFut = frontEndClient.putFrontEndConnectionStatuses(Seq(status), endpoint1Address) + Await.result(updateFut, 5000.milliseconds) + + checkMarkedOnline(1.8) + } + + test("second endpoint logs in") { + val session = this.session.get + val endpointUuid = this.endpoint1.get.getUuid + + val measClient = MeasurementService.client(session) + val frontEndClient = FrontEndService.client(session) + + retryUntilSuccess(10, 500) { + checkMarkedOffline(1.8) + } + + val reg = FrontEndRegistrationTemplate.newBuilder() + .setEndpointUuid(endpointUuid) + .setFepNodeId("node2") + .build() + + val regFut = frontEndClient.putFrontEndRegistration(reg, endpointUuid.getValue) + + val registration = Await.result(regFut, 5000.milliseconds) + + registration.getEndpointUuid should equal(endpointUuid) + + val address = registration.getInputAddress + this.endpoint1Address2 = Some(address) + } + + test("original address is dead, can't re-register") { + val session = this.session.get + val endpointUuid = this.endpoint1.get.getUuid + val frontEndClient = FrontEndService.client(session) + val measClient = MeasurementService.client(session) + + val status = FrontEndStatusUpdate.newBuilder().setEndpointUuid(endpointUuid).setStatus(FrontEndConnectionStatus.Status.COMMS_UP).build() + val updateFut = frontEndClient.putFrontEndConnectionStatuses(Seq(status), endpoint1Address1.get) + + intercept[TimeoutException] { + Await.result(updateFut, 200.milliseconds) + } + + val postTime = System.currentTimeMillis() + val measFut = measClient.postMeasurements(analogBatch(this.pointAUuid.get, 1.85, postTime), endpoint1Address1.get) + intercept[TimeoutException] { + Await.result(measFut, 200.milliseconds) + } + + val reg = FrontEndRegistrationTemplate.newBuilder() + .setEndpointUuid(endpointUuid) + .build() + + val regFut = frontEndClient.putFrontEndRegistration(reg, endpointUuid.getValue) + + intercept[LockedException] { + Await.result(regFut, 5000.milliseconds) + } + } + + test("id'ed re-registration succeeds immediately") { + val session = this.session.get + val endpointUuid = this.endpoint1.get.getUuid + + // keep alive + val postTime = System.currentTimeMillis() + postAndGet(session, this.pointAUuid.get, analogBatch(this.pointAUuid.get, 1.2, postTime), this.endpoint1Address2.get) + + val frontEndClient = FrontEndService.client(session) + + val reg = FrontEndRegistrationTemplate.newBuilder() + .setEndpointUuid(endpointUuid) + .setFepNodeId("node2") + .build() + + val regFut = frontEndClient.putFrontEndRegistration(reg, endpointUuid.getValue) + + val registration = Await.result(regFut, 5000.milliseconds) + + registration.getEndpointUuid should equal(endpointUuid) + + postAndGet(session, this.pointAUuid.get, analogBatch(this.pointAUuid.get, 1.25, postTime), registration.getInputAddress) + this.endpoint1Address2 = Some(registration.getInputAddress) + } + + test("second measproc") { + val session = this.session.get + val endpointUuid = this.endpoint1.get.getUuid + val frontEndClient = FrontEndService.client(session) + + this.processor.get ! PoisonPill + + var i = 0 + var timedOut = false + while (i < 10 && !timedOut) { + + val status = FrontEndStatusUpdate.newBuilder().setEndpointUuid(endpointUuid).setStatus(FrontEndConnectionStatus.Status.COMMS_UP).build() + val updateFut = frontEndClient.putFrontEndConnectionStatuses(Seq(status), this.endpoint1Address2.get) + + try { + Await.result(updateFut, 300.milliseconds) + Thread.sleep(1000) + i += 1 + } catch { + case ex: TimeoutException => + timedOut = true + } + } + + timedOut should equal(true) + + val reg = FrontEndRegistrationTemplate.newBuilder() + .setEndpointUuid(endpointUuid) + .setFepNodeId("node2") + .build() + + val registration = retryFutureUntilSuccess(3, 2000) { + frontEndClient.putFrontEndRegistration(reg, endpointUuid.getValue) + } + + this.endpoint1Address3 = Some(registration.getInputAddress) + + retryUntilSuccess(10, 500) { + checkMarked(1.25, Quality.Validity.INVALID) + } + + val postTime = System.currentTimeMillis() + postAndGet(session, this.pointAUuid.get, analogBatch(this.pointAUuid.get, 1.11, postTime), endpoint1Address3.get) + + } +} + diff --git a/integration/src/test/scala/io/greenbus/integration/IntegrationConfig.scala b/integration/src/test/scala/io/greenbus/integration/IntegrationConfig.scala new file mode 100644 index 0000000..15cb755 --- /dev/null +++ b/integration/src/test/scala/io/greenbus/integration/IntegrationConfig.scala @@ -0,0 +1,170 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.integration + +import io.greenbus.client.service.proto.EventRequests.EventConfigTemplate +import io.greenbus.client.service.proto.Events.EventConfig +import io.greenbus.client.service.proto.Measurements.Quality +import io.greenbus.client.service.proto.Processing._ +import io.greenbus.client.service.proto.{ Model, Processing } +import io.greenbus.loader.set.Actions._ +import io.greenbus.loader.set.Mdl.EdgeDesc +import io.greenbus.loader.set.{ NamedEntId, ByteArrayValue, Upload } +import io.greenbus.msg.Session + +object IntegrationConfig { + + def buildAnalogPoint(prefix: String, cache: ActionCache): String = { + + val name = prefix + "PointA" + cache.pointPuts += PutPoint(None, name, Set("TypeA"), Model.PointCategory.ANALOG, "unit1") + + val rangeTrigger = Trigger.newBuilder() + .setAnalogLimit(AnalogLimit.newBuilder() + .setUpperLimit(2.0d) + .setLowerLimit(1.0d) + .build()) + .addActions(Action.newBuilder() + .setActionName("WentOutOfRange") + .setType(Processing.ActivationType.RISING) + .setEvent(EventGeneration.newBuilder() + .setEventType("OutOfRangeEvent"))) + .addActions(Action.newBuilder() + .setActionName("MakeItQuestionable") + .setType(Processing.ActivationType.RISING) + .setQualityAnnotation(Quality.newBuilder() + .setValidity(Quality.Validity.QUESTIONABLE))) + .addActions(Action.newBuilder() + .setActionName("WentBackInRange") + .setType(Processing.ActivationType.FALLING) + .setEvent(EventGeneration.newBuilder() + .setEventType("BackInRangeEvent"))) + .build() + + val ts = TriggerSet.newBuilder().addTriggers(rangeTrigger).build() + + cache.keyValuePutByNames += PutKeyValueByName(name, "triggerSet", ByteArrayValue(ts.toByteArray)) + + name + } + + def buildStatusPoint(prefix: String, cache: ActionCache): String = { + + val name = prefix + "PointB" + cache.pointPuts += PutPoint(None, name, Set("TypeA"), Model.PointCategory.STATUS, "unit1") + + val rangeTrigger = Trigger.newBuilder() + .setBoolValue(false) + .addActions(Action.newBuilder() + .setActionName("WentAbnormal") + .setType(Processing.ActivationType.RISING) + .setEvent(EventGeneration.newBuilder() + .setEventType("AbnormalEvent"))) + .build() + + val ts = TriggerSet.newBuilder().addTriggers(rangeTrigger).build() + + cache.keyValuePutByNames += PutKeyValueByName(name, "triggerSet", ByteArrayValue(ts.toByteArray)) + + name + } + + def eventConfigs(): Seq[EventConfigTemplate] = { + + List(EventConfigTemplate.newBuilder() + .setEventType("OutOfRangeEvent") + .setDesignation(EventConfig.Designation.EVENT) + .setSeverity(6) + .setResource("out of range") + .build(), + EventConfigTemplate.newBuilder() + .setEventType("AbnormalEvent") + .setDesignation(EventConfig.Designation.EVENT) + .setSeverity(7) + .setResource("abnormal") + .build()) + } + + def loadActions(actionSet: ActionsList, session: Session): Unit = { + Upload.push(session, actionSet, Seq()) + } + + def buildConfig(prefix: String): ActionsList = { + + val cache = new ActionCache + + val pointAName = buildAnalogPoint(prefix, cache) + val pointBName = buildStatusPoint(prefix, cache) + + val endpointName = prefix + "Endpoint" + cache.endpointPuts += PutEndpoint(None, endpointName, Set(), "test") + + cache.edgePuts += PutEdge(EdgeDesc(NamedEntId(endpointName), "source", NamedEntId(pointAName))) + cache.edgePuts += PutEdge(EdgeDesc(NamedEntId(endpointName), "source", NamedEntId(pointBName))) + + cache.result() + } + + def buildFilterTrigger(): TriggerSet = { + TriggerSet.newBuilder() + .addTriggers(Trigger.newBuilder() + .setFilter(Filter.newBuilder().setType(Filter.FilterType.DUPLICATES_ONLY)) + .addActions(Action.newBuilder() + .setActionName("suppress") + .setSuppress(true) + .setType(Processing.ActivationType.LOW) + .build())) + .build() + } + + def buildOffsetTrigger(offset: Double): TriggerSet = { + TriggerSet.newBuilder() + .addTriggers(Trigger.newBuilder() + .setAnalogLimit(AnalogLimit.newBuilder() + .setLowerLimit(1.2d) + .build()) + .addActions(Action.newBuilder() + .setActionName("offsetaction") + .setType(Processing.ActivationType.HIGH) + .setLinearTransform( + LinearTransform.newBuilder() + .setScale(1.0) + .setOffset(offset) + .build()) + .build()) + .build()) + .build() + } + + def buildUnconditionalOffsetTrigger(offset: Double): TriggerSet = { + TriggerSet.newBuilder() + .addTriggers(Trigger.newBuilder() + .addActions(Action.newBuilder() + .setActionName("offsetaction2") + .setType(Processing.ActivationType.HIGH) + .setLinearTransform( + LinearTransform.newBuilder() + .setScale(1.0) + .setOffset(offset) + .build()) + .build()) + .build()) + .build() + } +} diff --git a/integration/src/test/scala/io/greenbus/integration/IntegrationHelpers.scala b/integration/src/test/scala/io/greenbus/integration/IntegrationHelpers.scala new file mode 100644 index 0000000..745d905 --- /dev/null +++ b/integration/src/test/scala/io/greenbus/integration/IntegrationHelpers.scala @@ -0,0 +1,137 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.integration + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.client.service.MeasurementService +import io.greenbus.client.service.proto.Measurements.{ Measurement, MeasurementBatch, PointMeasurementValue, Quality } +import io.greenbus.client.service.proto.Model.ModelUUID +import io.greenbus.client.service.proto.ModelRequests.EndpointDisabledUpdate +import io.greenbus.client.service.proto.Processing +import io.greenbus.client.service.proto.Processing._ +import io.greenbus.msg.Session +import org.scalatest.matchers.ShouldMatchers + +import scala.annotation.tailrec +import scala.concurrent._ +import scala.concurrent.duration._ + +object IntegrationHelpers extends ShouldMatchers with Logging { + + def analogBatch(uuid: ModelUUID, v: Double, time: Long): MeasurementBatch = { + MeasurementBatch.newBuilder() + .addPointMeasurements(PointMeasurementValue.newBuilder() + .setPointUuid(uuid) + .setValue(analogMeas(v, time))) + .build() + } + + def analogMeas(v: Double, time: Long): Measurement = { + Measurement.newBuilder() + .setDoubleVal(v) + .setType(Measurement.Type.DOUBLE) + .setTime(time) + .build() + } + + def checkAnalog(m: Measurement, v: Double, time: Option[Long], qual: Quality.Validity = Quality.Validity.GOOD) { + m.hasDoubleVal should equal(true) + m.getDoubleVal should equal(v) + m.getType should equal(Measurement.Type.DOUBLE) + time.foreach(t => m.getTime should equal(t)) + m.getQuality.getValidity should equal(qual) + } + + def postAndGet(session: Session, point: ModelUUID, batch: MeasurementBatch, address: String, time: Long = 5000): Measurement = { + val measClient = MeasurementService.client(session) + val postResult = Await.result(measClient.postMeasurements(batch, address), time.milliseconds) + postResult should equal(true) + + val getResult = Await.result(measClient.getCurrentValues(List(point)), time.milliseconds) + getResult.size should equal(1) + + getResult.head.getValue + } + + def endUpdate(uuid: ModelUUID, disabled: Boolean) = EndpointDisabledUpdate.newBuilder().setEndpointUuid(uuid).setDisabled(disabled).build() + + @tailrec + def retryFutureUntilSuccess[A](times: Int, wait: Long)(f: => Future[A]): A = { + if (times == 0) { + throw new RuntimeException("Failed too many times") + } + try { + Await.result(f, wait.milliseconds) + } catch { + case ex: Throwable => + logger.warn("Request failure: " + ex) + retryFutureUntilSuccess(times - 1, wait)(f) + } + } + + @tailrec + def retryUntilSuccess[A](times: Int, wait: Long)(f: => A): A = { + if (times == 0) { + throw new RuntimeException("Failed too many times") + } + try { + f + } catch { + case ex: Throwable => + logger.warn("Attempt failure: " + ex) + Thread.sleep(wait) + retryUntilSuccess(times - 1, wait)(f) + } + } +} + +class TypedEventQueue[A] { + + private var queue = Seq.empty[A] + private var chk = Option.empty[(Promise[Seq[A]], Seq[A] => Boolean)] + + private val mutex = new Object + + def received(obj: A): Unit = { + mutex.synchronized { + queue = queue ++ Vector(obj) + chk.foreach { + case (prom, check) => + if (check(queue)) { + prom.success(queue) + chk = None + } + } + } + } + + def listen(check: Seq[A] => Boolean): Future[Seq[A]] = { + mutex.synchronized { + val prom = promise[Seq[A]] + + if (check(queue)) { + prom.success(queue) + } else { + chk = Some((prom, check)) + } + prom.future + + } + } +} diff --git a/integration/src/test/scala/io/greenbus/integration/ServiceAndProcessorTest.scala b/integration/src/test/scala/io/greenbus/integration/ServiceAndProcessorTest.scala new file mode 100644 index 0000000..5913f54 --- /dev/null +++ b/integration/src/test/scala/io/greenbus/integration/ServiceAndProcessorTest.scala @@ -0,0 +1,698 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.integration + +import java.util.concurrent.TimeoutException + +import akka.actor.{ ActorRef, ActorSystem } +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.client.ServiceConnection +import io.greenbus.client.service._ +import io.greenbus.client.service.proto.EventRequests.{ EventQuery, EventQueryParams } +import io.greenbus.client.service.proto.FrontEndRequests.FrontEndRegistrationTemplate +import io.greenbus.client.service.proto.MeasurementRequests.MeasurementBatchSubscriptionQuery +import io.greenbus.client.service.proto.Measurements._ +import io.greenbus.client.service.proto.Model +import io.greenbus.client.service.proto.Model._ +import io.greenbus.client.service.proto.ModelRequests.{ EntityEdgeDescriptor, EntityKeySet, EntityTemplate, _ } +import io.greenbus.client.service.proto.Processing.MeasOverride +import io.greenbus.measproc.MeasurementProcessor +import io.greenbus.msg.Session +import io.greenbus.msg.amqp.AmqpSettings +import io.greenbus.msg.qpid.QpidBroker +import io.greenbus.services.{ CoreServices, ResetDatabase, ServiceManager } +import io.greenbus.util.UserSettings +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner +import org.scalatest.matchers.ShouldMatchers +import org.scalatest.{ BeforeAndAfterAll, FunSuite } + +import scala.collection.JavaConversions._ +import scala.concurrent.Await +import scala.concurrent.duration._ + +@RunWith(classOf[JUnitRunner]) +class ServiceAndProcessorTest extends FunSuite with ShouldMatchers with Logging with BeforeAndAfterAll { + import IntegrationHelpers._ + import io.greenbus.integration.IntegrationConfig._ + import io.greenbus.integration.tools.PollingUtils._ + + val testConfigPath = "io.greenbus.test.cfg" + val system = ActorSystem("integrationTest") + var services = Option.empty[ActorRef] + var processor = Option.empty[ActorRef] + var conn = Option.empty[ServiceConnection] + var session = Option.empty[Session] + + var set1PointA = Option.empty[Point] + var set1PointB = Option.empty[Point] + var endpoint1 = Option.empty[Endpoint] + var endpoint1Address = Option.empty[String] + var endpoint2 = Option.empty[Endpoint] + var endpoint2Address = Option.empty[String] + var dynamicPoint = Option.empty[Point] + + val measQueue = new TypedEventQueue[MeasurementNotification] + val batchQueue = new TypedEventQueue[MeasurementBatchNotification] + + override protected def beforeAll(): Unit = { + ResetDatabase.reset(testConfigPath) + + logger.info("starting services") + services = Some(system.actorOf(ServiceManager.props(testConfigPath, testConfigPath, CoreServices.runServices))) + + val amqpConfig = AmqpSettings.load(testConfigPath) + val conn = ServiceConnection.connect(amqpConfig, QpidBroker, 5000) + this.conn = Some(conn) + + val userConfig = UserSettings.load(testConfigPath) + + val session = pollForSuccess(500, 5000) { + Await.result(conn.login(userConfig.user, userConfig.password), 500.milliseconds) + } + + this.session = Some(session) + + IntegrationConfig.loadActions(buildConfig("Set1"), session) + + logger.info("starting processor") + processor = Some(system.actorOf(MeasurementProcessor.buildProcessor(testConfigPath, testConfigPath, testConfigPath, 10000, "testNode"))) + + val eventClient = EventService.client(session) + + Await.result(eventClient.putEventConfigs(eventConfigs()), 5000.milliseconds) + + val modelClient = ModelService.client(session) + + val points = Await.result(modelClient.getPoints(EntityKeySet.newBuilder().addNames("Set1PointA").addNames("Set1PointB").build()), 5000.milliseconds) + points.size should equal(2) + val pointA = points.find(_.getName == "Set1PointA").get + val pointB = points.find(_.getName == "Set1PointB").get + this.set1PointA = Some(pointA) + this.set1PointB = Some(pointB) + + val endpoints = Await.result(modelClient.endpointQuery(EndpointQuery.newBuilder().addProtocols("test").build()), 5000.milliseconds) + + endpoints.size should equal(1) + val endpoint = endpoints.head + + this.endpoint1 = Some(endpoint) + + val measClient = MeasurementService.client(session) + + val measSubFut = measClient.getCurrentValuesAndSubscribe(Seq(pointA.getUuid, pointB.getUuid)) + val (_, measSub) = Await.result(measSubFut, 5000.milliseconds) + measSub.start { not => measQueue.received(not) } + + val batchSubFut = measClient.subscribeToBatches(MeasurementBatchSubscriptionQuery.newBuilder().build()) + val (_, batchSub) = Await.result(batchSubFut, 5000.milliseconds) + batchSub.start { not => /*println("BATCH: " + not);*/ batchQueue.received(not) } + } + + override protected def afterAll(): Unit = { + system.shutdown() + system.awaitTermination() + this.conn.get.disconnect() + } + + test("Simple meas publish") { + val session = this.session.get + + val modelClient = ModelService.client(session) + val frontEndClient = FrontEndService.client(session) + + val endpoint = endpoint1.get + + // let the measproc do its config, just a chance to avoid 2s wasted in the next step + Thread.sleep(500) + + val reg = FrontEndRegistrationTemplate.newBuilder() + .setEndpointUuid(endpoint.getUuid) + .build() + + val registration = retryFutureUntilSuccess(3, 2000) { + frontEndClient.putFrontEndRegistration(reg, endpoint.getUuid.getValue) + } + + registration.getEndpointUuid should equal(endpoint.getUuid) + + val address = registration.getInputAddress + this.endpoint1Address = Some(address) + + val measClient = MeasurementService.client(session) + + val original = Await.result(measClient.getCurrentValues(List(set1PointA.get.getUuid)), 5000.milliseconds) + original.size should equal(0) + + val measEventFut = measQueue.listen(_.nonEmpty) + val measBatchFut = batchQueue.listen(_.nonEmpty) + + val postTime = 5 + val posted = pollForSuccess(500, 5000) { + Await.result(measClient.postMeasurements(analogBatch(set1PointA.get.getUuid, 1.45, postTime), address), 500.milliseconds) + } + + posted should equal(true) + + val after = Await.result(measClient.getCurrentValues(List(set1PointA.get.getUuid)), 5000.milliseconds) + after.size should equal(1) + checkAnalog(after.head.getValue, 1.45, Some(postTime)) + + val measEvents = Await.result(measEventFut, 5000.milliseconds) + measEvents.size should equal(1) + checkAnalog(after.head.getValue, 1.45, Some(postTime)) + + val batchEvents = Await.result(measBatchFut, 5000.milliseconds) + batchEvents.size should equal(1) + batchEvents.head.getEndpointUuid should equal(endpoint.getUuid) + batchEvents.head.getValuesList.size should equal(1) + batchEvents.head.getValuesList.head.getPointUuid should equal(set1PointA.get.getUuid) + batchEvents.head.getValuesList.head.getPointName should equal(set1PointA.get.getName) + checkAnalog(batchEvents.head.getValuesList.head.getValue, 1.45, Some(postTime)) + } + + test("Post with nonexistent point") { + val session = this.session.get + val address = this.endpoint1Address.get + val measClient = MeasurementService.client(session) + + def partialBatchTest(v: Double, time: Long, batch: MeasurementBatch): Unit = { + val posted = pollForSuccess(500, 5000) { + Await.result(measClient.postMeasurements(batch, address), 500.milliseconds) + } + + posted should equal(true) + + val after = Await.result(measClient.getCurrentValues(List(set1PointA.get.getUuid)), 5000.milliseconds) + after.size should equal(1) + checkAnalog(after.head.getValue, v, Some(time)) + } + + val nameBatch = MeasurementBatch.newBuilder() + .addNamedMeasurements( + NamedMeasurementValue.newBuilder() + .setPointName(set1PointA.get.getName) + .setValue(analogMeas(1.59, 6)) + .build()) + .addNamedMeasurements( + NamedMeasurementValue.newBuilder() + .setPointName("erroneousPoint") + .setValue(analogMeas(3.33, 6)) + .build()) + .build() + + partialBatchTest(1.59, 6, nameBatch) + + val postTime2 = 6 + val batch2 = MeasurementBatch.newBuilder() + .addPointMeasurements( + PointMeasurementValue.newBuilder() + .setPointUuid(set1PointA.get.getUuid) + .setValue(analogMeas(1.63, postTime2)) + .build()) + .addPointMeasurements( + PointMeasurementValue.newBuilder() + .setPointUuid(ModelUUID.newBuilder().setValue("erroneousUuid").build()) + .setValue(analogMeas(3.33, postTime2)) + .build()) + .build() + + partialBatchTest(1.63, postTime2, batch2) + + } + + test("Event triggered") { + val session = this.session.get + val address = this.endpoint1Address.get + + val modelClient = ModelService.client(session) + val points = Await.result(modelClient.getPoints(EntityKeySet.newBuilder().addNames("Set1PointA").addNames("Set1PointB").build()), 5000.milliseconds) + points.size should equal(2) + val pointA = points.find(_.getName == "Set1PointA").get + + val postTime = 10 + val measClient = MeasurementService.client(session) + val posted = Await.result(measClient.postMeasurements(analogBatch(pointA.getUuid, 2.5, postTime), address), 5000.milliseconds) + + posted should equal(true) + + val after = Await.result(measClient.getCurrentValues(List(pointA.getUuid)), 5000.milliseconds) + after.size should equal(1) + + checkAnalog(after.head.getValue, 2.5, Some(postTime), Quality.Validity.QUESTIONABLE) + + val eventClient = EventService.client(session) + + val events = pollForSuccess(500, 5000) { + val evs = Await.result(eventClient.eventQuery(EventQuery.newBuilder().setQueryParams(EventQueryParams.newBuilder.addEventType("OutOfRangeEvent")).setPageSize(100).build()), 5000.milliseconds) + if (evs.nonEmpty) evs else throw new Exception("poll miss") + } + events.size should equal(1) + } + + test("Endpoint added") { + val session = this.session.get + + val modelClient = ModelService.client(session) + val frontEndClient = FrontEndService.client(session) + + val endpointTemplate = EndpointTemplate.newBuilder() + .setEntityTemplate( + EntityTemplate.newBuilder() + .setName("Set2Endpoint") + .build()) + .setProtocol("test2") + .build() + + val ends = Await.result(modelClient.putEndpoints(List(endpointTemplate)), 5000.milliseconds) + ends.size should equal(1) + val endpoint = ends.head + + // let the measproc do its config, just a chance to avoid 2s wasted in the next step + Thread.sleep(500) + + val regRequest = FrontEndRegistrationTemplate.newBuilder().setEndpointUuid(endpoint.getUuid).build() + + val regResult = retryFutureUntilSuccess(3, 2000) { + frontEndClient.putFrontEndRegistration(regRequest, endpoint.getUuid.getValue) + } + + val set2Address = regResult.getInputAddress + this.endpoint2 = Some(endpoint) + this.endpoint2Address = Some(set2Address) + + val pointATemplate = PointTemplate.newBuilder() + .setEntityTemplate( + EntityTemplate.newBuilder() + .setName("Set2PointA") + .addTypes("Point")) + .setPointCategory(Model.PointCategory.ANALOG) + .setUnit("unit3") + .build() + + val pointAResult = Await.result(modelClient.putPoints(List(pointATemplate)), 5000.milliseconds) + + pointAResult.size should equal(1) + val pointA = pointAResult.head + this.dynamicPoint = Some(pointA) + + val measClient = MeasurementService.client(session) + val pointABatch = analogBatch(pointA.getUuid, 1.2, 3) + + logger.info("post meas") + + val postResult = pollForSuccess(500, 5000) { + Await.result(measClient.postMeasurements(pointABatch, set2Address), 500.milliseconds) + } + postResult should equal(true) + + logger.info("get meas") + val emptyMeasResult = Await.result(measClient.getCurrentValues(List(pointA.getUuid)), 5000.milliseconds) + emptyMeasResult.size should equal(0) + + val edgeDesc = EntityEdgeDescriptor.newBuilder() + .setParentUuid(endpoint.getUuid) + .setChildUuid(pointA.getUuid) + .setRelationship("source") + .build() + + logger.info("put edges") + val edgeResult = Await.result(modelClient.putEdges(List(edgeDesc)), 5000.milliseconds) + edgeResult.size should equal(1) + + val measInDb = pollForSuccess(500, 5000) { + val postResult2 = Await.result(measClient.postMeasurements(pointABatch, set2Address), 5000.milliseconds) + postResult2 should equal(true) + + val shouldExistResult = Await.result(measClient.getCurrentValues(List(pointA.getUuid)), 5000.milliseconds) + if (shouldExistResult.isEmpty) { + throw new Exception("Still empty") + } else { + shouldExistResult + } + } + + checkAnalog(measInDb.head.getValue, 1.2, Some(3)) + + } + + test("Trigger added") { + val session = this.session.get + val pointA = this.dynamicPoint.get + val set2Address = this.endpoint2Address.get + + val auxTrigger = buildOffsetTrigger(0.33d) + + val keyValue = EntityKeyValue.newBuilder() + .setUuid(pointA.getUuid) + .setKey("triggerSet") + .setValue(StoredValue.newBuilder() + .setByteArrayValue(auxTrigger.toByteString)) + .build() + + val modelClient = ModelService.client(session) + + val setPutResult = Await.result(modelClient.putEntityKeyValues(Seq(keyValue)), 5000.milliseconds) + setPutResult.size should equal(1) + + val unitWasSet = pollForSuccessWithIter(500, 5000) { i => + val m = postAndGet(session, pointA.getUuid, analogBatch(pointA.getUuid, 1.1, 9 + i), set2Address) + if (m.getDoubleVal != 1.1) m else throw new Exception("poll miss") + } + + checkAnalog(unitWasSet, 1.1 + 0.33, None) + } + + test("Endpoint disable/enable") { + val session = this.session.get + val pointA = this.dynamicPoint.get + val endpoint2 = this.endpoint2.get + val set2Address = this.endpoint2Address.get + + val modelClient = ModelService.client(session) + val frontEndClient = FrontEndService.client(session) + val measClient = MeasurementService.client(session) + + val disabledResult = Await.result(modelClient.putEndpointDisabled(List(endUpdate(endpoint2.getUuid, true))), 5000.milliseconds) + disabledResult.size should equal(1) + + pollForSuccessWithIter(500, 5000) { i => + val time = 15 + i + try { + Await.ready(measClient.postMeasurements(analogBatch(pointA.getUuid, 1.7, time), set2Address), 100.milliseconds) + throw new Exception("poll miss") + } catch { + case ex: TimeoutException => + } + } + + Await.result(modelClient.putEndpointDisabled(List(endUpdate(endpoint2.getUuid, false))), 5000.milliseconds).size should equal(1) + + // let the measproc do its config, just a chance to avoid 2s wasted in the next step + Thread.sleep(500) + + val regRequest = FrontEndRegistrationTemplate.newBuilder().setEndpointUuid(endpoint2.getUuid).build() + + val regResult = retryFutureUntilSuccess(3, 2000) { + frontEndClient.putFrontEndRegistration(regRequest, endpoint2.getUuid.getValue) + } + + val nextEndpoint2Address = regResult.getInputAddress + this.endpoint2Address = Some(nextEndpoint2Address) + + pollForSuccessWithIter(500, 5000) { i => + val time = 18 + i + val m = postAndGet(session, pointA.getUuid, analogBatch(pointA.getUuid, 1.236, time), nextEndpoint2Address, 500) + if (m.getTime == time) m else throw new Exception("poll miss") + } + } + + private var measTime = 20 + def nextMeasTime(): Int = { + val r = measTime + measTime += 1 + r + } + + test("Override Added") { + val session = this.session.get + val pointA = this.dynamicPoint.get + val set2Address = this.endpoint2Address.get + + val modelClient = ModelService.client(session) + val processingClient = ProcessingService.client(session) + val measClient = MeasurementService.client(session) + + // Set up new value suppression + val auxTrigger = buildFilterTrigger() + + val keyValue = EntityKeyValue.newBuilder() + .setUuid(pointA.getUuid) + .setKey("triggerSet") + .setValue(StoredValue.newBuilder() + .setByteArrayValue(auxTrigger.toByteString)) + .build() + + val setPutResult = Await.result(modelClient.putEntityKeyValues(Seq(keyValue)), 5000.milliseconds) + setPutResult.size should equal(1) + + val offsetUsed = pollForSuccessWithIter(500, 5000) { i => + val t = nextMeasTime() + val m = postAndGet(session, pointA.getUuid, analogBatch(pointA.getUuid, 20.56, t), set2Address) + if (m.getTime != t) m else throw new Exception("poll miss") + } + + val startMeas = Await.result(measClient.getCurrentValues(List(pointA.getUuid)), 5000.milliseconds) + val startValue = startMeas.head.getValue.getDoubleVal + + val measOver = MeasOverride.newBuilder() + .setPointUuid(pointA.getUuid) + .build() + + val overResult = Await.result(processingClient.putOverrides(List(measOver)), 5000.milliseconds) + overResult.size should equal(1) + + val nisMeas = pollForSuccess(500, 5000) { + val result = Await.result(measClient.getCurrentValues(List(pointA.getUuid)), 5000.milliseconds) + result.size should equal(1) + val m = result.head.getValue + val isBlocked = m.getQuality.hasOperatorBlocked && m.getQuality.getOperatorBlocked + if (isBlocked) m else throw new Exception("poll miss") + } + + nisMeas.getType should equal(Measurement.Type.DOUBLE) + nisMeas.getDoubleVal should equal(startValue) + (nisMeas.getQuality.hasOperatorBlocked && nisMeas.getQuality.getOperatorBlocked) should equal(true) + (nisMeas.getQuality.getDetailQual.hasOldData && nisMeas.getQuality.getDetailQual.getOldData) should equal(true) + + val postNisResult = Await.result(measClient.postMeasurements(analogBatch(pointA.getUuid, 1.9, nextMeasTime()), set2Address), 5000.milliseconds) + postNisResult should equal(true) + + val stillNisResult = Await.result(measClient.getCurrentValues(List(pointA.getUuid)), 5000.milliseconds) + stillNisResult.size should equal(1) + + val stillNisMeas = stillNisResult.head.getValue + stillNisMeas.getType should equal(Measurement.Type.DOUBLE) + stillNisMeas.getDoubleVal should equal(startValue) + (stillNisMeas.getQuality.hasOperatorBlocked && stillNisMeas.getQuality.getOperatorBlocked) should equal(true) + (stillNisMeas.getQuality.getDetailQual.hasOldData && stillNisMeas.getQuality.getDetailQual.getOldData) should equal(true) + + val delResult = Await.result(processingClient.deleteOverrides(List(pointA.getUuid)), 5000.milliseconds) + delResult.size should equal(1) + + val afterNisMeas = pollForSuccess(500, 5000) { + val result = Await.result(measClient.getCurrentValues(List(pointA.getUuid)), 5000.milliseconds) + result.size should equal(1) + val m = result.head.getValue + println(m) + val notBlocked = !(m.getQuality.hasOperatorBlocked && m.getQuality.getOperatorBlocked) + if (notBlocked) m else throw new Exception("poll miss") + } + + afterNisMeas.getType should equal(Measurement.Type.DOUBLE) + afterNisMeas.getDoubleVal should equal(1.9) + } + + test("Override add/remove with identical update inbetween") { + val session = this.session.get + val pointA = this.dynamicPoint.get + val set2Address = this.endpoint2Address.get + + val processingClient = ProcessingService.client(session) + val measClient = MeasurementService.client(session) + + val initialValue = Await.result(measClient.postMeasurements(analogBatch(pointA.getUuid, 2.35, nextMeasTime()), set2Address), 5000.milliseconds) + initialValue should equal(true) + + val startMeas = Await.result(measClient.getCurrentValues(List(pointA.getUuid)), 5000.milliseconds) + println("startMeas: " + startMeas) + val startValue = startMeas.head.getValue.getDoubleVal + + val overVal = 1.11 + + val measOver = MeasOverride.newBuilder() + .setPointUuid(pointA.getUuid) + .setMeasurement(Measurement.newBuilder() + .setDoubleVal(overVal) + .setType(Measurement.Type.DOUBLE) + .setTime(nextMeasTime())) + .build() + + val overResult = Await.result(processingClient.putOverrides(List(measOver)), 5000.milliseconds) + overResult.size should equal(1) + + val overMeas = pollForSuccess(500, 5000) { + val result = Await.result(measClient.getCurrentValues(List(pointA.getUuid)), 5000.milliseconds) + result.size should equal(1) + val m = result.head.getValue + val isBlocked = m.getQuality.hasOperatorBlocked && m.getQuality.getOperatorBlocked + if (isBlocked) m else throw new Exception("poll miss") + } + + overMeas.getType should equal(Measurement.Type.DOUBLE) + overMeas.getDoubleVal should equal(overVal) + (overMeas.getQuality.hasOperatorBlocked && overMeas.getQuality.getOperatorBlocked) should equal(true) + (overMeas.getQuality.hasSource && overMeas.getQuality.getSource == Quality.Source.SUBSTITUTED) should equal(true) + + val postNisResult = Await.result(measClient.postMeasurements(analogBatch(pointA.getUuid, 2.35, nextMeasTime()), set2Address), 5000.milliseconds) + postNisResult should equal(true) + + val stillOverResult = Await.result(measClient.getCurrentValues(List(pointA.getUuid)), 5000.milliseconds) + stillOverResult.size should equal(1) + + val stillOverMeas = stillOverResult.head.getValue + stillOverMeas.getType should equal(Measurement.Type.DOUBLE) + stillOverMeas.getDoubleVal should equal(overVal) + (stillOverMeas.getQuality.hasOperatorBlocked && stillOverMeas.getQuality.getOperatorBlocked) should equal(true) + (stillOverMeas.getQuality.hasSource && stillOverMeas.getQuality.getSource == Quality.Source.SUBSTITUTED) should equal(true) + + val delResult = Await.result(processingClient.deleteOverrides(List(pointA.getUuid)), 5000.milliseconds) + delResult.size should equal(1) + + val afterRemoveMeas = pollForSuccess(500, 5000) { + val result = Await.result(measClient.getCurrentValues(List(pointA.getUuid)), 5000.milliseconds) + result.size should equal(1) + val m = result.head.getValue + val notBlocked = !(m.getQuality.hasOperatorBlocked && m.getQuality.getOperatorBlocked) + if (notBlocked) m else throw new Exception("poll miss") + } + + afterRemoveMeas.getType should equal(Measurement.Type.DOUBLE) + afterRemoveMeas.getDoubleVal should equal(2.35) + } + + test("un-nissing same measurement doesn't run transform twice") { + val session = this.session.get + val pointA = this.dynamicPoint.get + val set2Address = this.endpoint2Address.get + + val modelClient = ModelService.client(session) + val measClient = MeasurementService.client(session) + val processingClient = ProcessingService.client(session) + + val auxTrigger = buildUnconditionalOffsetTrigger(0.01d) + + val keyValue = EntityKeyValue.newBuilder() + .setUuid(pointA.getUuid) + .setKey("triggerSet") + .setValue(StoredValue.newBuilder() + .setByteArrayValue(auxTrigger.toByteString)) + .build() + + val setPutResult = Await.result(modelClient.putEntityKeyValues(Seq(keyValue)), 5000.milliseconds) + setPutResult.size should equal(1) + + val offsetUsed = pollForSuccessWithIter(500, 5000) { i => + val m = postAndGet(session, pointA.getUuid, analogBatch(pointA.getUuid, 1.55, nextMeasTime()), set2Address) + if (m.getDoubleVal == 1.56) m else throw new Exception("poll miss") + } + + val measOver = MeasOverride.newBuilder() + .setPointUuid(pointA.getUuid) + .build() + + val overResult = Await.result(processingClient.putOverrides(List(measOver)), 5000.milliseconds) + overResult.size should equal(1) + + val nisMeas = pollForSuccess(500, 5000) { + val result = Await.result(measClient.getCurrentValues(List(pointA.getUuid)), 5000.milliseconds) + result.size should equal(1) + val m = result.head.getValue + val isBlocked = m.getQuality.hasOperatorBlocked && m.getQuality.getOperatorBlocked + if (isBlocked) m else throw new Exception("poll miss") + } + + nisMeas.getType should equal(Measurement.Type.DOUBLE) + nisMeas.getDoubleVal should equal(1.56) + (nisMeas.getQuality.hasOperatorBlocked && nisMeas.getQuality.getOperatorBlocked) should equal(true) + (nisMeas.getQuality.getDetailQual.hasOldData && nisMeas.getQuality.getDetailQual.getOldData) should equal(true) + + val delResult = Await.result(processingClient.deleteOverrides(List(pointA.getUuid)), 5000.milliseconds) + delResult.size should equal(1) + + val afterNisMeas = pollForSuccess(500, 5000) { + val result = Await.result(measClient.getCurrentValues(List(pointA.getUuid)), 5000.milliseconds) + result.size should equal(1) + val m = result.head.getValue + val notBlocked = !(m.getQuality.hasOperatorBlocked && m.getQuality.getOperatorBlocked) + if (notBlocked) m else throw new Exception("poll miss") + } + + afterNisMeas.getType should equal(Measurement.Type.DOUBLE) + afterNisMeas.getDoubleVal should equal(1.56) + } + + test("Swap point between endpoints") { + val session = this.session.get + val pointA = this.dynamicPoint.get + val endpoint1 = this.endpoint1.get + val end1address = this.endpoint1Address.get + val endpoint2 = this.endpoint2.get + val end2address = this.endpoint2Address.get + + val modelClient = ModelService.client(session) + val end2Edge = EntityEdgeDescriptor.newBuilder() + .setParentUuid(endpoint2.getUuid) + .setChildUuid(pointA.getUuid) + .setRelationship("source") + .build() + + logger.info("put edges") + val edgeResult = Await.result(modelClient.deleteEdges(List(end2Edge)), 5000.milliseconds) + edgeResult.size should equal(1) + + pollForSuccessWithIter(500, 5000) { i => + val t = nextMeasTime() + val m = postAndGet(session, pointA.getUuid, analogBatch(pointA.getUuid, 1.7, t), end2address) + if (m.getTime != t) m else throw new Exception("poll miss") + } + + val end1Edge = EntityEdgeDescriptor.newBuilder() + .setParentUuid(endpoint1.getUuid) + .setChildUuid(pointA.getUuid) + .setRelationship("source") + .build() + + val end1Result = Await.result(modelClient.putEdges(List(end1Edge)), 5000.milliseconds) + end1Result.size should equal(1) + + pollForSuccessWithIter(500, 5000) { i => + val t = nextMeasTime() + val m = postAndGet(session, pointA.getUuid, analogBatch(pointA.getUuid, 1.74, t), end1address) + if (m.getTime == t) m else throw new Exception("poll miss") + } + + val unitTrigger = buildOffsetTrigger(0.66d) + val keyValue = EntityKeyValue.newBuilder() + .setUuid(pointA.getUuid) + .setKey("triggerSet") + .setValue(StoredValue.newBuilder() + .setByteArrayValue(unitTrigger.toByteString)) + .build() + val triggerResult = Await.result(modelClient.putEntityKeyValues(List(keyValue)), 5000.milliseconds) + triggerResult.size should equal(1) + + pollForSuccessWithIter(500, 5000) { i => + val t = nextMeasTime() + val m = postAndGet(session, pointA.getUuid, analogBatch(pointA.getUuid, 1.05, t), end1address) + if (m.getDoubleVal == (1.05 + 0.66)) m else throw new Exception("poll miss") + } + } +} + diff --git a/integration/src/test/scala/io/greenbus/integration/ldr/LoaderTest.scala b/integration/src/test/scala/io/greenbus/integration/ldr/LoaderTest.scala new file mode 100644 index 0000000..66cb60a --- /dev/null +++ b/integration/src/test/scala/io/greenbus/integration/ldr/LoaderTest.scala @@ -0,0 +1,495 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.integration.ldr + +import java.io.File + +import akka.actor.{ ActorRef, ActorSystem } +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.client.ServiceConnection +import io.greenbus.client.service.ModelService +import io.greenbus.client.service.proto.Model.ModelUUID +import io.greenbus.client.service.proto.ModelRequests.{ EndpointQuery, EntityKeySet, EntityEdgeQuery, EntityQuery } +import io.greenbus.ldr.XmlImporter +import io.greenbus.ldr.xml.Configuration +import io.greenbus.loader.set.LoadingException +import io.greenbus.msg.Session +import io.greenbus.msg.amqp.AmqpSettings +import io.greenbus.msg.qpid.QpidBroker +import io.greenbus.services.{ CoreServices, ResetDatabase, ServiceManager } +import io.greenbus.util.{ XmlHelper, UserSettings } +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner +import org.scalatest.matchers.ShouldMatchers +import org.scalatest.{ BeforeAndAfterAll, FunSuite } + +import scala.concurrent.Await +import scala.concurrent.duration._ +import scala.collection.JavaConversions._ + +@RunWith(classOf[JUnitRunner]) +class LoaderTest extends FunSuite with ShouldMatchers with Logging with BeforeAndAfterAll { + import io.greenbus.integration.tools.PollingUtils._ + + val testConfigPath = "io.greenbus.test.cfg" + val amqpConfig = AmqpSettings.load(testConfigPath) + val userConfig = UserSettings.load(testConfigPath) + val system = ActorSystem("integrationTest") + var services = Option.empty[ActorRef] + var conn = Option.empty[ServiceConnection] + var session = Option.empty[Session] + val parentDir = new File(".") + + override protected def beforeAll(): Unit = { + ResetDatabase.reset(testConfigPath) + + logger.info("starting services") + services = Some(system.actorOf(ServiceManager.props(testConfigPath, testConfigPath, CoreServices.runServices))) + + val conn = ServiceConnection.connect(amqpConfig, QpidBroker, 5000) + this.conn = Some(conn) + + val session = pollForSuccess(500, 5000) { + Await.result(conn.login(userConfig.user, userConfig.password), 500.milliseconds) + } + + this.session = Some(session) + } + + override protected def afterAll(): Unit = { + system.shutdown() + system.awaitTermination() + this.conn.get.disconnect() + } + + test("initial import") { + val session = this.session.get + + val xml = XmlHelper.read(TestXml.xmlSimple, classOf[Configuration]) + + XmlImporter.importFromXml(amqpConfig, userConfig, None, xml, parentDir, prompt = false) + + val modelClient = ModelService.client(session) + + val allEntities = Await.result(modelClient.entityQuery(EntityQuery.newBuilder().build()), 5000.milliseconds) + + allEntities.size should equal(7) + + val rootA = allEntities.find(_.getName == "RootA").get + val equipA = allEntities.find(_.getName == "EquipA").get + val equipB = allEntities.find(_.getName == "EquipB").get + val pointA = allEntities.find(_.getName == "PointA").get + val commandA = allEntities.find(_.getName == "CommandA").get + val endpointA = allEntities.find(_.getName == "EndpointA").get + val dangling = allEntities.find(_.getName == "DanglingEndpoint").get + + rootA.getTypesList.toSet should equal(Set("Root")) + equipA.getTypesList.toSet should equal(Set("Equip")) + equipB.getTypesList.toSet should equal(Set("Equip", "TypeA")) + pointA.getTypesList.toSet should equal(Set("Point", "PointTypeA")) + commandA.getTypesList.toSet should equal(Set("Command", "CommandTypeA")) + endpointA.getTypesList.toSet should equal(Set("Endpoint", "EndpointTypeA")) + + val allUuids = Seq(rootA, equipA, equipB, pointA, commandA, endpointA).map(_.getUuid) + + val allKeys = Await.result(modelClient.getEntityKeys(allUuids), 5000.milliseconds) + + val allKeyPairs = Await.result(modelClient.getEntityKeyValues(allKeys), 5000.milliseconds) + + allKeyPairs.size should equal(1) + val key = allKeyPairs.find(_.getUuid == equipA.getUuid).get + key.getKey should equal("keyA") + key.getValue.hasStringValue should equal(true) + key.getValue.getStringValue should equal("valueA") + + val edgeQuery = EntityEdgeQuery.newBuilder() + .addAllParentUuids(allUuids) + .addAllChildUuids(allUuids) + .setDepthLimit(1) + .setPageSize(Int.MaxValue) + .build() + + val allEdges = Await.result(modelClient.edgeQuery(edgeQuery), 5000.milliseconds) + + val edgeSet = Set( + (rootA.getUuid, "owns", equipA.getUuid), + (rootA.getUuid, "owns", equipB.getUuid), + (equipA.getUuid, "owns", pointA.getUuid), + (equipA.getUuid, "owns", commandA.getUuid), + (pointA.getUuid, "feedback", commandA.getUuid), + (endpointA.getUuid, "source", pointA.getUuid), + (endpointA.getUuid, "source", commandA.getUuid)) + + allEdges.map(e => (e.getParent, e.getRelationship, e.getChild)).toSet should equal(edgeSet) + } + + test("dangling endpoint doesn't prevent re-upload") { + val session = this.session.get + + val xml = XmlHelper.read(TestXml.xmlSimple, classOf[Configuration]) + + XmlImporter.importFromXml(amqpConfig, userConfig, None, xml, parentDir, prompt = false) + + val modelClient = ModelService.client(session) + + val endpoints = Await.result(modelClient.endpointQuery(EndpointQuery.newBuilder.build), 5000.milliseconds) + + endpoints.size should equal(2) + } + + test("double key") { + val session = this.session.get + + val xml = XmlHelper.read(TestXml.xmlDoubleKey, classOf[Configuration]) + + intercept[LoadingException] { + XmlImporter.importFromXml(amqpConfig, userConfig, None, xml, parentDir, prompt = false) + } + + val modelClient = ModelService.client(session) + + val allEntities = Await.result(modelClient.get(EntityKeySet.newBuilder().addNames("RootB").build()), 5000.milliseconds) + allEntities.size should equal(0) + } + + test("double name in same frag") { + val session = this.session.get + + val xml = XmlHelper.read(TestXml.xmlRootBWithDuplicate, classOf[Configuration]) + + intercept[LoadingException] { + XmlImporter.importFromXml(amqpConfig, userConfig, None, xml, parentDir, prompt = false) + } + + val modelClient = ModelService.client(session) + + val allEntities = Await.result(modelClient.get(EntityKeySet.newBuilder().addNames("RootB").build()), 5000.milliseconds) + allEntities.size should equal(0) + } + + test("duplicate source edge") { + val session = this.session.get + + val xml = XmlHelper.read(TestXml.xmlDuplicateSource, classOf[Configuration]) + + intercept[LoadingException] { + XmlImporter.importFromXml(amqpConfig, userConfig, None, xml, parentDir, prompt = false) + } + } + + test("rename to name that already exists in another fragment") { + val session = this.session.get + + val xml = XmlHelper.read(TestXml.xmlRootB, classOf[Configuration]) + + XmlImporter.importFromXml(amqpConfig, userConfig, None, xml, parentDir, prompt = false) + + val modelClient = ModelService.client(session) + + val keySet = EntityKeySet.newBuilder() + .addNames("RootB") + .addNames("EquipC") + .build() + + val allEntities = Await.result(modelClient.get(keySet), 5000.milliseconds) + allEntities.size should equal(2) + + val rootB = allEntities.find(_.getName == "RootB").get + val equipC = allEntities.find(_.getName == "EquipC").get + + val xml2 = XmlHelper.read(TestXml.xmlRootBRename(rootB.getUuid, equipC.getUuid), classOf[Configuration]) + + intercept[LoadingException] { + XmlImporter.importFromXml(amqpConfig, userConfig, None, xml2, parentDir, prompt = false) + } + + val xml3 = XmlHelper.read(TestXml.xmlRootBCreateDuplicate, classOf[Configuration]) + + intercept[LoadingException] { + XmlImporter.importFromXml(amqpConfig, userConfig, None, xml3, parentDir, prompt = false) + } + } + + test("promoting up the hierarchy does not cause diamond exception") { + val session = this.session.get + + val xml = XmlHelper.read(TestXml.xmlRootC, classOf[Configuration]) + + XmlImporter.importFromXml(amqpConfig, userConfig, None, xml, parentDir, prompt = false) + + val modelClient = ModelService.client(session) + + val keySet = EntityKeySet.newBuilder() + .addNames("RootC") + .addNames("EquipE") + .addNames("PointC") + .build() + + val allEntities = Await.result(modelClient.get(keySet), 5000.milliseconds) + allEntities.size should equal(3) + + val rootC = allEntities.find(_.getName == "RootC").get + val pointC = allEntities.find(_.getName == "PointC").get + + val xml2 = XmlHelper.read(TestXml.xmlRootCPromote(rootC.getUuid, pointC.getUuid), classOf[Configuration]) + + XmlImporter.importFromXml(amqpConfig, userConfig, None, xml2, parentDir, prompt = false) + + val allEntitiesAfter = Await.result(modelClient.get(keySet), 5000.milliseconds) + allEntitiesAfter.size should equal(2) + allEntitiesAfter.map(_.getName).contains("EquipE") should equal(false) + } + + test("deleting") { + val session = this.session.get + + val xml = XmlHelper.read(TestXml.xmlSimpleDeleted, classOf[Configuration]) + + XmlImporter.importFromXml(amqpConfig, userConfig, None, xml, parentDir, prompt = false) + + val modelClient = ModelService.client(session) + + val keySet = EntityKeySet.newBuilder() + .addNames("RootA") + .addNames("EquipA") + .addNames("PointA") + .addNames("CommandA") + .addNames("EquipB") + .build() + + val allEntities = Await.result(modelClient.get(keySet), 5000.milliseconds) + allEntities.size should equal(1) + allEntities.head.getName should equal("RootA") + } +} + +object TestXml { + + val xmlSimple = + """ + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + """.stripMargin + + val xmlSimpleDeleted = + """ + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + """.stripMargin + + val xmlDoubleKey = + """ + | + | + | + | + | + | + | + | + | + """.stripMargin + + val xmlDuplicateSource = + """ + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + """.stripMargin + + val xmlRootBWithDuplicate = + """ + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + """.stripMargin + + val xmlRootB = + """ + | + | + | + | + | + | + | + | + | + | + | + """.stripMargin + + def xmlRootBRename(rootBUuid: ModelUUID, equipCUuid: ModelUUID) = + s""" + | + | + | + | + | + | + | + | + | + | + | + """.stripMargin + + val xmlRootBCreateDuplicate = + """ + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + """.stripMargin + + val xmlRootC = + """ + | + | + | + | + | + | + | + | + | + | + | + | + | + | + """.stripMargin + + def xmlRootCPromote(rootCUuid: ModelUUID, pointUuid: ModelUUID) = + s""" + | + | + | + | + | + | + | + | + | + | + """.stripMargin +} diff --git a/io.greenbus.msg.amqp.cfg b/io.greenbus.msg.amqp.cfg new file mode 100755 index 0000000..d58497e --- /dev/null +++ b/io.greenbus.msg.amqp.cfg @@ -0,0 +1,13 @@ + +# AMQP settings +io.greenbus.msg.amqp.host = 127.0.0.1 +io.greenbus.msg.amqp.port = 5672 +io.greenbus.msg.amqp.user = qpid +io.greenbus.msg.amqp.password = qpid +io.greenbus.msg.amqp.virtualHost = greenbus +io.greenbus.msg.amqp.heartbeatTimeSeconds = 30 +io.greenbus.msg.amqp.ssl = false +io.greenbus.msg.amqp.trustStore = etc/trust-store.jks +io.greenbus.msg.amqp.trustStorePassword = password +#io.greenbus.msg.amqp.keyStore = etc/key-store.jks +#io.greenbus.msg.amqp.keyStorePassword = password \ No newline at end of file diff --git a/io.greenbus.sql.cfg b/io.greenbus.sql.cfg new file mode 100755 index 0000000..268af63 --- /dev/null +++ b/io.greenbus.sql.cfg @@ -0,0 +1,17 @@ + +# sql settings + + +# host address and port of database server +io.greenbus.sql.url = jdbc:postgresql://127.0.0.1:5432/greenbus3_d + +# database user/name password must match what was entered during database creation +io.greenbus.sql.username = core +io.greenbus.sql.password = core + +# any SQL query that takes longer than slowquery will generate a log message so we can track +# if debugging a sql issue this can be turned to 0 to see every sql query as it is made +io.greenbus.sql.slowquery = 100 + +# configure the max size of the database connection pool +# io.greenbus.sql.maxactive = 50 \ No newline at end of file diff --git a/io.greenbus.test.cfg b/io.greenbus.test.cfg new file mode 100755 index 0000000..26e5c8b --- /dev/null +++ b/io.greenbus.test.cfg @@ -0,0 +1,21 @@ + +io.greenbus.sql.url = jdbc:postgresql://127.0.0.1:5432/greenbus3_t +io.greenbus.sql.username = core +io.greenbus.sql.password = core +io.greenbus.sql.slowquery = 100 +# io.greenbus.sql.maxactive = 50 + +io.greenbus.user.username=system +io.greenbus.user.password=system + +io.greenbus.msg.amqp.host = 127.0.0.1 +io.greenbus.msg.amqp.port = 5672 +io.greenbus.msg.amqp.user = qpid +io.greenbus.msg.amqp.password = qpid +io.greenbus.msg.amqp.virtualHost = test +io.greenbus.msg.amqp.heartbeatTimeSeconds = 30 +io.greenbus.msg.amqp.ssl = false +io.greenbus.msg.amqp.trustStore = etc/trust-store.jks +io.greenbus.msg.amqp.trustStorePassword = password +#io.greenbus.msg.amqp.keyStore = etc/key-store.jks +#io.greenbus.msg.amqp.keyStorePassword = password diff --git a/io.greenbus.user.cfg b/io.greenbus.user.cfg new file mode 100755 index 0000000..3edb931 --- /dev/null +++ b/io.greenbus.user.cfg @@ -0,0 +1,3 @@ + +io.greenbus.user.username=system +io.greenbus.user.password=system \ No newline at end of file diff --git a/loader-xml/pom.xml b/loader-xml/pom.xml new file mode 100755 index 0000000..66e5546 --- /dev/null +++ b/loader-xml/pom.xml @@ -0,0 +1,75 @@ + + 4.0.0 + + + io.greenbus + greenbus-scala-base + 3.0.0 + ../scala-base + + + greenbus-loader-xml + jar + + + + AGPLv3 + http://www.gnu.org/licenses/agpl-3.0.txt + + + + + + + + com.mycila.maven-license-plugin + maven-license-plugin + +
../AGPL_FILE_HEADER
+
+
+ + org.codehaus.mojo + jaxb2-maven-plugin + ${jaxb2-maven-plugin.version} + + + config + + xjc + + + src/main/resources + configuration.xsd,agents.xsd,events.xsd + ${project.build.directory}/generated-sources/jaxb/.ConfigurationStaleFlag2 + + false + + + + + +
+
+ + + + io.greenbus + greenbus-loading + 3.0.0 + + + commons-cli + commons-cli + 1.2 + + + commons-io + commons-io + 2.4 + + + + +
diff --git a/loader-xml/src/main/resources/agents.xsd b/loader-xml/src/main/resources/agents.xsd new file mode 100755 index 0000000..153a3a1 --- /dev/null +++ b/loader-xml/src/main/resources/agents.xsd @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/loader-xml/src/main/resources/calculation.xsd b/loader-xml/src/main/resources/calculation.xsd new file mode 100755 index 0000000..589ba95 --- /dev/null +++ b/loader-xml/src/main/resources/calculation.xsd @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/loader-xml/src/main/resources/configuration.xsd b/loader-xml/src/main/resources/configuration.xsd new file mode 100755 index 0000000..1189373 --- /dev/null +++ b/loader-xml/src/main/resources/configuration.xsd @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + +

+ The root element of the entire configuration file. It also contains the main sections of the + configuration file -messageModel, + actionModel,equipmentModel, and + communicationsModel. +

+
+
+ + + + + + + + + + + + + + + +
+ +
diff --git a/loader-xml/src/main/resources/events.xsd b/loader-xml/src/main/resources/events.xsd new file mode 100644 index 0000000..2de7e24 --- /dev/null +++ b/loader-xml/src/main/resources/events.xsd @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/loader-xml/src/main/resources/modelHierarchy.xsd b/loader-xml/src/main/resources/modelHierarchy.xsd new file mode 100755 index 0000000..3287ba1 --- /dev/null +++ b/loader-xml/src/main/resources/modelHierarchy.xsd @@ -0,0 +1,400 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Types are tags used for classification of equipment and measurements into groups + or categories. +

+
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Defines a control. The "name" attribute is the name of the Control. This name will + be displayed in the HMI. Control names should not contain spaces or periods "." and should be + limited to 64 characters. (ex. Breaker100_Open). +

+
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Defines an status measurement. The following are attributes of + status +

+ name + - Name of the status measurement that will be displayed in the HMI. Status names should not + contain spaces or periods "." and should be limited to 64 characters. (ex. CB1001_Status, + Recloser122_status). +
+
+ unit + - The status unit of measurement. +
+

+
+
+ + + + + + +
+ + + + +

Defines an analog measurement. The following are attributes of + analog +

+ name + - Name of the analog measurement that will be displayed in the HMI. Analog names should not + contain spaces or periods "." and should be limited to 64 characters. (ex. Line100_Current, + Bus201_Voltage). +
+
+ unit + - The analog unit of measurement. +
+

+
+
+ + + + + + +
+ + + + +

+ Defines a counter. The + name + attribute is the of the Counter Measurement. This name will be displayed in the HMI. Counter names + should not contain spaces or periods "." and should be limited to 64 characters. (ex. + TX300_TotOperations). The + unit + attribute is the counter unit of measurement. +

+
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ Defines entities within the system such as stations, equipment groups, equipment, and devices. + Measurement tags are represented as + equipment.equipment.measurement-name. The parent-child relationship of any measurement tag is + defined by nesting of equipment elements. + equipment + elements can specify all contained equipment or reference an + equipmentProfile. The only difference between an + equipmentProfile + and + equipment + is that + equipment + has an optional + equipmentProfile + attribute. Both can specify multiple profiles in descendant elements. equipment elements can contain + the following elements (equipmentProfile,type,control,status,analog,counter,equipment). +

+
+
+ + + + + + +
+ + + + + +

This is the root element of the equipment modeling section, child elements of + equipmentModel + are profiles and equipment. +

+
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

This is the root element of the equipment modeling section, child elements of + equipmentModel + are profiles and equipment. +

+
+
+ + + + + + +
+ +
diff --git a/loader-xml/src/main/resources/triggersActionsMessages.xsd b/loader-xml/src/main/resources/triggersActionsMessages.xsd new file mode 100755 index 0000000..e9cb777 --- /dev/null +++ b/loader-xml/src/main/resources/triggersActionsMessages.xsd @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/loader-xml/src/main/scala/io/greenbus/ldr/CalculationConversion.scala b/loader-xml/src/main/scala/io/greenbus/ldr/CalculationConversion.scala new file mode 100644 index 0000000..3e9e38b --- /dev/null +++ b/loader-xml/src/main/scala/io/greenbus/ldr/CalculationConversion.scala @@ -0,0 +1,219 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.ldr + +import java.util.UUID + +import io.greenbus.ldr.xml._ +import io.greenbus.client.service.proto.Calculations +import io.greenbus.client.service.proto.Calculations._ +import io.greenbus.loader.set.{ LoadingException, EntityId } + +import scala.collection.JavaConversions._ + +object CalculationConversion { + + def toProto(calc: xml.Calculation): (CalculationDescriptor.Builder, Seq[(EntityId, CalculationInput.Builder)]) = { + + val (inputs, inputStratOpt) = if (calc.isSetInputs) { + val inputs = calc.getInputs + + val stratOpt = if (inputs.isSetInputQualityStrategy) { + Some(inputStratToProto(inputs.getInputQualityStrategy)) + } else { + None + } + + val ins = inputs.getSingleOrMulti.map { inputType => + + val nameOpt = if (inputType.isSetPointName) Some(inputType.getPointName) else None + val uuidOpt = if (inputType.isSetPointUuid) Some(inputType.getPointUuid) else None + if (nameOpt.isEmpty && uuidOpt.isEmpty) throw new LoadingXmlException("Reference must have name or UUID", inputType) + + if (nameOpt.isEmpty && uuidOpt.isEmpty) { + throw new LoadingXmlException("Calculation input must include name or UUID or both", inputType) + } + + val entId = EntityId(uuidOpt.map(UUID.fromString), nameOpt) + + val variable = if (inputType.isSetVariable) inputType.getVariable else throw new LoadingXmlException("Calculation missing variable name", inputType) + + val b = CalculationInput.newBuilder().setVariableName(variable) + inputType match { + case single: xml.Single => + b.setSingle( + SingleMeasurement.newBuilder().setStrategy( + SingleMeasurement.MeasurementStrategy.MOST_RECENT)) + case multi: xml.Multi => + val fromOpt = if (multi.isSetFrom) Some(multi.getFrom) else None + val limitOpt = if (multi.isSetLimit) Some(multi.getLimit) else None + val sinceLast = if (multi.isSetSinceLastPublish) Some(multi.getSinceLastPublish) else None + + val range = MeasurementRange.newBuilder() + fromOpt.foreach(range.setFromMs) + limitOpt.foreach(range.setLimit) + sinceLast.foreach(range.setSinceLast) + b.setRange(range) + } + + (entId, b) + } + + (ins, stratOpt) + } else { + (Seq(), None) + } + + if (!(calc.isSetFormula && calc.getFormula.isSetValue)) { + throw new LoadingXmlException("Calculation must include formula", calc) + } + + val form = calc.getFormula.getValue + + val strat = if (calc.isSetTriggering) { + val trigXml = calc.getTriggering + if (trigXml.isSetUpdateOnAnyChange) { + TriggerStrategy.newBuilder() + .setUpdateAny(trigXml.getUpdateOnAnyChange) + .build() + } else if (trigXml.isSetUpdateEveryPeriodInMilliseconds) { + TriggerStrategy.newBuilder() + .setPeriodMs(trigXml.getUpdateEveryPeriodInMilliseconds) + .build() + } else { + throw new LoadingXmlException("Calculation missing valid trigger strategy", calc) + } + } else { + throw new LoadingXmlException("Calculation missing valid trigger strategy", calc) + } + + val qualOutput = if (calc.isSetOutput && calc.getOutput.isSetOutputQualityStrategy) { + val protoOutputStrat = outputStratToProto(calc.getOutput.getOutputQualityStrategy) + OutputQuality.newBuilder().setStrategy(protoOutputStrat) + } else { + OutputQuality.newBuilder().setStrategy(Calculations.OutputQuality.Strategy.WORST_QUALITY) + } + + val builder = CalculationDescriptor.newBuilder() + .setFormula(form) + .setTriggering(strat) + .setQualityOutput(qualOutput) + + inputStratOpt.foreach(strat => builder.setTriggeringQuality(InputQuality.newBuilder().setStrategy(strat))) + + (builder, inputs) + } + + def toXml(calc: CalculationDescriptor, uuidToNameMap: Map[UUID, String]): xml.Calculation = { + + val inputs = new Inputs + calc.getCalcInputsList.map(in => inputToXml(in, uuidToNameMap)).foreach(inputs.getSingleOrMulti.add) + val inputQualStrat = if (calc.hasTriggeringQuality) calc.getTriggeringQuality else throw new LoadingException("Calculation missing input quality") + inputs.setInputQualityStrategy(inputStratToXml(inputQualStrat.getStrategy)) + + val exprString = if (calc.hasFormula) calc.getFormula else throw new LoadingException("Calculation missing formula") + val formula = new Formula + formula.setValue(exprString) + + val triggeringElem = new Triggering + val triggeringProto = if (calc.hasTriggering) calc.getTriggering else throw new LoadingException("Calculation missing triggering") + if (triggeringProto.hasPeriodMs) { + triggeringElem.setUpdateEveryPeriodInMilliseconds(triggeringProto.getPeriodMs) + } else if (triggeringProto.hasUpdateAny) { + triggeringElem.setUpdateOnAnyChange(true) + } + + val qualityOutput = if (calc.hasQualityOutput) calc.getQualityOutput else throw new LoadingException("Calculation quality output") + val outputElem = new Output + outputElem.setOutputQualityStrategy(outputStratToXml(qualityOutput.getStrategy)) + + val elem = new xml.Calculation + elem.setInputs(inputs) + elem.setFormula(formula) + elem.setTriggering(triggeringElem) + elem.setOutput(outputElem) + + elem + } + + private def inputToXml(input: CalculationInput, uuidToNameMap: Map[UUID, String]) = { + val uuid = if (input.hasPointUuid) input.getPointUuid else throw new LoadingException("No uuid for calculation input") + val variable = if (input.hasVariableName) input.getVariableName else throw new LoadingException("No variable name for calculation input") + + if (input.hasSingle) { + + val elem = new xml.Single + elem.setVariable(variable) + elem.setPointUuid(uuid.getValue) + uuidToNameMap.get(UUID.fromString(uuid.getValue)).foreach(elem.setPointName) + elem + + } else if (input.hasRange) { + + val range = input.getRange + + val elem = new Multi + elem.setVariable(variable) + elem.setPointUuid(uuid.getValue) + uuidToNameMap.get(UUID.fromString(uuid.getValue)).foreach(elem.setPointName) + + val fromMsOpt = if (range.hasFromMs) Some(range.getFromMs) else None + val limitOpt = if (range.hasLimit) Some(range.getLimit) else None + + val sinceLast = range.hasSinceLast && range.getSinceLast + + fromMsOpt.foreach(elem.setFrom) + limitOpt.foreach(elem.setLimit) + elem.setSinceLastPublish(sinceLast) + + elem + + } else { + throw new LoadingException("Calculation input has no single/multi configuration") + } + } + + private def outputStratToXml(outputStrat: Calculations.OutputQuality.Strategy): OutputQualityEnum = { + outputStrat match { + case Calculations.OutputQuality.Strategy.ALWAYS_OK => OutputQualityEnum.ALWAYS_OK + case Calculations.OutputQuality.Strategy.WORST_QUALITY => OutputQualityEnum.WORST_QUALITY + } + } + private def outputStratToProto(outputStrat: OutputQualityEnum): Calculations.OutputQuality.Strategy = { + outputStrat match { + case OutputQualityEnum.ALWAYS_OK => Calculations.OutputQuality.Strategy.ALWAYS_OK + case OutputQualityEnum.WORST_QUALITY => Calculations.OutputQuality.Strategy.WORST_QUALITY + } + } + + private def inputStratToXml(inputStrat: Calculations.InputQuality.Strategy): InputQualityEnum = { + inputStrat match { + case Calculations.InputQuality.Strategy.ACCEPT_ALL => InputQualityEnum.ACCEPT_ALL + case Calculations.InputQuality.Strategy.ONLY_WHEN_ALL_OK => InputQualityEnum.ONLY_WHEN_ALL_OK + case Calculations.InputQuality.Strategy.REMOVE_BAD_AND_CALC => InputQualityEnum.REMOVE_BAD_AND_CALC + } + } + private def inputStratToProto(inputStrat: InputQualityEnum): Calculations.InputQuality.Strategy = { + inputStrat match { + case InputQualityEnum.ACCEPT_ALL => Calculations.InputQuality.Strategy.ACCEPT_ALL + case InputQualityEnum.ONLY_WHEN_ALL_OK => Calculations.InputQuality.Strategy.ONLY_WHEN_ALL_OK + case InputQualityEnum.REMOVE_BAD_AND_CALC => Calculations.InputQuality.Strategy.REMOVE_BAD_AND_CALC + } + } +} diff --git a/loader-xml/src/main/scala/io/greenbus/ldr/KvFileStorage.scala b/loader-xml/src/main/scala/io/greenbus/ldr/KvFileStorage.scala new file mode 100644 index 0000000..8cc88ce --- /dev/null +++ b/loader-xml/src/main/scala/io/greenbus/ldr/KvFileStorage.scala @@ -0,0 +1,103 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.ldr + +import java.io.File + +import org.apache.commons.io.FileUtils +import io.greenbus.loader.set.Mdl.FlatModelFragment +import io.greenbus.loader.set._ + +object KvFileStorage { + + val ignored = Set("calculation", "triggerSet") + + def mapKeyValuesToFileReferences(flat: FlatModelFragment, buildFilename: (String, String) => String): (FlatModelFragment, Seq[(String, Array[Byte])]) = { + + def mapFields(f: EntityFields): (EntityFields, Seq[(String, Array[Byte])]) = { + val name = f.id match { + case FullEntId(_, name) => name + case NamedEntId(name) => name + case _ => throw new LoadingException("Names needs to be resolved to write key values") + } + + val fileDescs = Vector.newBuilder[(String, Array[Byte])] + + val mappedKvs = f.kvs.map { + case (key, vh) => + if (ignored.contains(key)) { + (key, vh) + } else { + vh match { + case ByteArrayValue(bytes) => + val filename = buildFilename(name, key) + fileDescs += ((filename, bytes)) + (key, FileReference(filename)) + case v => (key, v) + } + } + } + + (f.copy(kvs = mappedKvs), fileDescs.result()) + } + + val refs = Vector.newBuilder[(String, Array[Byte])] + + def genFieldMap(f: EntityFields): EntityFields = { + val (mappedFields, mappedRefs) = mapFields(f) + refs ++= mappedRefs + mappedFields + } + + val ents = flat.modelEntities.map(obj => obj.copy(fields = genFieldMap(obj.fields))) + val points = flat.points.map(obj => obj.copy(fields = genFieldMap(obj.fields))) + val commands = flat.commands.map(obj => obj.copy(fields = genFieldMap(obj.fields))) + val endpoints = flat.endpoints.map(obj => obj.copy(fields = genFieldMap(obj.fields))) + + val model = flat.copy(modelEntities = ents, points = points, commands = commands, endpoints = endpoints) + + (model, refs.result()) + } + + def resolveFileReferences(flat: FlatModelFragment, parent: File): FlatModelFragment = { + + def resolveFields(fields: EntityFields): EntityFields = { + val kvs = fields.kvs.map { + case (key, vh) => + vh match { + case FileReference(filename) => + val bytes = try { + FileUtils.readFileToByteArray(new File(parent, filename)) + } catch { + case ex: Throwable => throw new LoadingException(s"Could not open file $filename: " + ex.getMessage) + } + (key, ByteArrayValue(bytes)) + case _ => (key, vh) + } + } + fields.copy(kvs = kvs) + } + + flat.copy( + modelEntities = flat.modelEntities.map(obj => obj.copy(fields = resolveFields(obj.fields))), + points = flat.points.map(obj => obj.copy(fields = resolveFields(obj.fields))), + commands = flat.commands.map(obj => obj.copy(fields = resolveFields(obj.fields))), + endpoints = flat.endpoints.map(obj => obj.copy(fields = resolveFields(obj.fields)))) + } +} diff --git a/loader-xml/src/main/scala/io/greenbus/ldr/LoadingXmlException.scala b/loader-xml/src/main/scala/io/greenbus/ldr/LoadingXmlException.scala new file mode 100644 index 0000000..c27acf6 --- /dev/null +++ b/loader-xml/src/main/scala/io/greenbus/ldr/LoadingXmlException.scala @@ -0,0 +1,23 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.ldr + +class LoadingXmlException(msg: String, elem: Any) extends Exception(msg) { + def element: Any = elem +} diff --git a/loader-xml/src/main/scala/io/greenbus/ldr/TreeToXml.scala b/loader-xml/src/main/scala/io/greenbus/ldr/TreeToXml.scala new file mode 100644 index 0000000..918ea39 --- /dev/null +++ b/loader-xml/src/main/scala/io/greenbus/ldr/TreeToXml.scala @@ -0,0 +1,311 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.ldr + +import java.util.UUID + +import io.greenbus.ldr.xml.EndpointType.Source +import io.greenbus.ldr.xml.PointType.{ Triggers, Commands } +import io.greenbus.ldr.xml._ +import io.greenbus.client.service.proto.Calculations.CalculationDescriptor +import io.greenbus.client.service.proto.Model.{ StoredValue, CommandCategory, PointCategory } +import io.greenbus.client.service.proto.Processing.TriggerSet +import io.greenbus.loader.set._ +import io.greenbus.loader.set.Mdl._ +import io.greenbus.loader.set + +object TreeToXml { + + val triggerSetKey = "triggerSet" + val calculationKey = "calculation" + + def xml(set: TreeModelFragment): Configuration = { + + val equipModel = new EquipmentModel + + val equipRoots: Seq[Equipment] = set.roots.sortBy(_.node.fields.id).map(equipNode).map { + case e: Equipment => e + case _ => throw new LoadingException("Roots must be equipment, not points or commands") + } + + equipRoots.foreach(equipModel.getEquipment.add) + val config = new Configuration + + config.setEquipmentModel(equipModel) + + val endpoints = set.endpoints.sortBy(_.node.fields.id).map(endpointNodeToXml) + + if (endpoints.nonEmpty) { + val endpointModel = new EndpointModel + endpoints.foreach(endpointModel.getEndpoint.add) + config.setEndpointModel(endpointModel) + } + + config + } + + def xmlType(typ: String) = { + val t = new Type + t.setName(typ) + t + } + + private def entIdToRefXml(id: set.EntityId): Reference = { + val (optUuid, optName) = set.EntityId.optional(id) + val ref = new Reference + optName.foreach(ref.setName) + optUuid.map(_.toString).foreach(ref.setUuid) + ref + } + + private def entIdAndTypeToRelationXml(id: set.EntityId, relation: String): Relationship = { + val (optUuid, optName) = set.EntityId.optional(id) + val rel = new Relationship + optName.foreach(rel.setName) + optUuid.map(_.toString).foreach(rel.setUuid) + rel.setRelation(relation) + rel + } + + private def equipNode(node: TreeNode): AnyRef = { + + def commonElements( + elements: java.util.List[AnyRef], + fields: EntityFields, + preExtractedCalc: Option[(CalculationDescriptor, Map[UUID, String])], + node: TreeNode)(implicit context: String): Unit = { + + fields.types.toSeq.sorted.map(xmlType).foreach(elements.add) + + val (kvs, triggerSetOpt, calcOpt) = mapKeyValues(fields.kvs, preExtractedCalc) + + kvs.sortBy(_.getKey).foreach(elements.add) + + if (node.extParentRefs.nonEmpty) { + val owners = new ExternalOwners + node.extParentRefs.sorted.map(entIdToRefXml).foreach(owners.getReference.add) + elements.add(owners) + } + + if (node.otherChildRefs.nonEmpty) { + val refs = new ChildRelationships + node.otherChildRefs.sorted.map(tup => entIdAndTypeToRelationXml(tup._1, tup._2)).foreach(refs.getRelationship.add) + elements.add(refs) + } + + if (node.otherParentRefs.nonEmpty) { + val parentRefs = new ParentRelationships + node.otherParentRefs.sorted.map(tup => entIdAndTypeToRelationXml(tup._1, tup._2)).foreach(parentRefs.getRelationship.add) + elements.add(parentRefs) + } + + val subs = node.children.sortBy(_.node.fields.id).map(equipNode) + subs.foreach(elements.add) + + triggerSetOpt.foreach(elements.add) + calcOpt.foreach(elements.add) + } + + node.node match { + case desc: EntityDesc => + val equip = new Equipment + val (optUuid, optName) = set.EntityId.optional(desc.fields.id) + optName.foreach(equip.setName) + optUuid.map(_.toString).foreach(equip.setUuid) + + implicit val context = equip.getName + + commonElements(equip.getElements, desc.fields, None, node) + + equip + + case desc: PointDesc => + val xml = desc.pointCategory match { + case PointCategory.ANALOG => new Analog + case PointCategory.STATUS => new Status + case PointCategory.COUNTER => new Counter + } + val (optUuid, optName) = set.EntityId.optional(desc.fields.id) + optName.foreach(xml.setName) + xml.setUnit(desc.unit) + optUuid.map(_.toString).foreach(xml.setUuid) + + implicit val context = xml.getName + + if (node.commandRefs.nonEmpty) { + val commands = new Commands + node.commandRefs.sorted.map(entIdToRefXml).foreach(commands.getReference.add) + xml.getElements.add(commands) + } + + val calcOpt = desc.calcHolder match { + case NoCalculation | CalcNotChecked => None + case NamesResolvedCalc(calc, uuidMap) => Some((calc, uuidMap)) + case _: UnresolvedCalc => throw new LoadingException("Not expecting UnresolvedCalc in outputter") + } + + commonElements(xml.getElements, desc.fields, calcOpt, node) + + xml + + case desc: CommandDesc => + val xml = desc.commandCategory match { + case CommandCategory.CONTROL => new Control + case _ => new Setpoint + } + + val (optUuid, optName) = set.EntityId.optional(desc.fields.id) + optName.foreach(xml.setName) + xml.setDisplayName(desc.displayName) + optUuid.map(_.toString).foreach(xml.setUuid) + + implicit val context = xml.getName + + commonElements(xml.getElements, desc.fields, None, node) + + xml + } + } + + private def endpointNodeToXml(node: EndpointNode): Endpoint = { + + val xml = new Endpoint + + val fields = node.node.fields + + val (optUuid, optName) = set.EntityId.optional(fields.id) + optName.foreach(xml.setName) + optUuid.map(_.toString).foreach(xml.setUuid) + + implicit val context = xml.getName + + fields.types.toSeq.map(xmlType).foreach(xml.getElements.add) + + xml.setProtocol(node.node.protocol) + + val kvXmlElems = fields.kvs.distinct.map { case (key, v) => kvHolderToXml(key, v) } + kvXmlElems.foreach(xml.getElements.add) + + if (node.otherChildRefs.nonEmpty) { + val refs = new ChildRelationships + node.otherChildRefs.sorted.map(tup => entIdAndTypeToRelationXml(tup._1, tup._2)).foreach(refs.getRelationship.add) + xml.getElements.add(refs) + } + + if (node.otherParentRefs.nonEmpty) { + val parentRefs = new ParentRelationships + node.otherParentRefs.sorted.map(tup => entIdAndTypeToRelationXml(tup._1, tup._2)).foreach(parentRefs.getRelationship.add) + xml.getElements.add(parentRefs) + } + + node.sourceRefs.sorted.foreach { id => + val elem = new Source + val (optUuid, optName) = set.EntityId.optional(id) + optName.foreach(elem.setName) + optUuid.map(_.toString).foreach(elem.setUuid) + xml.getElements.add(elem) + } + + xml + } + + def storedValueToXml(result: KeyValue, v: StoredValue)(implicit context: String) { + if (v.hasBoolValue) { + result.setBooleanValue(v.getBoolValue) + } else if (v.hasInt32Value) { + result.setIntValue(v.getInt32Value) + } else if (v.hasInt64Value) { + result.setIntValue(v.getInt64Value) + } else if (v.hasUint32Value) { + result.setIntValue(v.getUint32Value) + } else if (v.hasUint64Value) { + result.setIntValue(v.getUint64Value) + } else if (v.hasDoubleValue) { + result.setDoubleValue(v.getDoubleValue) + } else if (v.hasStringValue) { + result.setStringValue(v.getStringValue) + } else { + throw new LoadingException(s"Could not convert key value to XML format for $context") + } + } + + private def kvHolderToXml(key: String, v: ValueHolder)(implicit context: String) = { + val result = new KeyValue + result.setKey(key) + + v match { + case SimpleValue(sv) => storedValueToXml(result, sv) + case ByteArrayValue(bytes) => + val size = bytes.length + result.setBytesSize(size) + case FileReference(name) => + result.setFileName(name) + case _ => + throw new LoadingException(s"Could not handle key value type for $context") + } + + result + } + + def mapKeyValues( + kvs: Seq[(String, ValueHolder)], + calcOpt: Option[(CalculationDescriptor, Map[UUID, String])])(implicit context: String): (Seq[KeyValue], Option[Triggers], Option[Calculation]) = { + + val (triggers, nonTriggers) = kvs.partition(_._1 == triggerSetKey) + val (calcs, nonTriggersOrCalcs) = nonTriggers.partition(_._1 == calculationKey) + + val triggerSetProto = triggers.headOption.map(_._2).map { + case ByteArrayValue(bytes) => { + try { + TriggerSet.parseFrom(bytes) + } catch { + case ex: Throwable => + throw new LoadingException("Could not parse trigger set stored value: " + ex + s" for $context") + } + } + case _ => throw new LoadingException(s"TriggerSet was not a byte array for $context") + } + + val triggerXmlElem = triggerSetProto.map(proto => TriggerActionConversion.toXml(proto)) + + val calcXmlElem = calcOpt match { + case None => + val calcProto = calcs.headOption.map(_._2).map { + case ByteArrayValue(bytes) => { + try { + CalculationDescriptor.parseFrom(bytes) + } catch { + case ex: Throwable => + throw new LoadingException("Could not parse calculation stored value: " + ex + s" for $context") + } + } + case _ => throw new LoadingException(s"Calculation was not a byte array for $context") + } + calcProto.map(p => CalculationConversion.toXml(p, Map())) + + case Some((desc, uuidToNameMap)) => Some(CalculationConversion.toXml(desc, uuidToNameMap)) + } + + val otherKvs = nonTriggersOrCalcs + + val kvXmlElems = otherKvs.map { case (key, v) => kvHolderToXml(key, v) } + + (kvXmlElems, triggerXmlElem, calcXmlElem) + } +} diff --git a/loader-xml/src/main/scala/io/greenbus/ldr/TriggerActionConversion.scala b/loader-xml/src/main/scala/io/greenbus/ldr/TriggerActionConversion.scala new file mode 100644 index 0000000..68f55d2 --- /dev/null +++ b/loader-xml/src/main/scala/io/greenbus/ldr/TriggerActionConversion.scala @@ -0,0 +1,293 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.ldr + +import io.greenbus.client.service.proto.Processing +import io.greenbus.client.service.proto.Processing.Filter.FilterType +import io.greenbus.client.service.proto.Processing._ +import io.greenbus.ldr.xml.IntegerMapping.Mapping +import io.greenbus.ldr.xml.PointType.Triggers +import io.greenbus.ldr.xml._ + +import scala.collection.JavaConversions._ + +object TriggerActionConversion { + + def triggersToProto(triggers: Triggers)(implicit context: String): TriggerSet = { + val protoTriggers = triggers.getTriggerGroup.map(triggerToProto) + + TriggerSet.newBuilder() + .addAllTriggers(protoTriggers) + .build() + } + + def triggerToProto(trig: TriggerBase)(implicit context: String) = { + val b: Trigger.Builder = trig match { + case elem: xml.Always => + Trigger.newBuilder() + + case elem: xml.Range => + val b = AnalogLimit.newBuilder() + if (elem.isSetLow) b.setLowerLimit(elem.getLow) + if (elem.isSetHigh) b.setUpperLimit(elem.getHigh) + if (elem.isSetDeadband) b.setDeadband(elem.getDeadband) + + Trigger.newBuilder().setAnalogLimit(b.build()) + + case elem: xml.Filter => + val filter = if (elem.isSetDeadband) { + Processing.Filter.newBuilder() + .setType(Processing.Filter.FilterType.DEADBAND) + .setDeadbandValue(elem.getDeadband) + .build() + } else { + Processing.Filter.newBuilder() + .setType(Processing.Filter.FilterType.DUPLICATES_ONLY) + .build() + } + + Trigger.newBuilder().setFilter(filter) + + case elem: xml.MatchValue => + + val tb = Trigger.newBuilder() + + if (elem.isSetBooleanValue) { + tb.setBoolValue(elem.getBooleanValue) + } else if (elem.isSetIntValue) { + tb.setIntValue(elem.getIntValue) + } else if (elem.isSetStringValue) { + tb.setStringValue(elem.getStringValue) + } else { + throw new LoadingXmlException(s"Missing value to match in MatchValue element for $context", elem) + } + tb + + case x => throw new LoadingXmlException("Unhandled trigger element: " + x.getClass.getSimpleName + s" for $context", trig) + } + + if (trig.isSetStopProcessingWhen) { + b.setStopProcessingWhen(activationTypeToProto(trig.getStopProcessingWhen)) + } + + val actions = trig.getActionGroup.map(xmlActionToProto) + b.addAllActions(actions) + + b.build() + } + + def toXml(proto: TriggerSet)(implicit context: String): PointType.Triggers = { + + val triggersXml = new Triggers + + proto.getTriggersList.foreach { trigger => + if (trigger.hasAnalogLimit) { + val limit = trigger.getAnalogLimit + + val range = new xml.Range() + + if (limit.hasLowerLimit) range.setLow(limit.getLowerLimit) + if (limit.hasUpperLimit) range.setHigh(limit.getUpperLimit) + if (limit.hasDeadband) range.setDeadband(limit.getDeadband) + + trigger.getActionsList.foreach(a => fillAction(a, range)) + if (trigger.hasStopProcessingWhen) range.setStopProcessingWhen(activationTypeToXml(trigger.getStopProcessingWhen)) + triggersXml.getTriggerGroup.add(range) + + } else if (trigger.hasBoolValue) { + val matcher = new MatchValue + matcher.setBooleanValue(trigger.getBoolValue) + trigger.getActionsList.foreach(a => fillAction(a, matcher)) + if (trigger.hasStopProcessingWhen) matcher.setStopProcessingWhen(activationTypeToXml(trigger.getStopProcessingWhen)) + triggersXml.getTriggerGroup.add(matcher) + + } else if (trigger.hasStringValue) { + + val matcher = new MatchValue + matcher.setStringValue(trigger.getStringValue) + trigger.getActionsList.foreach(a => fillAction(a, matcher)) + if (trigger.hasStopProcessingWhen) matcher.setStopProcessingWhen(activationTypeToXml(trigger.getStopProcessingWhen)) + triggersXml.getTriggerGroup.add(matcher) + + } else if (trigger.hasIntValue) { + + val matcher = new MatchValue + matcher.setIntValue(trigger.getIntValue) + trigger.getActionsList.foreach(a => fillAction(a, matcher)) + if (trigger.hasStopProcessingWhen) matcher.setStopProcessingWhen(activationTypeToXml(trigger.getStopProcessingWhen)) + triggersXml.getTriggerGroup.add(matcher) + + } else if (trigger.hasFilter) { + + val filter = trigger.getFilter + val filterXml = new xml.Filter + filter.getType match { + case FilterType.DEADBAND => + if (filter.hasDeadbandValue) filterXml.setDeadband(filter.getDeadbandValue) + case FilterType.DUPLICATES_ONLY => + } + + trigger.getActionsList.foreach(a => fillAction(a, filterXml)) + if (trigger.hasStopProcessingWhen) filterXml.setStopProcessingWhen(activationTypeToXml(trigger.getStopProcessingWhen)) + triggersXml.getTriggerGroup.add(filterXml) + + } else { + + val alwaysXml = new Always + trigger.getActionsList.foreach(a => fillAction(a, alwaysXml)) + if (trigger.hasStopProcessingWhen) alwaysXml.setStopProcessingWhen(activationTypeToXml(trigger.getStopProcessingWhen)) + triggersXml.getTriggerGroup.add(alwaysXml) + } + } + + triggersXml + } + + def xmlActionToProto(xmlAction: ActionType)(implicit context: String): Action = { + val actType = if (xmlAction.isSetActivation) xmlAction.getActivation else throw new LoadingXmlException(s"Missing activation type for action for $context", xmlAction) + + val b = Action.newBuilder().setType(activationTypeToProto(actType)) + + xmlAction match { + case elem: xml.Suppress => + b.setSuppress(true) + case elem: xml.StripValue => + b.setStripValue(true) + case elem: xml.SetBool => + val bValue = if (elem.isSetValue) elem.isValue else throw new LoadingXmlException(s"Missing bool value for SetBool for $context", elem) + b.setSetBool(bValue) + case elem: xml.Event => + val eventConfig = if (elem.isSetEventType) elem.getEventType else throw new LoadingXmlException(s"Missing event config value for Event action for $context", elem) + b.setEvent(EventGeneration.newBuilder().setEventType(eventConfig).build()) + case elem: xml.Scale => + val scale = if (elem.isSetScale) elem.getScale else throw new LoadingXmlException(s"Missing scale for Scale element for $context", elem) + val offset = if (elem.isSetOffset) elem.getOffset else throw new LoadingXmlException(s"Missing offset for Scale element for $context", elem) + val forceToDoubleOpt = if (elem.isSetForceToDouble) Some(elem.getForceToDouble) else None + + val ltb = LinearTransform.newBuilder() + .setScale(scale) + .setOffset(offset) + + forceToDoubleOpt.foreach(ltb.setForceToDouble) + + b.setLinearTransform(ltb.build()) + + case elem: xml.BoolMapping => + val falseString = if (elem.isSetFalseString) elem.getFalseString else throw new LoadingXmlException(s"Missing falseString for BoolMapping element for $context", elem) + val trueString = if (elem.isSetTrueString) elem.getTrueString else throw new LoadingXmlException(s"Missing trueString for BoolMapping element for $context", elem) + b.setBoolTransform(BoolEnumTransform.newBuilder().setFalseString(falseString).setTrueString(trueString).build()) + + case elem: xml.IntegerMapping => + val mappings = elem.getMapping.map { m => + val fromInt = if (m.isSetFromInteger) m.getFromInteger else throw new LoadingXmlException(s"Need from integer in IntegerMapping for $context", elem) + val toString = if (m.isSetToString) m.getToString else throw new LoadingXmlException(s"Need to string in IntegerMapping for $context", elem) + IntToStringMapping.newBuilder().setValue(fromInt).setString(toString).build() + } + + b.setIntTransform(IntEnumTransform.newBuilder().addAllMappings(mappings).build()) + + case _ => throw new LoadingXmlException(s"Unrecognized action type for $context", xmlAction) + } + + b.build() + } + + private def fillAction(action: Action, trigger: TriggerBase): Unit = { + if (action.hasSuppress) { + val elem = new xml.Suppress + elem.setActivation(activationTypeToXml(action.getType)) + trigger.getActionGroup.add(elem) + + } else if (action.hasStripValue) { + val elem = new StripValue + elem.setActivation(activationTypeToXml(action.getType)) + trigger.getActionGroup.add(elem) + + } else if (action.hasSetBool) { + val elem = new SetBool + elem.setActivation(activationTypeToXml(action.getType)) + elem.setValue(action.getSetBool) + trigger.getActionGroup.add(elem) + + } else if (action.hasEvent) { + val elem = new Event + elem.setActivation(activationTypeToXml(action.getType)) + elem.setEventType(action.getEvent.getEventType) + trigger.getActionGroup.add(elem) + + } else if (action.hasLinearTransform) { + val elem = new Scale + elem.setActivation(activationTypeToXml(action.getType)) + + val trans = action.getLinearTransform + if (trans.hasScale) elem.setScale(trans.getScale) + if (trans.hasOffset) elem.setOffset(trans.getOffset) + if (trans.hasForceToDouble) elem.setForceToDouble(trans.getForceToDouble) + + trigger.getActionGroup.add(elem) + + } else if (action.hasBoolTransform) { + val elem = new BoolMapping + elem.setActivation(activationTypeToXml(action.getType)) + + val boolTrans = action.getBoolTransform + if (boolTrans.hasFalseString) elem.setFalseString(boolTrans.getFalseString) + if (boolTrans.hasTrueString) elem.setTrueString(boolTrans.getTrueString) + + trigger.getActionGroup.add(elem) + + } else if (action.hasIntTransform) { + val elem = new IntegerMapping + elem.setActivation(activationTypeToXml(action.getType)) + + val intTransform = action.getIntTransform + val xmlMapElems = intTransform.getMappingsList.map { map => + val xmlMap = new Mapping + xmlMap.setFromInteger(map.getValue) + xmlMap.setToString(map.getString) + xmlMap + } + xmlMapElems.foreach(elem.getMapping.add) + + if (intTransform.hasDefaultValue) elem.setDefaultValue(intTransform.getDefaultValue) + + trigger.getActionGroup.add(elem) + } + } + + def activationTypeToXml(typ: ActivationType): ActivationConditionType = { + typ match { + case ActivationType.HIGH => ActivationConditionType.HIGH + case ActivationType.LOW => ActivationConditionType.LOW + case ActivationType.RISING => ActivationConditionType.RISING + case ActivationType.FALLING => ActivationConditionType.FALLING + case ActivationType.TRANSITION => ActivationConditionType.TRANSITION + } + } + def activationTypeToProto(typ: ActivationConditionType): ActivationType = { + typ match { + case ActivationConditionType.HIGH => ActivationType.HIGH + case ActivationConditionType.LOW => ActivationType.LOW + case ActivationConditionType.RISING => ActivationType.RISING + case ActivationConditionType.FALLING => ActivationType.FALLING + case ActivationConditionType.TRANSITION => ActivationType.TRANSITION + } + } + +} diff --git a/loader-xml/src/main/scala/io/greenbus/ldr/XmlExporter.scala b/loader-xml/src/main/scala/io/greenbus/ldr/XmlExporter.scala new file mode 100644 index 0000000..e060b2f --- /dev/null +++ b/loader-xml/src/main/scala/io/greenbus/ldr/XmlExporter.scala @@ -0,0 +1,175 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.ldr + +import java.io.File + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.ldr.xml.Configuration +import org.apache.commons.cli.{ HelpFormatter, Options } +import org.apache.commons.io.FileUtils +import io.greenbus.msg.amqp.AmqpSettings +import io.greenbus.msg.qpid.QpidBroker +import io.greenbus.client.ServiceConnection +import io.greenbus.loader.set._ +import io.greenbus.util.{ XmlHelper, UserSettings } + +import scala.concurrent.duration._ +import org.apache.commons.cli + +object XmlExporter extends Logging { + + val rootFlag = "root" + val outputFlag = "output" + val directoryFlag = "directory" + val kvFilesFlag = "kv-files" + val endpointFlag = "endpoint" + + def buildOptions: Options = { + val opts = new Options + opts.addOption("h", "help", false, "Display this help text") + opts.addOption(new cli.Option(null, rootFlag, true, "Root entity to export")) + opts.addOption(new cli.Option("d", directoryFlag, true, "Directory to output files to")) + opts.addOption(new cli.Option("o", outputFlag, true, "Output filename")) + opts.addOption(new cli.Option("k", kvFilesFlag, false, s"Write key values to file. Must be used with --$directoryFlag")) + opts.addOption(new cli.Option(null, endpointFlag, true, "Additional Endpoints to export even if not connected to model fragment")) + opts + } + + def main(args: Array[String]): Unit = { + try { + run(args) + } catch { + case ex: LoadingException => + System.err.println(ex.getMessage) + case ex: io.greenbus.msg.amqp.util.LoadingException => + System.err.println(ex.getMessage) + case ex: java.util.concurrent.TimeoutException => + System.err.println("Service request timed out") + case ex: Throwable => + logger.error("Unhandled exception: " + ex) + System.err.println("Error: " + ex.getMessage) + } + } + + def run(args: Array[String]): Unit = { + + val options = buildOptions + val parser = new cli.BasicParser + val line = parser.parse(options, args) + + def printHelp() { + (new HelpFormatter).printHelp("loader file", options) + } + + if (line.hasOption("h")) { + printHelp() + } else { + + val roots = Option(line.getOptionValues(rootFlag)).map(_.toSeq).getOrElse(Seq()) + + val additionalEndpoints = Option(line.getOptionValues(endpointFlag)).map(_.toSeq).getOrElse(Seq()) + + val dirOpt = Option(line.getOptionValue(directoryFlag)) + + val outOpt = Option(line.getOptionValue(outputFlag)) + + val kvFiles = line.hasOption(kvFilesFlag) + + if (roots.isEmpty) { + System.err.println("Must include one or more root entities.") + } else if (kvFiles && dirOpt.isEmpty) { + System.err.println("Must specify output directory when writing key values to files.") + } else { + export(roots, additionalEndpoints, outOpt, dirOpt, kvFiles) + } + } + } + + def export(roots: Seq[String], additionalEndpoints: Seq[String], outputOpt: Option[String], dirOpt: Option[String], kvsToFiles: Boolean) = { + + val baseDir = Option(System.getProperty("io.greenbus.config.base")).getOrElse("") + val amqpConfigPath = Option(System.getProperty("io.greenbus.config.amqp")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.msg.amqp.cfg") + val userConfigPath = Option(System.getProperty("io.greenbus.config.user")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.user.cfg") + + val config = AmqpSettings.load(amqpConfigPath) + val userConfig = UserSettings.load(userConfigPath) + + val conn = try { + ServiceConnection.connect(config, QpidBroker, 10000) + } catch { + case ex: java.io.IOException => throw new LoadingException(ex.getMessage) + } + + try { + + val session = conn.login(userConfig.user, userConfig.password, Duration(5000, MILLISECONDS)) + + val downloaded = Downloader.downloadByIds(session, roots.map(NamedEntId), additionalEndpoints.map(NamedEntId)) + + val flat = DownloadConversion.downloadToIntermediate(downloaded) + + val flatResolved = NameResolver.resolveFlatModelUuids(session, flat) + + val (flatFinal, kvFiles) = if (kvsToFiles) { + KvFileStorage.mapKeyValuesToFileReferences(flatResolved, (a, b) => s"$a.$b") + } else { + (flatResolved, Seq()) + } + + val tree = Mdl.flatToTree(flatFinal) + + val xml = TreeToXml.xml(tree) + + val text = XmlHelper.writeToString(xml, classOf[Configuration], formatted = true) + + dirOpt match { + case Some(dirname) => + writeInDir(text, outputOpt, dirname, kvFiles) + case None => + outputOpt match { + case Some(filename) => + FileUtils.write(new File(filename), text) + case None => + println(text) + } + } + + } finally { + conn.disconnect() + } + } + + def writeInDir(main: String, outNameOpt: Option[String], dirname: String, kvs: Seq[(String, Array[Byte])]): Unit = { + val dirFile = new File(dirname) + if (!dirFile.exists()) { + dirFile.mkdir() + } + + val mainFilename = outNameOpt.getOrElse("config.xml") + + val mainFile = new File(dirFile, mainFilename) + FileUtils.write(mainFile, main) + + kvs.foreach { + case (name, bytes) => + FileUtils.writeByteArrayToFile(new File(dirFile, name), bytes) + } + } +} diff --git a/loader-xml/src/main/scala/io/greenbus/ldr/XmlImporter.scala b/loader-xml/src/main/scala/io/greenbus/ldr/XmlImporter.scala new file mode 100644 index 0000000..a8f58b2 --- /dev/null +++ b/loader-xml/src/main/scala/io/greenbus/ldr/XmlImporter.scala @@ -0,0 +1,187 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.ldr + +import java.io.File +import javax.xml.bind.UnmarshalException +import javax.xml.stream.{ XMLStreamException, Location } + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.ldr.xml.Configuration +import org.apache.commons.cli +import org.apache.commons.cli.{ HelpFormatter, Options } +import io.greenbus.msg.amqp.AmqpSettings +import io.greenbus.msg.qpid.QpidBroker +import io.greenbus.client.ServiceConnection +import io.greenbus.loader.set.Mdl.FlatModelFragment +import io.greenbus.loader.set._ +import io.greenbus.util.{ UserSettings, XmlHelper } + +import scala.concurrent.duration._ + +object XmlImporter extends Logging { + + val rootFlag = "root" + + def buildOptions: Options = { + val opts = new Options + opts.addOption("h", "help", false, "Display this help text") + opts.addOption(new cli.Option(null, rootFlag, true, "Root entity to import")) + opts + } + + def main(args: Array[String]): Unit = { + try { + run(args) + } catch { + case ex: LoadingException => + System.err.println(ex.getMessage) + case ex: io.greenbus.msg.amqp.util.LoadingException => + System.err.println(ex.getMessage) + case ex: java.util.concurrent.TimeoutException => + System.err.println("Service request timed out") + case ex: UnmarshalException => + logger.error("Unmarshal exception: " + ex) + Option(ex.getCause) match { + case Some(se: XMLStreamException) => System.err.println(se.getMessage) + case _ => System.err.println("Unknown error parsing XML.") + } + case ex: Throwable => + logger.error("Unhandled exception: " + ex) + System.err.println("Error: " + ex.getMessage) + } + } + + def run(args: Array[String]): Unit = { + + val baseDir = Option(System.getProperty("io.greenbus.config.base")).getOrElse("") + val amqpConfigPath = Option(System.getProperty("io.greenbus.config.amqp")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.msg.amqp.cfg") + val userConfigPath = Option(System.getProperty("io.greenbus.config.user")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.user.cfg") + + val config = AmqpSettings.load(amqpConfigPath) + val userConfig = UserSettings.load(userConfigPath) + + val options = buildOptions + val parser = new cli.BasicParser + val line = parser.parse(options, args) + + def printHelp() { + (new HelpFormatter).printHelp("loader file", options) + } + + if (line.hasOption("h")) { + printHelp() + } else { + val roots = Option(line.getOptionValues(rootFlag)).map(_.toSeq).getOrElse(Seq()) + + if (line.getArgs.length < 1) { + System.err.println("Must include file to import.") + System.exit(1) + } + val filename = line.getArgs.head + + val optCommandRoots = if (roots.nonEmpty) Some(roots) else None + + val file = new File(filename) + val parentDir = file.getParentFile + + val (xmlConfig, locationMap) = XmlHelper.readWithLocation(file, classOf[Configuration]) + + try { + importFromXml(config, userConfig, optCommandRoots, xmlConfig, parentDir, prompt = true) + } catch { + case ex: LoadingXmlException => + throw new LoadingException(ex.getMessage + " " + locationError(ex.element, locationMap)) + } + } + } + + def locationError(obj: Any, locations: Map[Any, Location]): String = { + locations.get(obj) match { + case None => "[line: ?, col: ?]" + case Some(loc) => s"[line: ${loc.getLineNumber}, col: ${loc.getColumnNumber}]" + } + } + + def importFromXml( + amqpConfig: AmqpSettings, + userConfig: UserSettings, + optCommandRoots: Option[Seq[String]], + xmlConfig: Configuration, + parentDir: File, + prompt: Boolean = true) = { + + val treeRepresentation = XmlToModel.convert(xmlConfig) + + val xmlRootIds = treeRepresentation.roots.map(_.node.fields.id) + + val flatModel = Mdl.treeToFlat(treeRepresentation) + + val flatFilesResolved = KvFileStorage.resolveFileReferences(flatModel, parentDir) + + importFragment(amqpConfig, userConfig, optCommandRoots, xmlRootIds, flatFilesResolved, prompt) + } + + def importFragment( + amqpConfig: AmqpSettings, + userConfig: UserSettings, + optCommandRoots: Option[Seq[String]], + xmlRoots: Seq[EntityId], + flatModel: FlatModelFragment, + prompt: Boolean = true) = { + + val conn = try { + ServiceConnection.connect(amqpConfig, QpidBroker, 10000) + } catch { + case ex: java.io.IOException => throw new LoadingException(ex.getMessage) + } + + try { + + val fragmentEndpointIds = flatModel.endpoints.map(_.fields.id) + + val session = conn.login(userConfig.user, userConfig.password, Duration(5000, MILLISECONDS)) + + val downloadSet = optCommandRoots.map { roots => + Downloader.downloadByIdsAndNames(session, xmlRoots, roots, fragmentEndpointIds) + }.getOrElse { + Downloader.downloadByIds(session, xmlRoots, fragmentEndpointIds) + } + + val ((actions, diff), idTuples) = Importer.importDiff(session, flatModel, downloadSet) + + Importer.summarize(diff) + + if (!diff.isEmpty) { + if (prompt) { + println("Proceed with modifications? (y/N)") + val answer = readLine() + if (Set("y", "yes").contains(answer.trim.toLowerCase)) { + Upload.push(session, actions, idTuples) + } + } else { + Upload.push(session, actions, idTuples) + } + } + + } finally { + conn.disconnect() + } + } +} diff --git a/loader-xml/src/main/scala/io/greenbus/ldr/XmlToModel.scala b/loader-xml/src/main/scala/io/greenbus/ldr/XmlToModel.scala new file mode 100644 index 0000000..d553a8b --- /dev/null +++ b/loader-xml/src/main/scala/io/greenbus/ldr/XmlToModel.scala @@ -0,0 +1,301 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.ldr + +import java.util.UUID + +import io.greenbus.ldr.xml.{ SetpointType, Configuration, Equipment } +import io.greenbus.client.service.proto.Model.{ CommandCategory, PointCategory, StoredValue } +import io.greenbus.loader.set._ + +import scala.collection.JavaConversions._ + +object XmlToModel { + import io.greenbus.loader.set.Mdl._ + + def classFilter[A](seq: Seq[AnyRef], klass: Class[A]) = { + seq.filter(_.getClass == klass).map(_.asInstanceOf[A]) + } + + def convert(root: Configuration): TreeModelFragment = { + val nodes = if (root.isSetEquipmentModel) { + val equipModel = root.getEquipmentModel + equipModel.getEquipment.map(traverse) + } else { + Seq() + } + + val endpoints = if (root.isSetEndpointModel) { + val endModel = root.getEndpointModel + endModel.getEndpoint.map(convertEndpoint) + } else { + Seq() + } + + TreeModelFragment(nodes, endpoints) + } + + def keyValFromXml(kvElem: xml.KeyValue) = { + val key = if (kvElem.isSetKey) kvElem.getKey else throw new LoadingXmlException("KeyValue element missing key name", kvElem) + + val valueHolder = if (kvElem.isSetBooleanValue) { + SimpleValue(StoredValue.newBuilder().setBoolValue(kvElem.getBooleanValue).build()) + } else if (kvElem.isSetStringValue) { + SimpleValue(StoredValue.newBuilder().setStringValue(kvElem.getStringValue).build()) + } else if (kvElem.isSetIntValue) { + SimpleValue(StoredValue.newBuilder().setInt64Value(kvElem.getIntValue).build()) + } else if (kvElem.isSetDoubleValue) { + SimpleValue(StoredValue.newBuilder().setDoubleValue(kvElem.getDoubleValue).build()) + } else if (kvElem.isSetBytesSize) { + ByteArrayReference(kvElem.getBytesSize) + } else if (kvElem.isSetFileName) { + FileReference(kvElem.getFileName) + } else { + throw new LoadingXmlException("Could not interpret key value", kvElem) + } + + (key, valueHolder) + } + + def extractOwnerRefs(elems: java.util.List[AnyRef]): Seq[EntityId] = { + val externalOwnersOpt = classFilter(elems, classOf[xml.ExternalOwners]).headOption + externalOwnersOpt.map { extOwners => + extOwners.getReference.map { ref => + val nameOpt = if (ref.isSetName) Some(ref.getName) else None + val uuidOpt = if (ref.isSetUuid) Some(ref.getUuid) else None + if (nameOpt.isEmpty && uuidOpt.isEmpty) throw new LoadingXmlException("Reference must have name or UUID", ref) + EntityId(uuidOpt.map(UUID.fromString), nameOpt) + } + }.getOrElse(Seq()) + } + + def extractCommandRefs(elems: java.util.List[AnyRef]): Seq[EntityId] = { + val externalOwnersOpt = classFilter(elems, classOf[xml.PointType.Commands]).headOption + externalOwnersOpt.map { extOwners => + extOwners.getReference.map { ref => + val nameOpt = if (ref.isSetName) Some(ref.getName) else None + val uuidOpt = if (ref.isSetUuid) Some(ref.getUuid) else None + if (nameOpt.isEmpty && uuidOpt.isEmpty) throw new LoadingXmlException("Reference must have name or UUID", ref) + EntityId(uuidOpt.map(UUID.fromString), nameOpt) + } + }.getOrElse(Seq()) + } + + def extractParentRefs(elems: java.util.List[AnyRef]): Seq[(EntityId, String)] = { + val elemSeq = classFilter(elems, classOf[xml.ParentRelationships]).headOption + elemSeq.map { elem => + elem.getRelationship.map { rel => + val nameOpt = if (rel.isSetName) Some(rel.getName) else None + val uuidOpt = if (rel.isSetUuid) Some(rel.getUuid) else None + if (nameOpt.isEmpty && uuidOpt.isEmpty) throw new LoadingXmlException("Reference must have name or UUID", rel) + val relation = if (rel.isSetRelation) rel.getRelation else throw new LoadingXmlException("Edge reference missing relationship", rel) + (EntityId(uuidOpt.map(UUID.fromString), nameOpt), relation) + } + }.getOrElse(Seq()) + } + + def extractChildRefs(elems: java.util.List[AnyRef]): Seq[(EntityId, String)] = { + val elemSeq = classFilter(elems, classOf[xml.ChildRelationships]).headOption + elemSeq.map { elem => + elem.getRelationship.map { rel => + val nameOpt = if (rel.isSetName) Some(rel.getName) else None + val uuidOpt = if (rel.isSetUuid) Some(rel.getUuid) else None + if (nameOpt.isEmpty && uuidOpt.isEmpty) throw new LoadingXmlException("Reference must have name or UUID", rel) + val relation = if (rel.isSetRelation) rel.getRelation else throw new LoadingXmlException("Edge reference missing relationship", rel) + (EntityId(uuidOpt.map(UUID.fromString), nameOpt), relation) + } + }.getOrElse(Seq()) + } + + def extractSourceRefs(elems: java.util.List[AnyRef]): Seq[EntityId] = { + val sourceRefs = classFilter(elems, classOf[xml.EndpointType.Source]) + sourceRefs.map { ref => + val nameOpt = if (ref.isSetName) Some(ref.getName) else None + val uuidOpt = if (ref.isSetUuid) Some(ref.getUuid) else None + if (nameOpt.isEmpty && uuidOpt.isEmpty) throw new LoadingXmlException("Reference must have name or UUID", ref) + EntityId(uuidOpt.map(UUID.fromString), nameOpt) + } + } + + def traverse(equip: Equipment): TreeNode = { + val nameOpt = if (equip.isSetName) Some(equip.getName) else None + val uuidOpt = if (equip.isSetUuid) Some(equip.getUuid) else None + if (nameOpt.isEmpty && uuidOpt.isEmpty) throw new LoadingXmlException("Equipment must have name or UUID", equip) + + val types = classFilter(equip.getElements, classOf[xml.Type]).map { elem => + if (elem.isSetName) elem.getName else throw new LoadingXmlException(s"Type element missing type name for ${equip.getName}", elem) + } + + val keysAndHolders = classFilter(equip.getElements, classOf[xml.KeyValue]).map(keyValFromXml) + + val ownerRefs = extractOwnerRefs(equip.getElements) + + val parentRefs = extractParentRefs(equip.getElements) + + val childRefs = extractChildRefs(equip.getElements) + + val childEquips = classFilter(equip.getElements, classOf[xml.Equipment]).map(traverse) + + val points = (classFilter(equip.getElements, classOf[xml.Analog]) ++ + classFilter(equip.getElements, classOf[xml.Status]) ++ + classFilter(equip.getElements, classOf[xml.Counter])).map(traverse) + + val commands = (classFilter(equip.getElements, classOf[xml.Control]) ++ + classFilter(equip.getElements, classOf[xml.Setpoint])).map(traverse) + + val desc = EntityDesc( + EntityFields( + EntityId(uuidOpt.map(UUID.fromString), nameOpt), + types.toSet, + keysAndHolders)) + + TreeNode(desc, childEquips ++ points ++ commands, ownerRefs, parentRefs, childRefs, Seq()) + } + + def traverse(pt: xml.PointType): TreeNode = { + val nameOpt = if (pt.isSetName) Some(pt.getName) else None + val uuidOpt = if (pt.isSetUuid) Some(pt.getUuid) else None + if (nameOpt.isEmpty && uuidOpt.isEmpty) throw new LoadingXmlException("Point must have name or UUID", pt) + + implicit val context = pt.getName + + val types = classFilter(pt.getElements, classOf[xml.Type]).map { elem => + if (elem.isSetName) elem.getName else throw new LoadingXmlException(s"Type element missing type name for ${pt.getName}", elem) + } + + val keysAndHolders = classFilter(pt.getElements, classOf[xml.KeyValue]).map(keyValFromXml) + + val triggerSetKvOpt = { + val triggerCollOpt = classFilter(pt.getElements, classOf[xml.PointType.Triggers]).headOption + + val triggerSetOpt = triggerCollOpt.map(TriggerActionConversion.triggersToProto) + + triggerSetOpt.map(ts => (TreeToXml.triggerSetKey, ByteArrayValue(ts.toByteArray))).toSeq + } + + val calcOpt = classFilter(pt.getElements, classOf[xml.Calculation]) + .headOption + .map(CalculationConversion.toProto) + .map(tup => UnresolvedCalc(tup._1, tup._2)) + + val keysAndTrigger = keysAndHolders ++ Seq(triggerSetKvOpt).flatten + + val pointCategory = pt match { + case _: xml.Analog => PointCategory.ANALOG + case _: xml.Status => PointCategory.STATUS + case _: xml.Counter => PointCategory.COUNTER + } + + val unit = if (pt.isSetUnit) pt.getUnit else throw new LoadingXmlException(s"Unit missing from point element for ${pt.getName}", pt) + + val ownerRefs = extractOwnerRefs(pt.getElements) + + val parentRefs = extractParentRefs(pt.getElements) + + val childRefs = extractChildRefs(pt.getElements) + + val commandRefs = extractCommandRefs(pt.getElements) + + val desc = PointDesc( + EntityFields( + EntityId(uuidOpt.map(UUID.fromString), nameOpt), + types.toSet, + keysAndTrigger), + pointCategory, + unit, + calcOpt.getOrElse(NoCalculation)) + + TreeNode(desc, Seq(), ownerRefs, parentRefs, childRefs, commandRefs) + } + + def traverse(elem: xml.Command): TreeNode = { + val nameOpt = if (elem.isSetName) Some(elem.getName) else None + val uuidOpt = if (elem.isSetUuid) Some(elem.getUuid) else None + if (nameOpt.isEmpty && uuidOpt.isEmpty) throw new LoadingXmlException("Command must have name or UUID", elem) + + val types = classFilter(elem.getElements, classOf[xml.Type]).map { elem => + if (elem.isSetName) elem.getName else throw new LoadingXmlException(s"Type element missing type name for ${elem.getName}", elem) + } + + val keysAndHolders = classFilter(elem.getElements, classOf[xml.KeyValue]).map(keyValFromXml) + + val commandCategory = elem match { + case _: xml.Control => CommandCategory.CONTROL + case sp: xml.Setpoint => + if (sp.isSetSetpointType) { + sp.getSetpointType match { + case SetpointType.DOUBLE => CommandCategory.SETPOINT_DOUBLE + case SetpointType.INTEGER => CommandCategory.SETPOINT_INT + case SetpointType.STRING => CommandCategory.SETPOINT_STRING + } + } else { + CommandCategory.SETPOINT_DOUBLE + } + } + + val displayName = if (elem.isSetDisplayName) elem.getDisplayName else throw new LoadingXmlException(s"Display name missing from command element for ${elem.getName}", elem) + + val ownerRefs = extractOwnerRefs(elem.getElements) + + val parentRefs = extractParentRefs(elem.getElements) + + val childRefs = extractChildRefs(elem.getElements) + + val desc = CommandDesc( + EntityFields( + EntityId(uuidOpt.map(UUID.fromString), nameOpt), + types.toSet, + keysAndHolders), + displayName, + commandCategory) + + TreeNode(desc, Seq(), ownerRefs, parentRefs, childRefs, Seq()) + } + + def convertEndpoint(elem: xml.Endpoint): EndpointNode = { + val nameOpt = if (elem.isSetName) Some(elem.getName) else None + val uuidOpt = if (elem.isSetUuid) Some(elem.getUuid) else None + if (nameOpt.isEmpty && uuidOpt.isEmpty) throw new LoadingXmlException("Endpoint must have name or UUID", elem) + + val protocol = if (elem.isSetProtocol) elem.getProtocol else throw new LoadingException(s"Protocol name must be set for endpoint for ${elem.getName}") + + val types = classFilter(elem.getElements, classOf[xml.Type]).map { elem => + if (elem.isSetName) elem.getName else throw new LoadingXmlException(s"Type element missing type name for ${elem.getName}", elem) + } + + val keysAndHolders = classFilter(elem.getElements, classOf[xml.KeyValue]).map(keyValFromXml) + + val sourceRefs = extractSourceRefs(elem.getElements) + + val parentRefs = extractParentRefs(elem.getElements) + + val childRefs = extractChildRefs(elem.getElements) + + EndpointNode( + EndpointDesc( + EntityFields( + EntityId(uuidOpt.map(UUID.fromString), nameOpt), + types.toSet, + keysAndHolders), + protocol), + sourceRefs, + parentRefs, + childRefs) + } +} diff --git a/loader-xml/src/main/scala/io/greenbus/ldr/auth/AuthConversion.scala b/loader-xml/src/main/scala/io/greenbus/ldr/auth/AuthConversion.scala new file mode 100644 index 0000000..53c7438 --- /dev/null +++ b/loader-xml/src/main/scala/io/greenbus/ldr/auth/AuthConversion.scala @@ -0,0 +1,199 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.ldr.auth + +import java.util.UUID + +import io.greenbus.ldr.xml.auth._ +import io.greenbus.ldr.XmlToModel +import io.greenbus.loader.set.LoadingException + +import scala.collection.JavaConversions._ + +object AuthConversion { + + def toXml(authMdl: AuthMdl.AuthSet): Authorization = { + + val xmlSets = authMdl.permSets.map(toXml) + + val xmlAgents = authMdl.agents.map(toXml) + + val xml = new Authorization + + val agentColl = new Agents + xmlAgents.foreach(agentColl.getAgent.add) + + val permsColl = new PermissionSets + xmlSets.foreach(permsColl.getPermissionSet.add) + + xml.setAgents(agentColl) + xml.setPermissionSets(permsColl) + + xml + } + + def toXml(agent: AuthMdl.Agent): Agent = { + val xml = new Agent + + agent.uuidOpt.foreach(u => xml.setUuid(u.toString)) + xml.setName(agent.name) + + val xmlPermRefs = agent.permissions.map { p => + val ps = new Agent.PermissionSet + ps.setName(p) + ps + } + + xmlPermRefs.foreach(xml.getPermissionSet.add) + + xml + } + + def toXml(permSet: AuthMdl.PermissionSet): PermissionSet = { + + val xml = new PermissionSet + + permSet.idOpt.foreach(id => xml.setId(id.toString)) + xml.setName(permSet.name) + + permSet.permissions.map(toXml).foreach(xml.getPermissions.add) + + xml + } + + def toXml(perm: AuthMdl.Permission): PermissionType = { + + val xml = if (perm.allow) new Allow else new Deny + + val actions = perm.actions.map { a => + val obj = new Action + obj.setName(a) + obj + } + + val resources = perm.resources.map { r => + val obj = new Resource + obj.setName(r) + obj + } + + val selectors = perm.selectors.map { sel => + val xmlSelector = new Selector + xmlSelector.setStyle(sel.style) + + val xmlArgs = sel.args.map { arg => + val a = new Argument + a.setValue(arg) + a + } + xmlArgs.foreach(xmlSelector.getArgument.add) + + xmlSelector + } + + actions.foreach(xml.getElements.add) + resources.foreach(xml.getElements.add) + selectors.foreach(xml.getElements.add) + + xml + } + + def toModel(set: Authorization): AuthMdl.AuthSet = { + + val agents = if (set.isSetAgents) { + set.getAgents.getAgent.map(toModel) + } else { + Seq() + } + + val permissionSets = if (set.isSetPermissionSets) { + set.getPermissionSets.getPermissionSet.map(toModel) + } else { + Seq() + } + + AuthMdl.AuthSet(permissionSets, agents) + } + + def toModel(agent: Agent): AuthMdl.Agent = { + + val name = if (agent.isSetName) agent.getName else throw new LoadingException("PermissionSet must include a name") + + val idOpt = if (agent.isSetUuid) { + try { + Some(UUID.fromString(agent.getUuid)) + } catch { + case ex: Throwable => + throw new LoadingException(s"UUID for Agent $name must be numeric") + } + } else { + None + } + + val permNames = agent.getPermissionSet.map { obj => + if (obj.isSetName) obj.getName else throw new LoadingException(s"Agent permissions for $name must include a name") + } + + AuthMdl.Agent(idOpt, name, permNames) + } + + def toModel(permSet: PermissionSet): AuthMdl.PermissionSet = { + + val name = if (permSet.isSetName) permSet.getName else throw new LoadingException("PermissionSet must include a name") + + val idOpt = if (permSet.isSetId) { + val idStr = permSet.getId + val idLong = try { idStr.toLong } catch { case ex: Throwable => throw new LoadingException(s"ID for PermissionSet $name must be numeric") } + Some(idLong) + } else { + None + } + + val perms = permSet.getPermissions.map(toModel) + + AuthMdl.PermissionSet(idOpt, name, perms) + } + + def toModel(permissionType: PermissionType): AuthMdl.Permission = { + + val isAllow = permissionType match { + case _: Allow => true + case _: Deny => false + } + + val resources = XmlToModel.classFilter(permissionType.getElements.toSeq, classOf[Resource]).map { obj => + if (obj.isSetName) obj.getName else throw new LoadingException("Resources must include a name") + } + + val actions = XmlToModel.classFilter(permissionType.getElements.toSeq, classOf[Action]).map { obj => + if (obj.isSetName) obj.getName else throw new LoadingException("Actions must include a name") + } + + val selectors = XmlToModel.classFilter(permissionType.getElements.toSeq, classOf[Selector]).map { obj => + val style = if (obj.isSetStyle) obj.getStyle else throw new LoadingException("Selectors must include a style") + val args = obj.getArgument.map { arg => + if (arg.isSetValue) arg.getValue else throw new LoadingException("Arguments must include a value") + } + AuthMdl.EntitySelector(style, args) + } + + AuthMdl.Permission(isAllow, resources, actions, selectors) + } + +} diff --git a/loader-xml/src/main/scala/io/greenbus/ldr/auth/AuthMdl.scala b/loader-xml/src/main/scala/io/greenbus/ldr/auth/AuthMdl.scala new file mode 100644 index 0000000..9fba560 --- /dev/null +++ b/loader-xml/src/main/scala/io/greenbus/ldr/auth/AuthMdl.scala @@ -0,0 +1,47 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.ldr.auth + +import java.util.UUID + +object AuthMdl { + + case class EntitySelector(style: String, args: Seq[String]) + + case class Permission(allow: Boolean, resources: Seq[String], actions: Seq[String], selectors: Seq[EntitySelector]) + + case class PermissionSet(idOpt: Option[Long], name: String, permissions: Seq[Permission]) + + case class Agent(uuidOpt: Option[UUID], name: String, permissions: Seq[String], passwordOpt: Option[String] = None) + + case class AuthSet(permSets: Seq[PermissionSet], agents: Seq[Agent]) + + def getIdSetForAgents(agents: Seq[Agent]): (Seq[UUID], Seq[String]) = { + val uuids = Vector.newBuilder[UUID] + val names = Vector.newBuilder[String] + + agents.foreach { agent => + agent.uuidOpt match { + case Some(uuid) => uuids += uuid + case None => names += agent.name + } + } + (uuids.result(), names.result()) + } +} diff --git a/loader-xml/src/main/scala/io/greenbus/ldr/auth/Download.scala b/loader-xml/src/main/scala/io/greenbus/ldr/auth/Download.scala new file mode 100644 index 0000000..cfd3b75 --- /dev/null +++ b/loader-xml/src/main/scala/io/greenbus/ldr/auth/Download.scala @@ -0,0 +1,119 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.ldr.auth + +import java.util.UUID + +import io.greenbus.client.service.proto.ModelRequests.EntityPagingParams +import io.greenbus.msg.Session +import io.greenbus.client.service.AuthService +import io.greenbus.client.service.proto.Auth.{ Permission, Agent, PermissionSet } +import io.greenbus.client.service.proto.AuthRequests.{ AgentKeySet, AgentQuery, PermissionSetQuery } +import io.greenbus.client.service.proto.Model.{ ModelUUID, ModelID } +import io.greenbus.ldr.auth.Download.AuthDownloadSet +import io.greenbus.loader.set.{ UUIDHelpers, Downloader } + +import scala.collection.JavaConversions._ +import scala.concurrent.duration._ + +import scala.concurrent.{ Await, Future } + +object Download { + + case class AuthDownloadSet(perms: Seq[PermissionSet], agents: Seq[Agent]) + + def getAgents(session: Session, uuids: Seq[UUID], names: Seq[String]): Seq[Agent] = { + val client = AuthService.client(session) + + val allKeys = uuids ++ names + + allKeys.grouped(300).flatMap { keyGroup => + val keySet = AgentKeySet.newBuilder() + + keyGroup.foreach { + case uuid: UUID => keySet.addUuids(UUIDHelpers.uuidToProtoUUID(uuid)) + case name: String => keySet.addNames(name) + } + + Await.result(client.getAgents(keySet.build()), 10000.milliseconds) + }.toVector + } + + def download(session: Session) = { + val client = AuthService.client(session) + + def permQuery(last: Option[ModelID], size: Int): Future[Seq[PermissionSet]] = { + val b = PermissionSetQuery.newBuilder().setPageSize(size) + last.foreach(b.setLastId) + client.permissionSetQuery(b.build()) + } + + def permPageBoundary(results: Seq[PermissionSet]): ModelID = { + results.last.getId + } + + val permSets = Downloader.allPages(200, permQuery, permPageBoundary) + + def agentQuery(last: Option[ModelUUID], size: Int): Future[Seq[Agent]] = { + + val pageB = EntityPagingParams.newBuilder().setPageSize(size) + + last.foreach(pageB.setLastUuid) + + client.agentQuery(AgentQuery.newBuilder().setPagingParams(pageB.build()).build()) + } + + def agentPageBoundary(results: Seq[Agent]): ModelUUID = { + results.last.getUuid + } + + val agents = Downloader.allPages(200, agentQuery, agentPageBoundary) + + AuthDownloadSet(permSets, agents) + } + +} + +object DownloadConversion { + + def toIntermediate(downloadSet: AuthDownloadSet): AuthMdl.AuthSet = { + val permSets = downloadSet.perms.map { permSet => + AuthMdl.PermissionSet(Some(UUIDHelpers.protoIdToLong(permSet.getId)), permSet.getName, permSet.getPermissionsList.map(permissionToMdl)) + } + + val agents = downloadSet.agents.map { agent => + AuthMdl.Agent(Some(UUIDHelpers.protoUUIDToUuid(agent.getUuid)), agent.getName, agent.getPermissionSetsList.toSeq) + } + + AuthMdl.AuthSet(permSets, agents) + } + + def permissionToMdl(proto: Permission): AuthMdl.Permission = { + val allow = proto.getAllow + val actions = proto.getActionsList.toSeq + val resources = proto.getResourcesList.toSeq + + val selectors = proto.getSelectorsList.map { selector => + AuthMdl.EntitySelector(selector.getStyle, selector.getArgumentsList.toSeq) + } + + AuthMdl.Permission(allow, resources = resources, actions = actions, selectors) + } + +} diff --git a/loader-xml/src/main/scala/io/greenbus/ldr/auth/Exporter.scala b/loader-xml/src/main/scala/io/greenbus/ldr/auth/Exporter.scala new file mode 100644 index 0000000..00415b5 --- /dev/null +++ b/loader-xml/src/main/scala/io/greenbus/ldr/auth/Exporter.scala @@ -0,0 +1,141 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.ldr.auth + +import java.io.File + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.ldr.xml.auth.Authorization +import org.apache.commons.cli +import org.apache.commons.cli.{ HelpFormatter, Options } +import org.apache.commons.io.FileUtils +import io.greenbus.msg.amqp.AmqpSettings +import io.greenbus.msg.qpid.QpidBroker +import io.greenbus.client.ServiceConnection +import io.greenbus.loader.set.LoadingException +import io.greenbus.util.{ UserSettings, XmlHelper } + +import scala.concurrent.duration._ + +object Exporter extends Logging { + + val outputFlag = "output" + val directoryFlag = "directory" + + def buildOptions: Options = { + val opts = new Options + opts.addOption("h", "help", false, "Display this help text") + opts.addOption(new cli.Option("d", directoryFlag, true, "Directory to output files to")) + opts.addOption(new cli.Option("o", outputFlag, true, "Output filename")) + opts + } + + def main(args: Array[String]): Unit = { + try { + run(args) + } catch { + case ex: LoadingException => + System.err.println(ex.getMessage) + case ex: io.greenbus.msg.amqp.util.LoadingException => + System.err.println(ex.getMessage) + case ex: java.util.concurrent.TimeoutException => + System.err.println("Service request timed out") + case ex: Throwable => + logger.error("Unhandled exception: " + ex) + System.err.println("Error: " + ex.getMessage) + } + } + + def run(args: Array[String]): Unit = { + + val options = buildOptions + val parser = new cli.BasicParser + val line = parser.parse(options, args) + + def printHelp() { + (new HelpFormatter).printHelp("loader file", options) + } + + if (line.hasOption("h")) { + printHelp() + } else { + + val dirOpt = Option(line.getOptionValue(directoryFlag)) + + val outOpt = Option(line.getOptionValue(outputFlag)) + + export(outOpt, dirOpt) + } + } + + def export(outputOpt: Option[String], dirOpt: Option[String]) = { + + val baseDir = Option(System.getProperty("io.greenbus.config.base")).getOrElse("") + val amqpConfigPath = Option(System.getProperty("io.greenbus.config.amqp")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.msg.amqp.cfg") + val userConfigPath = Option(System.getProperty("io.greenbus.config.user")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.user.cfg") + + val config = AmqpSettings.load(amqpConfigPath) + val userConfig = UserSettings.load(userConfigPath) + + val conn = try { + ServiceConnection.connect(config, QpidBroker, 10000) + } catch { + case ex: java.io.IOException => throw new LoadingException(ex.getMessage) + } + + try { + val session = conn.login(userConfig.user, userConfig.password, Duration(5000, MILLISECONDS)) + + val downloadSet = Download.download(session) + + val model = DownloadConversion.toIntermediate(downloadSet) + + val xml = AuthConversion.toXml(model) + + val text = XmlHelper.writeToString(xml, classOf[Authorization], formatted = true) + + dirOpt match { + case Some(dirname) => + writeInDir(text, outputOpt, dirname) + case None => + outputOpt match { + case Some(filename) => + FileUtils.write(new File(filename), text) + case None => + println(text) + } + } + + } finally { + conn.disconnect() + } + } + + def writeInDir(main: String, outNameOpt: Option[String], dirname: String): Unit = { + val dirFile = new File(dirname) + if (!dirFile.exists()) { + dirFile.mkdir() + } + + val mainFilename = outNameOpt.getOrElse("authorization.xml") + + val mainFile = new File(dirFile, mainFilename) + FileUtils.write(mainFile, main) + } +} diff --git a/loader-xml/src/main/scala/io/greenbus/ldr/auth/Importer.scala b/loader-xml/src/main/scala/io/greenbus/ldr/auth/Importer.scala new file mode 100644 index 0000000..6f5a2dc --- /dev/null +++ b/loader-xml/src/main/scala/io/greenbus/ldr/auth/Importer.scala @@ -0,0 +1,154 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.ldr.auth + +import java.io.File + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.ldr.xml.auth.Authorization +import org.apache.commons.cli +import org.apache.commons.cli.{ HelpFormatter, Options } +import io.greenbus.msg.amqp.AmqpSettings +import io.greenbus.msg.qpid.QpidBroker +import io.greenbus.client.ServiceConnection +import io.greenbus.loader.set.{ UUIDHelpers, LoadingException } +import io.greenbus.util.{ UserSettings, XmlHelper } + +import scala.concurrent.duration._ + +object Importer extends Logging { + + def buildOptions: Options = { + val opts = new Options + opts.addOption("h", "help", false, "Display this help text") + opts + } + + def main(args: Array[String]): Unit = { + try { + run(args) + } catch { + case ex: LoadingException => + System.err.println(ex.getMessage) + case ex: io.greenbus.msg.amqp.util.LoadingException => + System.err.println(ex.getMessage) + case ex: java.util.concurrent.TimeoutException => + System.err.println("Service request timed out") + case ex: Throwable => + logger.error("Unhandled exception: " + ex) + System.err.println("Error: " + ex.getMessage) + } + } + + def run(args: Array[String]): Unit = { + + val options = buildOptions + val parser = new cli.BasicParser + val line = parser.parse(options, args) + + def printHelp() { + (new HelpFormatter).printHelp("loader file", options) + } + + if (line.hasOption("h")) { + printHelp() + } else { + + if (line.getArgs.length < 1) { + System.err.println("Must include file to import.") + System.exit(1) + } + val filename = line.getArgs.head + + val file = new File(filename) + val parentDir = file.getParentFile + + val back = XmlHelper.read(file, classOf[Authorization]) + + val model = AuthConversion.toModel(back) + + importAuth(model) + } + } + + def importAuth(mdl: AuthMdl.AuthSet) = { + + val baseDir = Option(System.getProperty("io.greenbus.config.base")).getOrElse("") + val amqpConfigPath = Option(System.getProperty("io.greenbus.config.amqp")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.msg.amqp.cfg") + val userConfigPath = Option(System.getProperty("io.greenbus.config.user")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.user.cfg") + + val config = AmqpSettings.load(amqpConfigPath) + val userConfig = UserSettings.load(userConfigPath) + + val conn = try { + ServiceConnection.connect(config, QpidBroker, 10000) + } catch { + case ex: java.io.IOException => throw new LoadingException(ex.getMessage) + } + + try { + + val session = conn.login(userConfig.user, userConfig.password, Duration(5000, MILLISECONDS)) + + val (uuids, names) = AuthMdl.getIdSetForAgents(mdl.agents) + + val currentAgents = Download.getAgents(session, uuids, names) + + val uuidsSet = currentAgents.map(_.getUuid).map(UUIDHelpers.protoUUIDToUuid).toSet + val namesSet = currentAgents.map(_.getName).toSet + + val agents = mdl.agents.map { agent => + if (agent.uuidOpt.exists(uuidsSet.contains) || namesSet.contains(agent.name)) { + agent + } else { + + def prompt(retries: Int): String = { + if (retries == 0) { + throw new IllegalArgumentException("Failed to provide a password") + } else { + + println(s"Enter new password for agent ${agent.name}: ") + val first = readLine() + println("Re-enter password: ") + val second = readLine() + if (first == second) { + first + } else { + println("Passwords did not match.\n") + prompt(retries - 1) + } + } + } + + val password = prompt(3) + + agent.copy(passwordOpt = Some(password)) + } + } + + val withPasswords = mdl.copy(agents = agents) + + Upload.upload(session, withPasswords) + + } finally { + conn.disconnect() + } + } + +} diff --git a/loader-xml/src/main/scala/io/greenbus/ldr/auth/Upload.scala b/loader-xml/src/main/scala/io/greenbus/ldr/auth/Upload.scala new file mode 100644 index 0000000..88ffbae --- /dev/null +++ b/loader-xml/src/main/scala/io/greenbus/ldr/auth/Upload.scala @@ -0,0 +1,88 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.ldr.auth + +import io.greenbus.msg.Session +import io.greenbus.client.service.AuthService +import io.greenbus.client.service.proto.Auth.{ EntitySelector, Permission } +import io.greenbus.client.service.proto.AuthRequests.{ PermissionSetTemplate, AgentTemplate } +import io.greenbus.ldr.auth.AuthMdl.{ PermissionSet, Agent, AuthSet } +import io.greenbus.loader.set.{ PrintTracker, UUIDHelpers } +import io.greenbus.util.Timing + +import scala.collection.JavaConversions._ + +object Upload { + + import io.greenbus.loader.set.Upload._ + + def upload(session: Session, mdl: AuthSet) = { + val timer = Timing.Stopwatch.start + val tracker = new PrintTracker(60, System.out) + + val authClient = AuthService.client(session) + + println("\n== Progress:") + + val permSets = chunkedCalls(allChunkSize, mdl.permSets.map(toTemplate), authClient.putPermissionSets, tracker) + + val agents = chunkedCalls(allChunkSize, mdl.agents.map(toTemplate), authClient.putAgents, tracker) + + println("\n") + println(s"== Finished in ${timer.elapsed} ms") + } + + def toTemplate(agent: Agent): AgentTemplate = { + val b = AgentTemplate.newBuilder() + .setName(agent.name) + .addAllPermissionSets(agent.permissions) + + agent.uuidOpt.map(UUIDHelpers.uuidToProtoUUID).foreach(b.setUuid) + agent.passwordOpt.foreach(b.setPassword) + + b.build() + } + + def toTemplate(permSet: PermissionSet): PermissionSetTemplate = { + + val b = PermissionSetTemplate.newBuilder() + .setName(permSet.name) + .addAllPermissions(permSet.permissions.map(toProto)) + + permSet.idOpt.map(UUIDHelpers.longToProtoId).foreach(b.setId) + + b.build() + } + + def toProto(mdl: AuthMdl.Permission): Permission = { + Permission.newBuilder() + .setAllow(mdl.allow) + .addAllActions(mdl.actions) + .addAllResources(mdl.resources) + .addAllSelectors(mdl.selectors.map(toProto)) + .build() + } + + def toProto(mdl: AuthMdl.EntitySelector): EntitySelector = { + EntitySelector.newBuilder() + .setStyle(mdl.style) + .addAllArguments(mdl.args) + .build() + } +} diff --git a/loader-xml/src/main/scala/io/greenbus/ldr/event/Download.scala b/loader-xml/src/main/scala/io/greenbus/ldr/event/Download.scala new file mode 100644 index 0000000..d8dabdd --- /dev/null +++ b/loader-xml/src/main/scala/io/greenbus/ldr/event/Download.scala @@ -0,0 +1,57 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.ldr.event + +import io.greenbus.msg.Session +import io.greenbus.client.service.EventService +import io.greenbus.client.service.proto.EventRequests.EventConfigQuery +import io.greenbus.client.service.proto.Events.EventConfig +import io.greenbus.loader.set.Downloader + +import scala.concurrent.Future + +object Download { + + def download(session: Session): Seq[EventConfig] = { + val client = EventService.client(session) + + def configQuery(last: Option[String], size: Int): Future[Seq[EventConfig]] = { + val b = EventConfigQuery.newBuilder().setPageSize(size) + last.foreach(b.setLastEventType) + client.eventConfigQuery(b.build()) + } + + def configPageBoundary(results: Seq[EventConfig]): String = { + results.last.getEventType + } + + Downloader.allPages(200, configQuery, configPageBoundary) + } + + def toMdl(cfg: EventConfig): EventMdl.EventConfig = { + + val eventType = cfg.getEventType + val designation = cfg.getDesignation + val alarmStateOpt = if (cfg.hasAlarmState) Some(cfg.getAlarmState) else None + val severity = cfg.getSeverity + val resource = cfg.getResource + + EventMdl.EventConfig(eventType, severity, designation, alarmStateOpt, resource) + } +} diff --git a/loader-xml/src/main/scala/io/greenbus/ldr/event/EventMdl.scala b/loader-xml/src/main/scala/io/greenbus/ldr/event/EventMdl.scala new file mode 100644 index 0000000..a5fb3d5 --- /dev/null +++ b/loader-xml/src/main/scala/io/greenbus/ldr/event/EventMdl.scala @@ -0,0 +1,111 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.ldr.event + +import io.greenbus.ldr.xml.events.{ EventConfigs, Events, EventConfig } +import io.greenbus.client.service.proto.Events.Alarm +import io.greenbus.client.service.proto.Events.EventConfig.Designation +import io.greenbus.loader.set.LoadingException + +import scala.collection.JavaConversions._ + +object EventMdl { + + case class EventConfig(typ: String, severity: Int, designation: Designation, initialAlarmState: Option[Alarm.State], resource: String) + +} + +object EventConversion { + + def toXml(all: Seq[EventMdl.EventConfig]): Events = { + val xml = new Events + + val coll = new EventConfigs + all.map(toXml).foreach(coll.getEventConfig.add) + + xml.setEventConfigs(coll) + + xml + } + + def toXml(mdl: EventMdl.EventConfig): EventConfig = { + val xml = new EventConfig + xml.setEventType(mdl.typ) + xml.setSeverity(mdl.severity) + xml.setDesignation(toXml(mdl.designation)) + xml.setValue(mdl.resource) + mdl.initialAlarmState.map(toXml).foreach(xml.setInitialAlarmState) + xml + } + + def toXml(des: Designation): String = { + des match { + case Designation.EVENT => "EVENT" + case Designation.ALARM => "ALARM" + case Designation.LOG => "LOG" + } + } + + def toXml(alarmState: Alarm.State): String = { + alarmState match { + case Alarm.State.UNACK_AUDIBLE => "UNACK_AUDIBLE" + case Alarm.State.UNACK_SILENT => "UNACK_SILENT" + case Alarm.State.ACKNOWLEDGED => "ACKNOWLEDGED" + case Alarm.State.REMOVED => "REMOVED" + } + } + + def toModel(xml: Events): Seq[EventMdl.EventConfig] = { + if (xml.isSetEventConfigs) { + xml.getEventConfigs.getEventConfig.map(toModel) + } else { + Seq() + } + } + + def toModel(eventConfig: EventConfig): EventMdl.EventConfig = { + + val eventType = if (eventConfig.isSetEventType) eventConfig.getEventType else throw new LoadingException("EventConfig must include event type") + val severity = if (eventConfig.isSetSeverity) eventConfig.getSeverity else throw new LoadingException(s"EventConfig $eventType must include severity") + val designationStr = if (eventConfig.isSetDesignation) eventConfig.getDesignation else throw new LoadingException(s"EventConfig $eventType must include designation") + val initialAlarmStrOpt = if (eventConfig.isSetInitialAlarmState) Some(eventConfig.getInitialAlarmState) else None + val resource = if (eventConfig.isSetValue) eventConfig.getValue else throw new LoadingException(s"EventConfig $eventType must include resource string") + + EventMdl.EventConfig(eventType, severity, desigToModel(designationStr), initialAlarmStrOpt.map(stateToModel), resource) + } + + def desigToModel(des: String): Designation = { + des match { + case "EVENT" => Designation.EVENT + case "ALARM" => Designation.ALARM + case "LOG" => Designation.LOG + } + } + + def stateToModel(alarmState: String): Alarm.State = { + alarmState match { + case "UNACK_AUDIBLE" => Alarm.State.UNACK_AUDIBLE + case "UNACK_SILENT" => Alarm.State.UNACK_SILENT + case "ACKNOWLEDGED" => Alarm.State.ACKNOWLEDGED + case "REMOVED" => Alarm.State.REMOVED + } + } + +} + diff --git a/loader-xml/src/main/scala/io/greenbus/ldr/event/Exporter.scala b/loader-xml/src/main/scala/io/greenbus/ldr/event/Exporter.scala new file mode 100644 index 0000000..9cf6a49 --- /dev/null +++ b/loader-xml/src/main/scala/io/greenbus/ldr/event/Exporter.scala @@ -0,0 +1,141 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.ldr.event + +import java.io.File + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.client.ServiceConnection +import io.greenbus.ldr.xml.events.Events +import io.greenbus.loader.set.LoadingException +import io.greenbus.msg.amqp.AmqpSettings +import io.greenbus.msg.qpid.QpidBroker +import io.greenbus.util.{ UserSettings, XmlHelper } +import org.apache.commons.cli +import org.apache.commons.cli.{ HelpFormatter, Options } +import org.apache.commons.io.FileUtils + +import scala.concurrent.duration._ + +object Exporter extends Logging { + + val outputFlag = "output" + val directoryFlag = "directory" + + def buildOptions: Options = { + val opts = new Options + opts.addOption("h", "help", false, "Display this help text") + opts.addOption(new cli.Option("d", directoryFlag, true, "Directory to output files to")) + opts.addOption(new cli.Option("o", outputFlag, true, "Output filename")) + opts + } + + def main(args: Array[String]): Unit = { + try { + run(args) + } catch { + case ex: LoadingException => + System.err.println(ex.getMessage) + case ex: io.greenbus.msg.amqp.util.LoadingException => + System.err.println(ex.getMessage) + case ex: java.util.concurrent.TimeoutException => + System.err.println("Service request timed out") + case ex: Throwable => + logger.error("Unhandled exception: " + ex) + System.err.println("Error: " + ex.getMessage) + } + } + + def run(args: Array[String]): Unit = { + + val options = buildOptions + val parser = new cli.BasicParser + val line = parser.parse(options, args) + + def printHelp() { + (new HelpFormatter).printHelp("loader file", options) + } + + if (line.hasOption("h")) { + printHelp() + } else { + + val dirOpt = Option(line.getOptionValue(directoryFlag)) + + val outOpt = Option(line.getOptionValue(outputFlag)) + + export(outOpt, dirOpt) + } + } + + def export(outputOpt: Option[String], dirOpt: Option[String]) = { + + val baseDir = Option(System.getProperty("io.greenbus.config.base")).getOrElse("") + val amqpConfigPath = Option(System.getProperty("io.greenbus.config.amqp")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.msg.amqp.cfg") + val userConfigPath = Option(System.getProperty("io.greenbus.config.user")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.user.cfg") + + val config = AmqpSettings.load(amqpConfigPath) + val userConfig = UserSettings.load(userConfigPath) + + val conn = try { + ServiceConnection.connect(config, QpidBroker, 10000) + } catch { + case ex: java.io.IOException => throw new LoadingException(ex.getMessage) + } + + try { + val session = conn.login(userConfig.user, userConfig.password, Duration(5000, MILLISECONDS)) + + val downloadSet = Download.download(session) + + val allConfigs = downloadSet.map(Download.toMdl) + + val xml = EventConversion.toXml(allConfigs) + + val text = XmlHelper.writeToString(xml, classOf[Events], formatted = true) + + dirOpt match { + case Some(dirname) => + writeInDir(text, outputOpt, dirname) + case None => + outputOpt match { + case Some(filename) => + FileUtils.write(new File(filename), text) + case None => + println(text) + } + } + + } finally { + conn.disconnect() + } + } + + def writeInDir(main: String, outNameOpt: Option[String], dirname: String): Unit = { + val dirFile = new File(dirname) + if (!dirFile.exists()) { + dirFile.mkdir() + } + + val mainFilename = outNameOpt.getOrElse("event-config.xml") + + val mainFile = new File(dirFile, mainFilename) + FileUtils.write(mainFile, main) + } +} diff --git a/loader-xml/src/main/scala/io/greenbus/ldr/event/Importer.scala b/loader-xml/src/main/scala/io/greenbus/ldr/event/Importer.scala new file mode 100644 index 0000000..a0b81d3 --- /dev/null +++ b/loader-xml/src/main/scala/io/greenbus/ldr/event/Importer.scala @@ -0,0 +1,114 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.ldr.event + +import java.io.File + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.ldr.xml.events.Events +import org.apache.commons.cli +import org.apache.commons.cli.{ HelpFormatter, Options } +import io.greenbus.msg.amqp.AmqpSettings +import io.greenbus.msg.qpid.QpidBroker +import io.greenbus.client.ServiceConnection +import io.greenbus.loader.set.LoadingException +import io.greenbus.util.{ UserSettings, XmlHelper } + +import scala.concurrent.duration._ + +object Importer extends Logging { + + def buildOptions: Options = { + val opts = new Options + opts.addOption("h", "help", false, "Display this help text") + opts + } + + def main(args: Array[String]): Unit = { + try { + run(args) + } catch { + case ex: LoadingException => + System.err.println(ex.getMessage) + case ex: io.greenbus.msg.amqp.util.LoadingException => + System.err.println(ex.getMessage) + case ex: java.util.concurrent.TimeoutException => + System.err.println("Service request timed out") + case ex: Throwable => + logger.error("Unhandled exception: " + ex) + System.err.println("Error: " + ex.getMessage) + } + } + + def run(args: Array[String]): Unit = { + + val options = buildOptions + val parser = new cli.BasicParser + val line = parser.parse(options, args) + + def printHelp() { + (new HelpFormatter).printHelp("loader file", options) + } + + if (line.hasOption("h")) { + printHelp() + } else { + + if (line.getArgs.length < 1) { + System.err.println("Must include file to import.") + System.exit(1) + } + val filename = line.getArgs.head + + val file = new File(filename) + val parentDir = file.getParentFile + + val back = XmlHelper.read(file, classOf[Events]) + + val model = EventConversion.toModel(back) + + importEventConfig(model) + } + } + + def importEventConfig(all: Seq[EventMdl.EventConfig]) = { + + val baseDir = Option(System.getProperty("io.greenbus.config.base")).getOrElse("") + val amqpConfigPath = Option(System.getProperty("io.greenbus.config.amqp")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.msg.amqp.cfg") + val userConfigPath = Option(System.getProperty("io.greenbus.config.user")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.user.cfg") + + val config = AmqpSettings.load(amqpConfigPath) + val userConfig = UserSettings.load(userConfigPath) + + val conn = try { + ServiceConnection.connect(config, QpidBroker, 10000) + } catch { + case ex: java.io.IOException => throw new LoadingException(ex.getMessage) + } + + try { + val session = conn.login(userConfig.user, userConfig.password, Duration(5000, MILLISECONDS)) + + Upload.upload(session, all) + + } finally { + conn.disconnect() + } + } +} diff --git a/loader-xml/src/main/scala/io/greenbus/ldr/event/Upload.scala b/loader-xml/src/main/scala/io/greenbus/ldr/event/Upload.scala new file mode 100644 index 0000000..49a1af0 --- /dev/null +++ b/loader-xml/src/main/scala/io/greenbus/ldr/event/Upload.scala @@ -0,0 +1,57 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.ldr.event + +import io.greenbus.msg.Session +import io.greenbus.client.service.EventService +import io.greenbus.client.service.proto.EventRequests.EventConfigTemplate +import io.greenbus.loader.set.PrintTracker +import io.greenbus.util.Timing + +object Upload { + + import io.greenbus.loader.set.Upload._ + + def upload(session: Session, all: Seq[EventMdl.EventConfig]) = { + val timer = Timing.Stopwatch.start + val tracker = new PrintTracker(60, System.out) + + val client = EventService.client(session) + + println("\n== Progress:") + + val eventConfigs = chunkedCalls(allChunkSize, all.map(toTemplate), client.putEventConfigs, tracker) + + println("\n") + println(s"== Finished in ${timer.elapsed} ms") + } + + def toTemplate(mdl: EventMdl.EventConfig): EventConfigTemplate = { + + val b = EventConfigTemplate.newBuilder() + .setEventType(mdl.typ) + .setSeverity(mdl.severity) + .setDesignation(mdl.designation) + .setResource(mdl.resource) + + mdl.initialAlarmState.foreach(b.setAlarmState) + + b.build() + } +} diff --git a/loading/pom.xml b/loading/pom.xml new file mode 100755 index 0000000..37b9012 --- /dev/null +++ b/loading/pom.xml @@ -0,0 +1,59 @@ + + 4.0.0 + + + io.greenbus + greenbus-scala-base + 3.0.0 + ../scala-base + + + greenbus-loading + jar + + + + AGPLv3 + http://www.gnu.org/licenses/agpl-3.0.txt + + + + + + + + com.mycila.maven-license-plugin + maven-license-plugin + +
../AGPL_FILE_HEADER
+
+
+
+
+ + + + io.greenbus + greenbus-client + 3.0.0 + + + io.greenbus + greenbus-util + 3.0.0 + + + io.greenbus.msg + greenbus-msg-qpid + 1.0.0 + + + commons-io + commons-io + 2.4 + + + + +
diff --git a/loading/src/main/scala/io/greenbus/loader/set/Actions.scala b/loading/src/main/scala/io/greenbus/loader/set/Actions.scala new file mode 100644 index 0000000..1284aa3 --- /dev/null +++ b/loading/src/main/scala/io/greenbus/loader/set/Actions.scala @@ -0,0 +1,271 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.loader.set + +import java.util.UUID + +import io.greenbus.client.service.proto.Model.{ PointCategory, CommandCategory } +import io.greenbus.loader.set.Mdl.EdgeDesc + +object Actions { + + case class PutEntity(uuidOpt: Option[UUID], name: String, types: Set[String]) + case class DeleteEntity(uuid: UUID) + + case class PutPoint(uuidOpt: Option[UUID], name: String, types: Set[String], category: PointCategory, unit: String) + case class DeletePoint(uuid: UUID) + + case class PutCommand(uuidOpt: Option[UUID], name: String, types: Set[String], category: CommandCategory, displayName: String) + case class DeleteCommand(uuid: UUID) + + case class PutEndpoint(uuidOpt: Option[UUID], name: String, types: Set[String], protocol: String) + case class DeleteEndpoint(uuid: UUID) + + case class PutEdge(desc: EdgeDesc) + case class DeleteEdge(parent: UUID, relationship: String, child: UUID) + + case class PutKeyValueByUuid(uuid: UUID, key: String, vh: ValueHolder) + case class PutKeyValueByName(name: String, key: String, vh: ValueHolder) + case class DeleteKeyValue(uuid: UUID, key: String) + + case class PutCalcByUuid(uuid: UUID, calc: CalculationHolder) + case class PutCalcByName(name: String, calc: CalculationHolder) + + class ActionCache { + + val entityPuts = Vector.newBuilder[PutEntity] + val entityDeletes = Vector.newBuilder[DeleteEntity] + + val pointPuts = Vector.newBuilder[PutPoint] + val pointDeletes = Vector.newBuilder[DeletePoint] + + val commandPuts = Vector.newBuilder[PutCommand] + val commandDeletes = Vector.newBuilder[DeleteCommand] + + val endpointPuts = Vector.newBuilder[PutEndpoint] + val endpointDeletes = Vector.newBuilder[DeleteEndpoint] + + val edgePuts = Vector.newBuilder[PutEdge] + val edgeDeletes = Vector.newBuilder[DeleteEdge] + + val calcPutsByUuid = Vector.newBuilder[PutCalcByUuid] + val calcPutsByName = Vector.newBuilder[PutCalcByName] + + val keyValuePutByUuids = Vector.newBuilder[PutKeyValueByUuid] + val keyValuePutByNames = Vector.newBuilder[PutKeyValueByName] + val keyValueDeletes = Vector.newBuilder[DeleteKeyValue] + + def result(): ActionsList = { + ActionsList( + entityPuts.result(), + entityDeletes.result(), + pointPuts.result(), + pointDeletes.result(), + commandPuts.result(), + commandDeletes.result(), + endpointPuts.result(), + endpointDeletes.result(), + edgePuts.result(), + edgeDeletes.result(), + keyValuePutByUuids.result(), + keyValuePutByNames.result(), + keyValueDeletes.result(), + calcPutsByUuid.result(), + calcPutsByName.result()) + } + } + + case class ActionsList( + entityPuts: Vector[PutEntity], + entityDeletes: Vector[DeleteEntity], + pointPuts: Vector[PutPoint], + pointDeletes: Vector[DeletePoint], + commandPuts: Vector[PutCommand], + commandDeletes: Vector[DeleteCommand], + endpointPuts: Vector[PutEndpoint], + endpointDeletes: Vector[DeleteEndpoint], + edgePuts: Vector[PutEdge], + edgeDeletes: Vector[DeleteEdge], + keyValuePutByUuids: Vector[PutKeyValueByUuid], + keyValuePutByNames: Vector[PutKeyValueByName], + keyValueDeletes: Vector[DeleteKeyValue], + calcPutsByUuid: Vector[PutCalcByUuid], + calcPutsByName: Vector[PutCalcByName]) + +} + +object Differences { + + trait EntBasedCreate { + val name: String + val types: Set[String] + val keys: Set[String] + val withUuid: Boolean + } + + trait EntBasedChange { + val currentName: String + val nameOpt: Option[ParamChange[String]] + val addedTypes: Set[String] + val removedTypes: Set[String] + val addedKeys: Set[String] + val modifiedKeys: Set[String] + val removedKeys: Set[String] + } + + case class ParamChange[A](original: A, updated: A) + + case class EntityCreated(name: String, types: Set[String], keys: Set[String], withUuid: Boolean = false) extends EntBasedCreate + + case class EntityChanged( + currentName: String, + nameOpt: Option[ParamChange[String]], + addedTypes: Set[String], + removedTypes: Set[String], + addedKeys: Set[String], + modifiedKeys: Set[String], + removedKeys: Set[String]) extends EntBasedChange + + case class EntityDeleted(name: String) + + case class PointCreated(name: String, types: Set[String], keys: Set[String], category: PointCategory, unit: String, withUuid: Boolean = false) extends EntBasedCreate + + case class PointChanged( + currentName: String, + nameOpt: Option[ParamChange[String]], + addedTypes: Set[String], + removedTypes: Set[String], + addedKeys: Set[String], + modifiedKeys: Set[String], + removedKeys: Set[String], + categoryOpt: Option[ParamChange[PointCategory]], + unitOpt: Option[ParamChange[String]]) extends EntBasedChange + + case class PointDeleted(name: String) + + case class CommandCreated(name: String, types: Set[String], keys: Set[String], category: CommandCategory, displayName: String, withUuid: Boolean = false) extends EntBasedCreate + + case class CommandChanged( + currentName: String, + nameOpt: Option[ParamChange[String]], + addedTypes: Set[String], + removedTypes: Set[String], + addedKeys: Set[String], + modifiedKeys: Set[String], + removedKeys: Set[String], + categoryOpt: Option[ParamChange[CommandCategory]], + displayNameOpt: Option[ParamChange[String]]) extends EntBasedChange + + case class CommandDeleted(name: String) + + case class EndpointCreated(name: String, types: Set[String], keys: Set[String], protocol: String, withUuid: Boolean = false) extends EntBasedCreate + + case class EndpointChanged( + currentName: String, + nameOpt: Option[ParamChange[String]], + addedTypes: Set[String], + removedTypes: Set[String], + addedKeys: Set[String], + modifiedKeys: Set[String], + removedKeys: Set[String], + protocolOpt: Option[ParamChange[String]]) extends EntBasedChange + + case class EndpointDeleted(name: String) + + case class KeyValueRecord(entityName: String, key: String) + + case class EdgeRecord(parent: String, relation: String, child: String) + + class RecordBuilder { + val entCreateRecords = Vector.newBuilder[EntityCreated] + val entChangedRecords = Vector.newBuilder[EntityChanged] + val entDeleteRecords = Vector.newBuilder[EntityDeleted] + + val pointCreateRecords = Vector.newBuilder[PointCreated] + val pointChangedRecords = Vector.newBuilder[PointChanged] + val pointDeleteRecords = Vector.newBuilder[PointDeleted] + + val commandCreateRecords = Vector.newBuilder[CommandCreated] + val commandChangedRecords = Vector.newBuilder[CommandChanged] + val commandDeleteRecords = Vector.newBuilder[CommandDeleted] + + val endpointCreateRecords = Vector.newBuilder[EndpointCreated] + val endpointChangedRecords = Vector.newBuilder[EndpointChanged] + val endpointDeleteRecords = Vector.newBuilder[EndpointDeleted] + + /*val keyValueCreateRecords = Vector.newBuilder[KeyValueRecord] + val keyValueChangedRecords = Vector.newBuilder[KeyValueRecord] + val keyValueDeleteRecords = Vector.newBuilder[KeyValueRecord]*/ + + val edgeCreateRecords = Vector.newBuilder[EdgeRecord] + val edgeDeleteRecords = Vector.newBuilder[EdgeRecord] + + def result(): DiffRecord = { + DiffRecord( + entCreateRecords.result(), + entChangedRecords.result(), + entDeleteRecords.result(), + pointCreateRecords.result(), + pointChangedRecords.result(), + pointDeleteRecords.result(), + commandCreateRecords.result(), + commandChangedRecords.result(), + commandDeleteRecords.result(), + endpointCreateRecords.result(), + endpointChangedRecords.result(), + endpointDeleteRecords.result(), + edgeCreateRecords.result(), + edgeDeleteRecords.result()) + } + } + + case class DiffRecord( + entCreateRecords: Vector[EntityCreated], + entChangedRecords: Vector[EntityChanged], + entDeleteRecords: Vector[EntityDeleted], + pointCreateRecords: Vector[PointCreated], + pointChangedRecords: Vector[PointChanged], + pointDeleteRecords: Vector[PointDeleted], + commandCreateRecords: Vector[CommandCreated], + commandChangedRecords: Vector[CommandChanged], + commandDeleteRecords: Vector[CommandDeleted], + endpointCreateRecords: Vector[EndpointCreated], + endpointChangedRecords: Vector[EndpointChanged], + endpointDeleteRecords: Vector[EndpointDeleted], + edgeCreateRecords: Vector[EdgeRecord], + edgeDeleteRecords: Vector[EdgeRecord]) { + + def isEmpty: Boolean = { + entCreateRecords.isEmpty && + entChangedRecords.isEmpty && + entDeleteRecords.isEmpty && + pointCreateRecords.isEmpty && + pointChangedRecords.isEmpty && + pointDeleteRecords.isEmpty && + commandCreateRecords.isEmpty && + commandChangedRecords.isEmpty && + commandDeleteRecords.isEmpty && + endpointCreateRecords.isEmpty && + endpointChangedRecords.isEmpty && + endpointDeleteRecords.isEmpty && + edgeCreateRecords.isEmpty && + edgeDeleteRecords.isEmpty + } + } +} diff --git a/loading/src/main/scala/io/greenbus/loader/set/Download.scala b/loading/src/main/scala/io/greenbus/loader/set/Download.scala new file mode 100644 index 0000000..1185cab --- /dev/null +++ b/loading/src/main/scala/io/greenbus/loader/set/Download.scala @@ -0,0 +1,521 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.loader.set + +import java.util.UUID + +import io.greenbus.msg.Session +import io.greenbus.client.service.ModelService +import io.greenbus.client.service.proto.Calculations.CalculationDescriptor +import io.greenbus.client.service.proto.Model._ +import io.greenbus.client.service.proto.ModelRequests.{ EntityEdgeQuery, EntityKeySet } +import io.greenbus.loader.set.Downloader.DownloadSet +import io.greenbus.loader.set.Mdl._ +import UUIDHelpers._ + +import scala.collection.JavaConversions._ +import scala.concurrent.duration._ +import scala.concurrent.{ Await, Future } + +object Downloader { + + case class DownloadSet( + modelEntities: Seq[Entity], + points: Seq[Point], + commands: Seq[Command], + endpoints: Seq[Endpoint], + edges: Seq[EntityEdge], + keyValues: Seq[EntityKeyValue]) + + val pageSizeForGet = 500 + val timeoutMs = 10000 + + private def entityIdsToKeySet(ids: Seq[EntityId]): Option[EntityKeySet] = { + + val (uuids, names) = NameResolver.mostSpecificIdElements(ids) + + if (uuids.nonEmpty || names.nonEmpty) { + Some(EntityKeySet.newBuilder() + .addAllUuids(uuids.map(UUIDHelpers.uuidToProtoUUID)) + .addAllNames(names) + .build()) + } else { + None + } + } + + def downloadByIdsAndNames(session: Session, rootIds: Seq[EntityId], rootNames: Seq[String], endpointIds: Seq[EntityId]) = { + + val (uuids, names) = NameResolver.mostSpecificIdElements(rootIds) + + val rootKeySet = EntityKeySet.newBuilder() + .addAllUuids(uuids.map(UUIDHelpers.uuidToProtoUUID)) + .addAllNames(names ++ rootNames) + .build() + + download(session, rootKeySet, entityIdsToKeySet(endpointIds)) + } + + def downloadByIds(session: Session, rootIds: Seq[EntityId], endpointIds: Seq[EntityId]): DownloadSet = { + + val rootKeySet = entityIdsToKeySet(rootIds).getOrElse { + throw new IllegalArgumentException("Must include at least one root id") + } + + download(session, rootKeySet, entityIdsToKeySet(endpointIds)) + } + + def download(session: Session, rootNames: Seq[String]): DownloadSet = { + + val rootKeySet = EntityKeySet.newBuilder().addAllNames(rootNames).build() + + download(session, rootKeySet, None) + } + + def download(session: Session, rootKeySet: EntityKeySet, endpointKeySetOpt: Option[EntityKeySet]): DownloadSet = { + + val modelClient = ModelService.client(session) + + val rootResults = Await.result(modelClient.get(rootKeySet), timeoutMs.milliseconds) + + if (rootResults.nonEmpty) { + downloadSet(session, rootResults, endpointKeySetOpt) + } else { + DownloadSet(Seq(), Seq(), Seq(), Seq(), Seq(), Seq()) + } + } + + private def downloadSet(session: Session, roots: Seq[Entity], endpointKeySetOpt: Option[EntityKeySet]): DownloadSet = { + + val rootUuids = roots.map(_.getUuid) + + val ownsSet = getOwnsSet(session, rootUuids) + + val ownsEntities = getEntities(session, ownsSet) + + val pointEntities = ownsEntities.filter(_.getTypesList.contains("Point")) + val pointUuids = pointEntities.map(_.getUuid) + val pointUuidSet = pointUuids.toSet + val points = getPoints(session, pointUuids) + + val commandEntities = ownsEntities.filter(_.getTypesList.contains("Command")) + val commandUuids = commandEntities.map(_.getUuid) + val commandUuidSet = commandUuids.toSet + val commands = getCommands(session, commandUuids) + + val allEdges = getAllEdges(session, ownsSet) + + val sourceEndpointUuids = endpointUuidsFromEdges(allEdges).distinct + + val endpointsBySource = getEndpoints(session, sourceEndpointUuids) + + val endpointsFromFragment = endpointKeySetOpt.map { keySet => + getEndpoints(session, keySet) + }.getOrElse(Seq()) + + val endpoints = distinctBy(endpointsBySource ++ endpointsFromFragment, { e: Endpoint => e.getUuid }) + val endpointUuids = endpoints.map(_.getUuid) + + val keyValues = getKeyValues(session, (ownsSet ++ endpointUuids).distinct) + + val modelEntities = ownsEntities.filterNot(ent => (pointUuidSet union commandUuidSet).contains(ent.getUuid)) + + DownloadSet(modelEntities, points, commands, endpoints, allEdges, keyValues) + } + + private def distinctBy[A, B](seq: Seq[A], id: A => B): Seq[A] = { + val b = Seq.newBuilder[A] + val seen = scala.collection.mutable.HashSet[B]() + for (x <- seq) { + val elemId = id(x) + if (!seen(elemId)) { + b += x + seen += elemId + } + } + b.result() + } + + private def getOwnsSet(session: Session, roots: Seq[ModelUUID]): Seq[ModelUUID] = { + val results = getOwnsSetEdges(session, roots) + roots ++ results.map(_.getChild) + } + + private def getOwnsSetEdges(session: Session, roots: Seq[ModelUUID]): Seq[EntityEdge] = { + + val modelClient = ModelService.client(session) + + def getEdgeQuery(last: Option[ModelID], size: Int): Future[Seq[EntityEdge]] = { + val b = EntityEdgeQuery.newBuilder() + .addAllParentUuids(roots) + .addRelationships("owns") + .setPageSize(size) + + last.foreach(b.setLastId) + + val query = b.build() + + modelClient.edgeQuery(query) + } + + def pageBoundary(results: Seq[EntityEdge]): ModelID = { + results.last.getId + } + + allPages(pageSizeForGet, getEdgeQuery, pageBoundary) + } + + private def endpointUuidsFromEdges(edges: Seq[EntityEdge]): Seq[ModelUUID] = { + edges.filter(_.getRelationship == "source").map(_.getParent) + } + + private def getAllEdges(session: Session, set: Seq[ModelUUID]): Seq[EntityEdge] = { + + val modelClient = ModelService.client(session) + + def parentQuery = { + EntityEdgeQuery.newBuilder() + .addAllParentUuids(set) + } + def childQuery = { + EntityEdgeQuery.newBuilder() + .addAllChildUuids(set) + } + + def edgeQuery(build: => EntityEdgeQuery.Builder)(last: Option[ModelID], size: Int): Future[Seq[EntityEdge]] = { + val b = build + .setDepthLimit(1) + .setPageSize(size) + + last.foreach(b.setLastId) + + val query = b.build() + + modelClient.edgeQuery(query) + } + + def pageBoundary(results: Seq[EntityEdge]): ModelID = { + results.last.getId + } + + (allPages(pageSizeForGet, edgeQuery(parentQuery), pageBoundary) ++ + allPages(pageSizeForGet, edgeQuery(childQuery), pageBoundary)).distinct + } + + def getObs[A](uuids: Seq[ModelUUID], get: EntityKeySet => Future[Seq[A]]): Seq[A] = { + uuids.grouped(pageSizeForGet).flatMap { set => + if (set.nonEmpty) { + val entKeySet = EntityKeySet.newBuilder().addAllUuids(set).build() + Await.result(get(entKeySet), timeoutMs.milliseconds) + } else { + Seq() + } + }.toVector + } + + private def getEntities(session: Session, uuids: Seq[ModelUUID]): Seq[Entity] = { + val modelClient = ModelService.client(session) + getObs(uuids, modelClient.get) + } + + private def getPoints(session: Session, uuids: Seq[ModelUUID]): Seq[Point] = { + val modelClient = ModelService.client(session) + getObs(uuids, modelClient.getPoints) + } + + private def getCommands(session: Session, uuids: Seq[ModelUUID]): Seq[Command] = { + val modelClient = ModelService.client(session) + getObs(uuids, modelClient.getCommands) + } + + private def getEndpoints(session: Session, uuids: Seq[ModelUUID]): Seq[Endpoint] = { + val modelClient = ModelService.client(session) + getObs(uuids, modelClient.getEndpoints) + } + + private def getEndpoints(session: Session, keySet: EntityKeySet): Seq[Endpoint] = { + val modelClient = ModelService.client(session) + Await.result(modelClient.getEndpoints(keySet), timeoutMs.milliseconds) + } + + private def getKeyValues(session: Session, uuids: Seq[ModelUUID]): Seq[EntityKeyValue] = { + val modelClient = ModelService.client(session) + + val keyPairs = uuids.grouped(pageSizeForGet).flatMap { set => + if (set.nonEmpty) { + Await.result(modelClient.getEntityKeys(set), timeoutMs.milliseconds) + } else { + Seq() + } + }.toVector + + keyPairs.grouped(pageSizeForGet).flatMap { set => + if (set.nonEmpty) { + Await.result(modelClient.getEntityKeyValues(set), timeoutMs.milliseconds) + } else { + Seq() + } + }.toVector + } + + def allPages[A, B](pageSize: Int, getResults: (Option[B], Int) => Future[Seq[A]], pageBoundary: Seq[A] => B): Seq[A] = { + + def rest(accum: Seq[A], boundary: Option[B]): Seq[A] = { + val results = Await.result(getResults(boundary, pageSize), timeoutMs.milliseconds) + if (results.isEmpty || results.size < pageSize) { + accum ++ results + } else { + rest(accum ++ results, Some(pageBoundary(results))) + } + } + + rest(Seq(), None) + } +} + +object NameResolver { + + val calculationKey = "calculation" + + def resolveFlatModelUuids(session: Session, model: FlatModelFragment): FlatModelFragment = { + + val allFields = model.modelEntities.map(_.fields) ++ model.points.map(_.fields) ++ model.commands.map(_.fields) ++ model.endpoints.map(_.fields) + val allEntIds = allFields.map(_.id) + val knownMap = entIdsToMap(allEntIds) + + val fromEdges = unresolvedEdgeUuids(model.edges, knownMap.keySet) + + val pointsWithCalcOpt: Seq[(PointDesc, Option[CalculationDescriptor])] = model.points.map { p => + val (calcSeq, otherKvs) = p.fields.kvs.partition(_._1 == calculationKey) + val calcOpt = calcSeq.headOption.map(_._2).map(valueHolderToCalc) + + (p.copy(fields = p.fields.copy(kvs = otherKvs)), calcOpt) + } + + val allCalcDescs = pointsWithCalcOpt.flatMap(_._2) + + val fromCalcs = allCalcDescs.flatMap(uuidsFromCalc).filterNot(knownMap.keySet.contains) + + val allUnresolved = fromEdges ++ fromCalcs + + val entities = lookupEntitiesByUuid(session, allUnresolved) + + val resolvedUuidToNames = entities.map(e => (UUID.fromString(e.getUuid.getValue), e.getName)).toMap + + val fullMap: Map[UUID, String] = knownMap ++ resolvedUuidToNames + + def resolveEntId(id: EntityId): EntityId = { + id match { + case UuidEntId(uuid) => + val name = fullMap.getOrElse(uuid, throw new LoadingException("UUID was not resolved: " + uuid)) + FullEntId(uuid, name) + case x => x + } + } + + val resolvedEdges = model.edges.map(e => e.copy(parent = resolveEntId(e.parent), child = resolveEntId(e.child))) + + val resolvedPointDescs = pointsWithCalcOpt.map { + case (pointDesc, Some(calcDesc)) => pointDesc.copy(calcHolder = NamesResolvedCalc(calcDesc, fullMap)) + case (pointDesc, None) => pointDesc.copy(calcHolder = NoCalculation) + } + + model.copy(edges = resolvedEdges, points = resolvedPointDescs) + } + + def resolveExternalReferences(session: Session, uuidRefs: Seq[UUID], nameRefs: Seq[String], downloadSet: DownloadSet): (Set[UUID], Set[String], Seq[(UUID, String)]) = { + + val downloadSetTuples = downloadTuples(downloadSet) + val downloadUuidsSet = downloadSetTuples.map(_._1).toSet + val downloadNamesSet = downloadSetTuples.map(_._2).toSet + + val unresUuidRefs = uuidRefs.filterNot(downloadUuidsSet.contains) + val unresNameRefs = nameRefs.filterNot(downloadNamesSet.contains) + + val uuidEnts = lookupEntitiesByUuid(session, unresUuidRefs) + val nameEnts = lookupEntitiesByName(session, unresNameRefs) + + val resolveTups = (uuidEnts.map(obj => (obj.getUuid, obj.getName)) ++ + nameEnts.map(obj => (obj.getUuid, obj.getName))) + .map { case (uuid, name) => (protoUUIDToUuid(uuid), name) } + + val resolveUuids = resolveTups.map(_._1) + val resolveNames = resolveTups.map(_._2) + + (downloadUuidsSet ++ resolveUuids, downloadNamesSet ++ resolveNames, downloadSetTuples ++ resolveTups) + } + + private def downloadTuples(downloadSet: DownloadSet) = { + (downloadSet.modelEntities.map(obj => (obj.getUuid, obj.getName)) ++ + downloadSet.points.map(obj => (obj.getUuid, obj.getName)) ++ + downloadSet.commands.map(obj => (obj.getUuid, obj.getName)) ++ + downloadSet.endpoints.map(obj => (obj.getUuid, obj.getName))) + .map { case (uuid, name) => (protoUUIDToUuid(uuid), name) } + } + + def lookupEntitiesByUuid(session: Session, set: Seq[UUID]): Seq[Entity] = { + val modelClient = ModelService.client(session) + set.grouped(Downloader.pageSizeForGet).flatMap { uuids => + if (uuids.nonEmpty) { + val modelUuids = uuids.map(u => ModelUUID.newBuilder().setValue(u.toString).build()) + val keySet = EntityKeySet.newBuilder().addAllUuids(modelUuids).build() + Await.result(modelClient.get(keySet), Downloader.timeoutMs.milliseconds) + } else { + Seq() + } + }.toVector + } + + def lookupEntitiesByName(session: Session, set: Seq[String]): Seq[Entity] = { + val modelClient = ModelService.client(session) + set.grouped(Downloader.pageSizeForGet).flatMap { uuids => + if (uuids.nonEmpty) { + val keySet = EntityKeySet.newBuilder().addAllNames(set).build() + Await.result(modelClient.get(keySet), Downloader.timeoutMs.milliseconds) + } else { + Seq() + } + }.toVector + } + + private def uuidsFromCalc(calc: CalculationDescriptor): Seq[UUID] = { + calc.getCalcInputsList.map(_.getPointUuid).map(uuid => UUID.fromString(uuid.getValue)) + } + + def mostSpecificIdElements(ids: Seq[EntityId]): (Seq[UUID], Seq[String]) = { + val uuids = Vector.newBuilder[UUID] + val names = Vector.newBuilder[String] + + ids.foreach { + case FullEntId(uuid, _) => uuids += uuid + case UuidEntId(uuid) => uuids += uuid + case NamedEntId(name) => names += name + } + + (uuids.result(), names.result()) + } + + def entIdsToMap(ids: Seq[EntityId]): Map[UUID, String] = { + ids.flatMap { + case FullEntId(uuid, name) => Some((uuid, name)) + case _ => None + }.toMap + } + + def uuidsFromEntIds(ids: Seq[EntityId]): Seq[UUID] = { + ids.flatMap { + case UuidEntId(uuid) => Some(uuid) + case _ => None + } + } + + def unresolvedEdgeUuids(descs: Seq[EdgeDesc], known: Set[UUID]): Seq[UUID] = { + val allEntIds = descs.flatMap(e => Seq(e.child, e.parent)) + val uuids = uuidsFromEntIds(allEntIds) + uuids.filterNot(known.contains) + } + + private def valueHolderToCalc(v: ValueHolder): CalculationDescriptor = { + val byteArray = v match { + case ByteArrayValue(bytes) => bytes + case SimpleValue(sv) => if (sv.hasByteArrayValue) sv.getByteArrayValue.toByteArray else throw new LoadingException("Calculation key value was not a byte array") + case _ => throw new LoadingException("Calculation key value was not a byte array") + } + + try { + CalculationDescriptor.parseFrom(byteArray) + } catch { + case ex: Throwable => + throw new LoadingException("Could not parse calculation stored value: " + ex) + } + } +} + +object DownloadConversion { + + implicit def protoUUIDToUuid(uuid: ModelUUID): UUID = UUID.fromString(uuid.getValue) + + private def toEntityDesc(ent: Entity, kvs: Seq[(String, StoredValue)]): EntityDesc = { + EntityDesc( + EntityFields( + FullEntId(ent.getUuid, ent.getName), + ent.getTypesList.toSet, + kvs.map(tup => (tup._1, storedValueToRepr(tup._2))))) + } + + private def toPointDesc(p: Point, kvs: Seq[(String, StoredValue)]): PointDesc = { + PointDesc( + EntityFields( + FullEntId(p.getUuid, p.getName), + p.getTypesList.toSet, + kvs.map(tup => (tup._1, storedValueToRepr(tup._2)))), + p.getPointCategory, + p.getUnit, + CalcNotChecked) + } + + private def toCommandDesc(c: Command, kvs: Seq[(String, StoredValue)]): CommandDesc = { + CommandDesc( + EntityFields( + FullEntId(c.getUuid, c.getName), + c.getTypesList.toSet, + kvs.map(tup => (tup._1, storedValueToRepr(tup._2)))), + c.getDisplayName, + c.getCommandCategory) + } + + private def toEndpointDesc(e: Endpoint, kvs: Seq[(String, StoredValue)]): EndpointDesc = { + EndpointDesc( + EntityFields( + FullEntId(e.getUuid, e.getName), + e.getTypesList.toSet, + kvs.map(tup => (tup._1, storedValueToRepr(tup._2)))), + e.getProtocol) + } + + def storedValueToRepr(v: StoredValue): ValueHolder = { + if (v.hasByteArrayValue) { + ByteArrayValue(v.getByteArrayValue.toByteArray) + } else { + SimpleValue(v) + } + } + + def downloadToIntermediate(set: DownloadSet): Mdl.FlatModelFragment = { + import Mdl._ + + val kvMap: Map[ModelUUID, Seq[(String, StoredValue)]] = set.keyValues.groupBy(_.getUuid).map { + case (uuid, ekvs) => (uuid, ekvs.map(ekv => (ekv.getKey, ekv.getValue))) + } + + def kvsForUuid(uuid: ModelUUID) = kvMap.getOrElse(uuid, Seq()) + + val modelDescs = set.modelEntities.map(ent => toEntityDesc(ent, kvsForUuid(ent.getUuid))) + + val pointDescs = set.points.map(pt => toPointDesc(pt, kvsForUuid(pt.getUuid))) + + val commandDescs = set.commands.map(cmd => toCommandDesc(cmd, kvsForUuid(cmd.getUuid))) + + val endpointDescs = set.endpoints.map(end => toEndpointDesc(end, kvsForUuid(end.getUuid))) + + val edgeDescs = set.edges.map(e => EdgeDesc(UuidEntId(e.getParent), e.getRelationship, UuidEntId(e.getChild))) + + FlatModelFragment(modelDescs, pointDescs, commandDescs, endpointDescs, edgeDescs) + } +} diff --git a/loading/src/main/scala/io/greenbus/loader/set/Importer.scala b/loading/src/main/scala/io/greenbus/loader/set/Importer.scala new file mode 100644 index 0000000..805b442 --- /dev/null +++ b/loading/src/main/scala/io/greenbus/loader/set/Importer.scala @@ -0,0 +1,770 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.loader.set + +import java.util +import java.util.UUID + +import io.greenbus.msg.Session +import io.greenbus.client.service.proto.Calculations.{ CalculationInput, CalculationDescriptor } +import io.greenbus.client.service.proto.Model._ +import io.greenbus.loader.set.Actions.ActionsList +import io.greenbus.loader.set.Differences.{ DiffRecord, EntBasedChange, EntBasedCreate, ParamChange } +import io.greenbus.loader.set.Downloader.DownloadSet +import io.greenbus.loader.set.Mdl._ + +import scala.collection.JavaConversions._ + +object Importer { + + import io.greenbus.loader.set.UUIDHelpers._ + + def importDiff(session: Session, update: FlatModelFragment, downloadSet: DownloadSet): ((ActionsList, DiffRecord), Seq[(UUID, String)]) = { + + performDiff(update, downloadSet, NameResolver.resolveExternalReferences(session, _, _, _), NameResolver.lookupEntitiesByName(session, _)) + } + + type ResolveNames = (Seq[UUID], Seq[String], DownloadSet) => (Set[UUID], Set[String], Seq[(UUID, String)]) + + def performDiff(update: FlatModelFragment, downloadSet: DownloadSet, resolveFunc: ResolveNames, getEntsForNames: Seq[String] => Seq[Entity]): ((ActionsList, DiffRecord), Seq[(UUID, String)]) = { + + val namesOfModelObjects = SetDiff.namesInSet(update) + + SetDiff.checkForNameDuplicatesInFragment(namesOfModelObjects) + + SetDiff.checkForDuplicateKeys(update) + + checkForDuplicateNamesOfExternalObjects(update, downloadSet, getEntsForNames) + + checkForDuplicateEdges(update) + + val (extUuids, extNames, updateTuples) = SetDiff.analyzeIdentities(update) + + val (foundUuids, foundNames, resolvedTuples) = resolveFunc(extUuids, extNames, downloadSet) + + val resolvedFlat = SetDiff.resolveIdentities(update, foundUuids, foundNames) + + val allIdTuples = resolvedTuples ++ updateTuples + + (compare(resolvedFlat, allIdTuples, downloadSet), allIdTuples) + } + + def checkForDuplicateEdges(model: FlatModelFragment): Unit = { + + val namesOnly: Seq[(String, String, String)] = model.edges.flatMap { e => + for { + parentName <- EntityId.toNameOpt(e.parent) + childName <- EntityId.toNameOpt(e.child) + } yield (parentName, e.relationship, childName) + } + + val duplicates = namesOnly.groupBy(a => a).filter { case (k, v) => v.size > 1 }.values.map(_.head) + + if (duplicates.nonEmpty) { + throw new LoadingException("The following edges were duplicated: \n\n" + duplicates.map { + case (parent, rel, child) => + "\t" + parent + " --[" + rel + "]--> " + child + }.mkString("\n")) + } + } + + def checkForDuplicateNamesOfExternalObjects(model: FlatModelFragment, downloadSet: DownloadSet, getEntsForNames: Seq[String] => Seq[Entity]): Unit = { + + val nameToUuidInDownloadSet: Seq[(String, UUID)] = + (downloadSet.modelEntities.map(obj => (obj.getName, obj.getUuid)) ++ + downloadSet.points.map(obj => (obj.getName, obj.getUuid)) ++ + downloadSet.commands.map(obj => (obj.getName, obj.getUuid)) ++ + downloadSet.endpoints.map(obj => (obj.getName, obj.getUuid))) + .map(tup => (tup._1, UUIDHelpers.protoUUIDToUuid(tup._2))) + + val nameToUuidInSetMap = nameToUuidInDownloadSet.toMap + + val allObjIdsInFrag = model.modelEntities.map(_.fields.id) ++ + model.points.map(_.fields.id) ++ + model.commands.map(_.fields.id) ++ + model.endpoints.map(_.fields.id) + + val (namedOnly, uuidNamePairs) = SetDiff.objectIdBreakdown(allObjIdsInFrag) + + val (namedExisting, namedNotExistingInFrag) = namedOnly.partition(nameToUuidInSetMap.contains) + + val allUuidNamePairs: Seq[(String, UUID)] = uuidNamePairs.map(t => (t._2, t._1)) ++ namedExisting.flatMap(n => nameToUuidInSetMap.get(n).map(u => (n, u))) + + val allObjNames = namedNotExistingInFrag ++ allUuidNamePairs.map(_._1) + + val entsForNames = getEntsForNames(allObjNames) + + val nameToUuidInSystem = entsForNames.map(e => (e.getName, UUIDHelpers.protoUUIDToUuid(e.getUuid))).toMap + + namedNotExistingInFrag.foreach { name => + if (nameToUuidInSystem.contains(name)) { + throw new LoadingException(s"Attempting to create $name but it already exists in the system") + } + } + + allUuidNamePairs.foreach { + case (name, uuid) => + nameToUuidInSystem.get(name) match { + case None => + case Some(lookupUuid) => + if (uuid != lookupUuid) { + throw new LoadingException(s"Attempting to use name $name but it already exists in the system") + } + } + } + } + + def summarize(diff: DiffRecord): Unit = { + + def commonCreate(created: EntBasedCreate): Unit = { + println("\t" + created.name) + if (created.withUuid) { + println("\t\t(* with UUID)") + } + println("\t\tTypes: " + created.types.mkString(", ")) + println("\t\tKey values: " + created.keys.mkString(", ")) + } + + def commonChange(changed: EntBasedChange): Unit = { + println("\t" + changed.currentName) + changed.nameOpt.foreach { + case ParamChange(prev, now) => println(s"\t\tName change: $prev -> $now") + } + if (changed.addedTypes.nonEmpty) { + println("\t\tTypes added: " + changed.addedTypes.mkString(", ")) + } + if (changed.removedTypes.nonEmpty) { + println("\t\tTypes removed: " + changed.removedTypes.mkString(", ")) + } + if (changed.addedKeys.nonEmpty) { + println("\t\tKey values added: " + changed.addedKeys.mkString(", ")) + } + if (changed.modifiedKeys.nonEmpty) { + println("\t\tKey values modified: " + changed.modifiedKeys.mkString(", ")) + } + if (changed.removedKeys.nonEmpty) { + println("\t\tKey values removed: " + changed.removedKeys.mkString(", ")) + } + } + + def printParam[A](name: String, opt: Option[ParamChange[A]]): Unit = { + opt.foreach { + case ParamChange(prev, now) => println(s"\t\t$name change: ${prev.toString} -> ${now.toString}") + } + } + + if (diff.isEmpty) { + println("No changes.") + } else { + + if (diff.entCreateRecords.nonEmpty) { + println("Created entities: ") + diff.entCreateRecords.foreach { created => + commonCreate(created) + } + println() + } + + if (diff.entChangedRecords.nonEmpty) { + println("Modified entities: ") + diff.entChangedRecords.foreach { changed => + commonChange(changed) + } + println() + } + + if (diff.entDeleteRecords.nonEmpty) { + println("Deleted entities: ") + diff.entDeleteRecords.foreach { deleted => + println("\t" + deleted.name) + } + println() + } + + if (diff.pointCreateRecords.nonEmpty) { + println("Created points: ") + diff.pointCreateRecords.foreach { created => + commonCreate(created) + println("\t\tCategory: " + created.category) + println("\t\tUnit: " + created.unit) + } + println() + } + + if (diff.pointChangedRecords.nonEmpty) { + println("Modified points: ") + diff.pointChangedRecords.foreach { changed => + commonChange(changed) + printParam("Category", changed.categoryOpt) + printParam("Unit", changed.unitOpt) + } + println() + } + + if (diff.pointDeleteRecords.nonEmpty) { + println("Deleted points: ") + diff.pointDeleteRecords.foreach { deleted => + println("\t" + deleted.name) + } + println() + } + + if (diff.commandCreateRecords.nonEmpty) { + println("Created commands: ") + diff.commandCreateRecords.foreach { created => + commonCreate(created) + println("\t\tCategory: " + created.category) + println("\t\tDisplay name: " + created.displayName) + } + println() + } + + if (diff.commandChangedRecords.nonEmpty) { + println("Modified commands: ") + diff.commandChangedRecords.foreach { changed => + commonChange(changed) + printParam("Category", changed.categoryOpt) + printParam("Display name", changed.displayNameOpt) + } + println() + } + + if (diff.commandDeleteRecords.nonEmpty) { + println("Deleted commands: ") + diff.commandDeleteRecords.foreach { deleted => + println("\t" + deleted.name) + } + println() + } + + if (diff.endpointCreateRecords.nonEmpty) { + println("Created endpoints: ") + diff.endpointCreateRecords.foreach { created => + commonCreate(created) + println("\t\tProtocol: " + created.protocol) + } + println() + } + + if (diff.endpointChangedRecords.nonEmpty) { + println("Modified endpoints: ") + diff.endpointChangedRecords.foreach { changed => + commonChange(changed) + printParam("Protocol", changed.protocolOpt) + } + println() + } + + if (diff.endpointDeleteRecords.nonEmpty) { + println("Deleted endpoints: ") + diff.endpointDeleteRecords.foreach { deleted => + println("\t" + deleted.name) + } + println() + } + + if (diff.edgeCreateRecords.nonEmpty) { + println("Created edges: ") + diff.edgeCreateRecords.foreach { edge => + println("\t" + edge.parent + " --[" + edge.relation + "]--> " + edge.child) + } + println() + } + + if (diff.edgeDeleteRecords.nonEmpty) { + println("Deleted edges: ") + diff.edgeDeleteRecords.foreach { edge => + println("\t" + edge.parent + " --[" + edge.relation + "]--> " + edge.child) + } + println() + } + + } + + } + + def compare(update: FlatModelFragment, resolvedIds: Seq[(UUID, String)], downloadSet: DownloadSet): (ActionsList, DiffRecord) = { + import io.greenbus.loader.set.Actions._ + import io.greenbus.loader.set.Differences._ + + val resolvedNameMap = resolvedIds.map(tup => (tup._2, tup._1)).toMap + val resolvedUuidMap = resolvedIds.toMap + + val uuidToEnt = downloadSet.modelEntities.map(obj => (protoUUIDToUuid(obj.getUuid), obj)).toMap + val nameToEnt = downloadSet.modelEntities.map(obj => (obj.getName, obj)).toMap + + val uuidToPoint = downloadSet.points.map(obj => (protoUUIDToUuid(obj.getUuid), obj)).toMap + val nameToPoint = downloadSet.points.map(obj => (obj.getName, obj)).toMap + + val uuidToCommand = downloadSet.commands.map(obj => (protoUUIDToUuid(obj.getUuid), obj)).toMap + val nameToCommand = downloadSet.commands.map(obj => (obj.getName, obj)).toMap + + val uuidToEndpoint = downloadSet.endpoints.map(obj => (protoUUIDToUuid(obj.getUuid), obj)).toMap + val nameToEndpoint = downloadSet.endpoints.map(obj => (obj.getName, obj)).toMap + + val uuidToKeyValues: Map[UUID, Seq[EntityKeyValue]] = downloadSet.keyValues.groupBy(kv => protoUUIDToUuid(kv.getUuid)) + + val cache = new ActionCache + val records = new RecordBuilder + + def handleNewKeyValues(uuidOpt: Option[UUID], name: String, kvs: Seq[(String, ValueHolder)]): Unit = { + kvs.foreach { + case (key, vh) => + uuidOpt match { + case None => cache.keyValuePutByNames += PutKeyValueByName(name, key, vh) + case Some(uuid) => cache.keyValuePutByUuids += PutKeyValueByUuid(uuid, key, vh) + } + } + } + + def handleKvs(uuid: UUID, name: String, kvs: Seq[(String, ValueHolder)]): (Set[String], Set[String], Set[String]) = { + val currentKvs = uuidToKeyValues.getOrElse(uuid, Seq()).filterNot(_.getKey == "calculation") + val (keyPuts, keysAdded, keysModified, keysDeleted) = keySetDiff(currentKvs, kvs) + + keyPuts.foreach(p => cache.keyValuePutByUuids += PutKeyValueByUuid(uuid, p._1, p._2)) + keysDeleted.foreach(k => cache.keyValueDeletes += DeleteKeyValue(uuid, k)) + + (keysAdded, keysModified, keysDeleted) + } + + def typeDiff(existing: Set[String], update: Set[String]): (Set[String], Set[String]) = { + val typesToAdd = update diff existing + val typesToRemove = existing diff update + (typesToAdd, typesToRemove) + } + + def optParamChange[A](old: A, next: A): Option[ParamChange[A]] = { + if (old != next) Some(ParamChange(old, next)) else None + } + + def handleEntityUpdate(uuid: UUID, name: String, ent: EntityDesc, existingEnt: Entity): Unit = { + + val nameChangeOpt = optParamChange(existingEnt.getName, name) + + val (typesToAdd, typesToRemove) = typeDiff(existingEnt.getTypesList.toSet, ent.fields.types) + val (keysAdded, keysModified, keysDeleted) = handleKvs(uuid, name, ent.fields.kvs) + val keyChange = keysAdded.nonEmpty || keysModified.nonEmpty || keysDeleted.nonEmpty + + if (name != existingEnt.getName || typesToAdd.nonEmpty || typesToRemove.nonEmpty || keyChange) { + cache.entityPuts += PutEntity(Some(uuid), name, ent.fields.types) + records.entChangedRecords += EntityChanged(existingEnt.getName, nameChangeOpt, typesToAdd, typesToRemove, keysAdded, keysModified, keysDeleted) + } + } + + def handleNewEntity(uuidOpt: Option[UUID], name: String, ent: EntityDesc) { + cache.entityPuts += PutEntity(uuidOpt, name, ent.fields.types) + val keys = ent.fields.kvs.map(_._1).toSet + records.entCreateRecords += EntityCreated(name, ent.fields.types, keys, withUuid = uuidOpt.nonEmpty) + handleNewKeyValues(uuidOpt, name, ent.fields.kvs) + } + + def handlePointUpdate(uuid: UUID, name: String, pt: PointDesc, existing: Point): Unit = { + + val nameChangeOpt = optParamChange(existing.getName, name) + + val (typesToAdd, typesToRemove) = typeDiff(existing.getTypesList.toSet, pt.fields.types) + + val existCalcOpt = uuidToKeyValues.getOrElse(uuid, Seq()).find(_.getKey == "calculation").map(_.getValue) + + val (calcAdd, calcModify, calcRemove) = calcDiff(pt.calcHolder, existCalcOpt, resolvedNameMap) match { + case CalcSame => (Seq(), Seq(), Seq()) + case CalcCreate => + cache.calcPutsByUuid += PutCalcByUuid(uuid, pt.calcHolder) + (Seq("calculation"), Seq(), Seq()) + case CalcUpdate => + cache.calcPutsByUuid += PutCalcByUuid(uuid, pt.calcHolder) + (Seq(), Seq("calculation"), Seq()) + case CalcDelete => + cache.keyValueDeletes += DeleteKeyValue(uuid, "calculation") + (Seq(), Seq(), Seq("calculation")) + } + + val pointKeys = pt.fields.kvs.filterNot(_._1 == "calculation") + val (nonCalcKeysAdded, nonCalcKeysModified, nonCalcKeysDeleted) = handleKvs(uuid, name, pointKeys) + val keysAdded = nonCalcKeysAdded ++ calcAdd + val keysModified = nonCalcKeysModified ++ calcModify + val keysDeleted = nonCalcKeysDeleted ++ calcRemove + + val keyChange = keysAdded.nonEmpty || keysModified.nonEmpty || keysDeleted.nonEmpty + + val pointCategoryOpt = optParamChange(existing.getPointCategory, pt.pointCategory) + val unitOpt = optParamChange(existing.getUnit, pt.unit) + + if (name != existing.getName || typesToAdd.nonEmpty || typesToRemove.nonEmpty || keyChange || pointCategoryOpt.nonEmpty || unitOpt.nonEmpty) { + cache.pointPuts += PutPoint(Some(uuid), name, pt.fields.types, pt.pointCategory, pt.unit) + records.pointChangedRecords += PointChanged(existing.getName, nameChangeOpt, typesToAdd, typesToRemove, keysAdded, keysModified, keysDeleted, pointCategoryOpt, unitOpt) + } + } + + def handleNewPoint(uuidOpt: Option[UUID], name: String, pt: PointDesc) { + cache.pointPuts += PutPoint(uuidOpt, name, pt.fields.types, pt.pointCategory, pt.unit) + + pt.calcHolder match { + case NoCalculation => + case _ => + uuidOpt match { + case None => cache.calcPutsByName += PutCalcByName(name, pt.calcHolder) + case Some(uuid) => cache.calcPutsByUuid += PutCalcByUuid(uuid, pt.calcHolder) + } + } + + val allKvs = pt.fields.kvs //++ calcKvSeq + + val keys = allKvs.map(_._1).toSet + records.pointCreateRecords += PointCreated(name, pt.fields.types, keys, pt.pointCategory, pt.unit, withUuid = uuidOpt.nonEmpty) + handleNewKeyValues(uuidOpt, name, allKvs) + } + + def handleCommandUpdate(uuid: UUID, name: String, cmd: CommandDesc, existing: Command): Unit = { + + val nameChangeOpt = optParamChange(existing.getName, name) + + val (typesToAdd, typesToRemove) = typeDiff(existing.getTypesList.toSet, cmd.fields.types) + val (keysAdded, keysModified, keysDeleted) = handleKvs(uuid, name, cmd.fields.kvs) + val keyChange = keysAdded.nonEmpty || keysModified.nonEmpty || keysDeleted.nonEmpty + + val commandCategoryOpt = optParamChange(existing.getCommandCategory, cmd.commandCategory) + val displayNameOpt = optParamChange(existing.getDisplayName, cmd.displayName) + + if (name != existing.getName || typesToAdd.nonEmpty || typesToRemove.nonEmpty || keyChange || commandCategoryOpt.nonEmpty || displayNameOpt.nonEmpty) { + cache.commandPuts += PutCommand(Some(uuid), name, cmd.fields.types, cmd.commandCategory, cmd.displayName) + records.commandChangedRecords += CommandChanged(existing.getName, nameChangeOpt, typesToAdd, typesToRemove, keysAdded, keysModified, keysDeleted, commandCategoryOpt, displayNameOpt) + } + } + + def handleNewCommand(uuidOpt: Option[UUID], name: String, cmd: CommandDesc) { + cache.commandPuts += PutCommand(uuidOpt, name, cmd.fields.types, cmd.commandCategory, cmd.displayName) + val keys = cmd.fields.kvs.map(_._1).toSet + records.commandCreateRecords += CommandCreated(name, cmd.fields.types, keys, cmd.commandCategory, cmd.displayName, withUuid = uuidOpt.nonEmpty) + handleNewKeyValues(uuidOpt, name, cmd.fields.kvs) + } + + def handleEndpointUpdate(uuid: UUID, name: String, end: EndpointDesc, existing: Endpoint): Unit = { + + val nameChangeOpt = optParamChange(existing.getName, name) + + val (typesToAdd, typesToRemove) = typeDiff(existing.getTypesList.toSet, end.fields.types) + val (keysAdded, keysModified, keysDeleted) = handleKvs(uuid, name, end.fields.kvs) + val keyChange = keysAdded.nonEmpty || keysModified.nonEmpty || keysDeleted.nonEmpty + + val protocolOpt = optParamChange(existing.getProtocol, end.protocol) + + if (name != existing.getName || typesToAdd.nonEmpty || typesToRemove.nonEmpty || keyChange || protocolOpt.nonEmpty) { + cache.endpointPuts += PutEndpoint(Some(uuid), name, end.fields.types, end.protocol) + records.endpointChangedRecords += EndpointChanged(existing.getName, nameChangeOpt, typesToAdd, typesToRemove, keysAdded, keysModified, keysDeleted, protocolOpt) + } + } + + def handleNewEndpoint(uuidOpt: Option[UUID], name: String, end: EndpointDesc) { + cache.endpointPuts += PutEndpoint(uuidOpt, name, end.fields.types, end.protocol) + val keys = end.fields.kvs.map(_._1).toSet + records.endpointCreateRecords += EndpointCreated(name, end.fields.types, keys, end.protocol, withUuid = uuidOpt.nonEmpty) + handleNewKeyValues(uuidOpt, name, end.fields.kvs) + } + + def handleObject[Desc, Proto]( + obj: Desc, + fields: EntityFields, + uuidMap: Map[UUID, Proto], + nameMap: Map[String, Proto], + handleUpdate: (UUID, String, Desc, Proto) => Unit, + handleNew: (Option[UUID], String, Desc) => Unit, + protoToUuid: Proto => ModelUUID): Unit = { + + fields.id match { + case FullEntId(uuid, name) => + // could be import of old system to a new DB + // could be name/param change of existing (including no change at all) + uuidMap.get(uuid) match { + case None => handleNew(Some(uuid), name, obj) + case Some(existing) => handleUpdate(uuid, name, obj, existing) + } + case NamedEntId(name) => + nameMap.get(name) match { + case None => handleNew(None, name, obj) + case Some(existing) => handleUpdate(protoUUIDToUuid(protoToUuid(existing)), name, obj, existing) + } + case _ => throw new LoadingException("Entity must have name") + } + } + + def splitIdSets(ids: Seq[EntityId]): (Set[UUID], Set[String]) = { + val (onlyUuids, onlyNames, full) = SetDiff.idReferenceBreakdown(ids) + ((onlyUuids ++ full.map(_._1)).toSet, onlyNames.toSet) + } + + // if the uuid is present we MUST match that; ignore name + val (fragModelUuidSet, fragModelNameSet) = splitIdSets(update.modelEntities.map(_.fields.id)) + val (fragPointUuidSet, fragPointNameSet) = splitIdSets(update.points.map(_.fields.id)) + val (fragCommandUuidSet, fragCommandNameSet) = splitIdSets(update.commands.map(_.fields.id)) + val (fragEndpointUuidSet, fragEndpointNameSet) = splitIdSets(update.endpoints.map(_.fields.id)) + + downloadSet.modelEntities.foreach { obj => + val uuid = UUIDHelpers.protoUUIDToUuid(obj.getUuid) + if (!fragModelUuidSet.contains(uuid) && !fragModelNameSet.contains(obj.getName)) { + cache.entityDeletes += DeleteEntity(uuid) + records.entDeleteRecords += EntityDeleted(obj.getName) + } + } + + downloadSet.points.foreach { obj => + val uuid = UUIDHelpers.protoUUIDToUuid(obj.getUuid) + if (!fragPointUuidSet.contains(uuid) && !fragPointNameSet.contains(obj.getName)) { + cache.pointDeletes += DeletePoint(uuid) + records.pointDeleteRecords += PointDeleted(obj.getName) + } + } + downloadSet.commands.foreach { obj => + val uuid = UUIDHelpers.protoUUIDToUuid(obj.getUuid) + if (!fragCommandUuidSet.contains(uuid) && !fragCommandNameSet.contains(obj.getName)) { + cache.commandDeletes += DeleteCommand(uuid) + records.commandDeleteRecords += CommandDeleted(obj.getName) + } + } + downloadSet.endpoints.foreach { obj => + val uuid = UUIDHelpers.protoUUIDToUuid(obj.getUuid) + if (!fragEndpointUuidSet.contains(uuid) && !fragEndpointNameSet.contains(obj.getName)) { + cache.endpointDeletes += DeleteEndpoint(uuid) + records.endpointDeleteRecords += EndpointDeleted(obj.getName) + } + } + + update.modelEntities.foreach { ent => + handleObject(ent, ent.fields, uuidToEnt, nameToEnt, handleEntityUpdate, handleNewEntity, { proto: Entity => proto.getUuid }) + } + + update.points.foreach { pt => + handleObject(pt, pt.fields, uuidToPoint, nameToPoint, handlePointUpdate, handleNewPoint, { proto: Point => proto.getUuid }) + } + + update.commands.foreach { cmd => + handleObject(cmd, cmd.fields, uuidToCommand, nameToCommand, handleCommandUpdate, handleNewCommand, { proto: Command => proto.getUuid }) + } + + update.endpoints.foreach { end => + handleObject(end, end.fields, uuidToEndpoint, nameToEndpoint, handleEndpointUpdate, handleNewEndpoint, { proto: Endpoint => proto.getUuid }) + } + + val (edgeAdds, edgeDeletes) = edgeDiffWhileUnresolved(update.edges, downloadSet.edges, resolvedUuidMap, resolvedNameMap) + edgeAdds.foreach { desc => + cache.edgePuts += PutEdge(desc) + records.edgeCreateRecords += EdgeRecord(EntityId.prettyPrint(desc.parent), desc.relationship, EntityId.prettyPrint(desc.child)) + } + + edgeDeletes.foreach { tup => + cache.edgeDeletes += DeleteEdge(tup._1, tup._2, tup._3) + records.edgeDeleteRecords += EdgeRecord(resolvedUuidMap(tup._1), tup._2, resolvedUuidMap(tup._3)) + } + + (cache.result(), records.result()) + } + + def keySetDiff(original: Seq[EntityKeyValue], update: Seq[(String, ValueHolder)]): (Seq[(String, ValueHolder)], Set[String], Set[String], Set[String]) = { + val originalKeyMap = original.map(kv => (kv.getKey, kv)).toMap + val originalKeySet = originalKeyMap.keySet + + val updateKeyMap = update.map(kv => (kv._1, kv._2)).toMap + val updateKeySet = updateKeyMap.keySet + + val keysToAdd = updateKeySet diff originalKeySet + val keysToRemove = originalKeySet diff updateKeySet + + val possibleChangeSet = updateKeyMap -- keysToAdd + + val changed = possibleChangeSet.filter { + case (key, vh) => + val origKv = originalKeyMap(key) + vh match { + case SimpleValue(sv) => !origKv.getValue.equals(sv) + case ByteArrayReference(length) => false + case ByteArrayValue(bytes) => + !(origKv.getValue.hasByteArrayValue && util.Arrays.equals(bytes, origKv.getValue.getByteArrayValue.toByteArray)) + case FileReference(ref) => true + } + } + + val changedKeys = changed.keySet + + val allPuts = update.filter(tup => (keysToAdd ++ changedKeys).contains(tup._1)) + + (allPuts, keysToAdd, changedKeys, keysToRemove) + } + + sealed trait CalcAction + case object CalcSame extends CalcAction + case object CalcCreate extends CalcAction + case object CalcUpdate extends CalcAction + case object CalcDelete extends CalcAction + + def calcDiff(calcHolder: CalculationHolder, existing: Option[StoredValue], nameMap: Map[String, UUID]): CalcAction = { + + val unresolvedOpt = calcHolder match { + case NoCalculation => None + case unres: UnresolvedCalc => Some(unres) + case _ => throw new LoadingException("Calculation must be nonexistent or unresolved") + } + + (existing, unresolvedOpt) match { + case (None, None) => CalcSame + case (None, Some(unres)) => CalcCreate + case (Some(sv), None) => CalcDelete + case (Some(sv), Some(unres)) => + if (sv.hasByteArrayValue) { + try { + val desc = CalculationDescriptor.parseFrom(sv.getByteArrayValue) + val descWithoutInputs = desc.toBuilder.clearCalcInputs() + + if (descWithoutInputs.build() == unres.builder.build()) { + val existingInputs = desc.getCalcInputsList.map(in => (in.getVariableName, in.getPointUuid, in)) + val existingVars = existingInputs.map(_._1) + val updateVars = unres.inputs.map(_._2.getVariableName) + + if (existingVars.sorted == updateVars.sorted) { + val existVarMap = existingInputs.map(tup => (tup._1, (tup._2, tup._3))).toMap + + def inputEquals(a: CalculationInput.Builder, b: CalculationInput.Builder): Boolean = { + a.clearPointUuid().clearVariableName().build() == b.clearPointUuid().clearVariableName().build() + } + + val changed = !unres.inputs.forall { + case (id, builder) => + val keyName = builder.getVariableName + val existingForKey = existVarMap(keyName) + val existingUuid = protoUUIDToUuid(existingForKey._1) + + id match { + case FullEntId(uuid, name) => + (uuid == existingUuid || nameMap.get(name) == Some(existingUuid)) && + inputEquals(builder, existingForKey._2.toBuilder) + case UuidEntId(uuid) => + uuid == existingUuid && inputEquals(builder, existingForKey._2.toBuilder) + case NamedEntId(name) => + nameMap.get(name) == Some(existingUuid) && inputEquals(builder, existingForKey._2.toBuilder) + } + } + + if (changed) CalcUpdate else CalcSame + + } else { + CalcUpdate + } + } else { + CalcUpdate + } + } catch { + case ex: Throwable => CalcUpdate + } + } else { + CalcUpdate + } + } + } + + def resolveCalcOpt(calc: CalculationHolder, nameMap: Map[String, UUID]): Option[CalculationDescriptor] = { + calc match { + case NoCalculation => None + case UnresolvedCalc(builder, inputs) => + val resolvedInputs = inputs.map { + case (id, inputBuilder) => + val resolvedUuid = entIdToUuid(id, nameMap) + inputBuilder.setPointUuid(uuidToProtoUUID(resolvedUuid)).build() + } + + resolvedInputs.foreach(builder.addCalcInputs) + Some(builder.build()) + + case CalcNotChecked | _: NamesResolvedCalc => throw new IllegalArgumentException("Can only resolve calculations from XML import") + } + } + + def resolveCalc(calc: CalculationHolder, nameMap: Map[String, UUID]): CalculationDescriptor = { + calc match { + case UnresolvedCalc(builder, inputs) => + + val resolvedInputs = inputs.map { + case (id, inputBuilder) => + val resolvedUuid = entIdToUuid(id, nameMap) + inputBuilder.setPointUuid(uuidToProtoUUID(resolvedUuid)).build() + } + + resolvedInputs.foreach(builder.addCalcInputs) + builder.build() + + case _ => throw new IllegalArgumentException("Can only resolve calculations from XML import") + } + } + + def edgeDiffWhileUnresolved( + updateDescs: Seq[EdgeDesc], + existingEdges: Seq[EntityEdge], + uuidMap: Map[UUID, String], + nameMap: Map[String, UUID]): (Seq[EdgeDesc], Set[(UUID, String, UUID)]) = { + + val existingSet = existingEdges.map(e => (protoUUIDToUuid(e.getParent), e.getRelationship, protoUUIDToUuid(e.getChild))).toSet + + val adds = updateDescs.filter { desc => + val parentUuidOpt = entIdToUuidOpt(desc.parent, nameMap) + val childUuidOpt = entIdToUuidOpt(desc.child, nameMap) + (parentUuidOpt, childUuidOpt) match { + case (Some(parentUuid), Some(childUuid)) => !existingSet.contains((parentUuid, desc.relationship, childUuid)) + case _ => true + } + } + + val updateSet = updateDescs.toSet + + val removes = existingSet.filter { + case (parentUuid, relationship, childUuid) => + val parentName = uuidMap.getOrElse(parentUuid, throw new LoadingException(s"Existing edge referred to UUID $parentUuid that was unresolved")) + val childName = uuidMap.getOrElse(childUuid, throw new LoadingException(s"Existing edge referred to UUID $childUuid that was unresolved")) + + def permute(uuid: UUID, name: String) = Seq(FullEntId(uuid, name), UuidEntId(uuid), NamedEntId(name)) + + val equivalentDescs: Seq[EdgeDesc] = for { + parentId <- permute(parentUuid, parentName) + childId <- permute(childUuid, childName) + } yield { + EdgeDesc(parentId, relationship, childId) + } + + !equivalentDescs.exists(updateSet.contains) + } + + (adds, removes) + } + + def resolveEdgeDesc(desc: EdgeDesc, nameMap: Map[String, UUID]): (UUID, String, UUID) = { + (entIdToUuid(desc.parent, nameMap), desc.relationship, entIdToUuid(desc.child, nameMap)) + } + + def entIdToUuid(id: EntityId, nameMap: Map[String, UUID]): UUID = { + id match { + case FullEntId(uuid, _) => uuid + case UuidEntId(uuid) => uuid + case NamedEntId(name) => nameMap.getOrElse(name, throw new IllegalArgumentException(s"Referenced unresolved name: $name")) + } + } + def entIdToUuidOpt(id: EntityId, nameMap: Map[String, UUID]): Option[UUID] = { + id match { + case FullEntId(uuid, _) => Some(uuid) + case UuidEntId(uuid) => Some(uuid) + case NamedEntId(name) => nameMap.get(name) + } + } +} diff --git a/loading/src/main/scala/io/greenbus/loader/set/LoadingException.scala b/loading/src/main/scala/io/greenbus/loader/set/LoadingException.scala new file mode 100644 index 0000000..093dfac --- /dev/null +++ b/loading/src/main/scala/io/greenbus/loader/set/LoadingException.scala @@ -0,0 +1,21 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.loader.set + +class LoadingException(msg: String) extends Exception(msg) \ No newline at end of file diff --git a/loading/src/main/scala/io/greenbus/loader/set/Mdl.scala b/loading/src/main/scala/io/greenbus/loader/set/Mdl.scala new file mode 100644 index 0000000..4dd4589 --- /dev/null +++ b/loading/src/main/scala/io/greenbus/loader/set/Mdl.scala @@ -0,0 +1,283 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.loader.set + +import java.util.UUID + +import io.greenbus.client.service.proto.Calculations.{ CalculationDescriptor, CalculationInput } +import io.greenbus.client.service.proto.Model +import io.greenbus.client.service.proto.Model.StoredValue + +object EntityId { + def optional(id: EntityId): (Option[UUID], Option[String]) = { + id match { + case NamedEntId(name) => (None, Some(name)) + case UuidEntId(uuid) => (Some(uuid), None) + case FullEntId(uuid, name) => (Some(uuid), Some(name)) + } + } + + def toUuidOpt(id: EntityId): Option[UUID] = { + id match { + case UuidEntId(uuid) => Some(uuid) + case FullEntId(uuid, name) => Some(uuid) + case NamedEntId(_) => None + } + } + def toNameOpt(id: EntityId): Option[String] = { + id match { + case UuidEntId(_) => None + case FullEntId(_, name) => Some(name) + case NamedEntId(name) => Some(name) + } + } + + def apply(optUuid: Option[UUID], optName: Option[String]): EntityId = { + (optUuid, optName) match { + case (None, None) => throw new LoadingException("Must have either name or UUID") + case (Some(uuid), None) => UuidEntId(uuid) + case (None, Some(name)) => NamedEntId(name) + case (Some(uuid), Some(name)) => FullEntId(uuid, name) + } + } + + def prettyPrint(id: EntityId): String = { + id match { + case NamedEntId(name) => name + case UuidEntId(uuid) => uuid.toString + case FullEntId(uuid, name) => name + } + } + + def lessThan(a: EntityId, b: EntityId): Boolean = { + (optional(a), optional(b)) match { + case ((_, Some(nameA)), (_, Some(nameB))) => nameA < nameB + case ((_, Some(_)), (_, None)) => true + case ((_, None), (_, Some(_))) => false + case ((Some(uuidA), None), (Some(uuidB), None)) => uuidA.compareTo(uuidB) < 0 + case _ => false + } + } + + implicit def ordering[A <: EntityId]: Ordering[EntityId] = { + Ordering.fromLessThan(lessThan) + } +} + +sealed trait EntityId +case class NamedEntId(name: String) extends EntityId +case class UuidEntId(uuid: UUID) extends EntityId +case class FullEntId(uuid: UUID, name: String) extends EntityId + +case class EntityFields( + id: EntityId, + types: Set[String], + kvs: Seq[(String, ValueHolder)]) + +sealed trait ValueHolder +case class SimpleValue(sv: StoredValue) extends ValueHolder +case class ByteArrayValue(bytes: Array[Byte]) extends ValueHolder +case class ByteArrayReference(size: Long) extends ValueHolder +case class FileReference(name: String) extends ValueHolder + +sealed trait CalculationHolder +case object NoCalculation extends CalculationHolder +case object CalcNotChecked extends CalculationHolder +case class UnresolvedCalc(builder: CalculationDescriptor.Builder, inputs: Seq[(EntityId, CalculationInput.Builder)]) extends CalculationHolder +case class NamesResolvedCalc(calc: CalculationDescriptor, uuidToName: Map[UUID, String]) extends CalculationHolder + +object Mdl { + + sealed trait ModelObject { + val fields: EntityFields + } + + sealed trait EquipNode extends ModelObject + + case class EntityDesc(fields: EntityFields) extends EquipNode + + case class PointDesc( + fields: EntityFields, + pointCategory: Model.PointCategory, + unit: String, + calcHolder: CalculationHolder) extends EquipNode + + case class CommandDesc( + fields: EntityFields, + displayName: String, + commandCategory: Model.CommandCategory) extends EquipNode + + case class EndpointDesc( + fields: EntityFields, + protocol: String) extends ModelObject + + case class EdgeDesc(parent: EntityId, relationship: String, child: EntityId) + + case class FlatModelFragment( + modelEntities: Seq[EntityDesc], + points: Seq[PointDesc], + commands: Seq[CommandDesc], + endpoints: Seq[EndpointDesc], + edges: Seq[EdgeDesc]) + + case class TreeNode( + node: EquipNode, + children: Seq[TreeNode], + extParentRefs: Seq[EntityId], + otherParentRefs: Seq[(EntityId, String)], + otherChildRefs: Seq[(EntityId, String)], + commandRefs: Seq[EntityId]) + + case class EndpointNode(node: EndpointDesc, + sourceRefs: Seq[EntityId], + otherParentRefs: Seq[(EntityId, String)], + otherChildRefs: Seq[(EntityId, String)]) + + case class TreeModelFragment( + roots: Seq[TreeNode], + endpoints: Seq[EndpointNode]) + + def forceEntIdToUuid(id: EntityId): UUID = { + EntityId.toUuidOpt(id).getOrElse(throw new LoadingException("Must have UUID resolved")) + } + + def flatToTree(flat: FlatModelFragment): TreeModelFragment = { + + val uuidAndEquips = flat.modelEntities.map(e => (forceEntIdToUuid(e.fields.id), e)) + val uuidAndPoints = flat.points.map(e => (forceEntIdToUuid(e.fields.id), e)) + val uuidAndCommands = flat.commands.map(e => (forceEntIdToUuid(e.fields.id), e)) + + val equipUuids = uuidAndEquips.map(_._1) ++ uuidAndPoints.map(_._1) ++ uuidAndCommands.map(_._1) + val equipUuidSet: Set[UUID] = equipUuids.toSet + + val roots = findRoots(equipUuids, flat.edges) + + val parentToChildMap = flat.edges.groupBy(e => forceEntIdToUuid(e.parent)) + val childToParentMap = flat.edges.groupBy(e => forceEntIdToUuid(e.child)) + + val equipNodeMap: Map[UUID, EquipNode] = (uuidAndEquips ++ uuidAndPoints ++ uuidAndCommands).toMap + + def fillTree(uuids: Seq[UUID]): Seq[TreeNode] = { + val nodes: Seq[(UUID, EquipNode)] = uuids.flatMap(uuid => equipNodeMap.get(uuid).map(n => (uuid, n))) + nodes.map { + case (uuid, node) => + val allChildEdges = parentToChildMap.getOrElse(uuid, Seq()) + val allParentEdges = childToParentMap.getOrElse(uuid, Seq()) + + val extParentOwns = allParentEdges + .filter(e => e.relationship == "owns" && !equipUuidSet.contains(forceEntIdToUuid(e.parent))) + .map(e => e.parent) + + val ownsChildren = allChildEdges.filter(_.relationship == "owns").map(_.child).map(forceEntIdToUuid) + + val commandRefs = allChildEdges.filter(_.relationship == "feedback").map(e => e.child) + + val builtInSet = Set("owns", "source", "feedback") + + val otherParents = allParentEdges + .filterNot(e => builtInSet.contains(e.relationship)) + .map(e => (e.parent, e.relationship)) + + val otherChildren = allChildEdges + .filterNot(e => builtInSet.contains(e.relationship)) + .map(e => (e.child, e.relationship)) + + TreeNode( + node, + fillTree(ownsChildren), + extParentOwns, + otherParents, + otherChildren, + commandRefs) + } + } + + val rootNodes = fillTree(roots) + + val endpointNodes = flat.endpoints.map { desc => + val uuid = forceEntIdToUuid(desc.fields.id) + val allChildEdges = parentToChildMap.getOrElse(uuid, Seq()) + val allParentEdges = childToParentMap.getOrElse(uuid, Seq()) + + val (sourceEdges, otherChildEdges) = allChildEdges.partition(_.relationship == "source") + + val sourceRefs = sourceEdges.map(_.child) + + EndpointNode(desc, sourceRefs, allParentEdges.map(e => (e.parent, e.relationship)), otherChildEdges.map(e => (e.child, e.relationship))) + } + + TreeModelFragment(rootNodes, endpointNodes) + } + + private def findRoots(nodes: Seq[UUID], edges: Seq[EdgeDesc]): Seq[UUID] = { + val nodeSet = nodes.toSet + + val nodesWithParentInSet = edges.filter(e => e.relationship == "owns" && nodeSet.contains(forceEntIdToUuid(e.parent))) + .map(_.child) + .map(forceEntIdToUuid) + .toSet + + nodes.filterNot(nodesWithParentInSet.contains) + } + + def treeToFlat(tree: TreeModelFragment): FlatModelFragment = { + + val entities = Vector.newBuilder[EntityDesc] + val points = Vector.newBuilder[PointDesc] + val commands = Vector.newBuilder[CommandDesc] + val endpoints = Vector.newBuilder[EndpointDesc] + + val edges = Vector.newBuilder[EdgeDesc] + + def traverse(node: TreeNode): EntityId = { + + val nodeId = node.node match { + case desc: EntityDesc => + entities += desc; desc.fields.id + case desc: PointDesc => + points += desc; desc.fields.id + case desc: CommandDesc => + commands += desc; desc.fields.id + } + + node.extParentRefs.map(parent => EdgeDesc(parent, "owns", nodeId)).foreach(edges.+=) + node.otherChildRefs.map { case (child, rel) => EdgeDesc(nodeId, rel, child) }.foreach(edges.+=) + node.otherParentRefs.map { case (parent, rel) => EdgeDesc(parent, rel, nodeId) }.foreach(edges.+=) + node.commandRefs.map(cmdId => EdgeDesc(nodeId, "feedback", cmdId)).foreach(edges.+=) + + val childIds = node.children.map(traverse) + + childIds.map(childId => EdgeDesc(nodeId, "owns", childId)).foreach(edges.+=) + + nodeId + } + + tree.roots.foreach(traverse) + + tree.endpoints.foreach { node => + endpoints += node.node + val nodeId = node.node.fields.id + node.sourceRefs.foreach(ref => edges += EdgeDesc(nodeId, "source", ref)) + node.otherParentRefs.foreach { case (ref, relation) => edges += EdgeDesc(ref, relation, nodeId) } + node.otherChildRefs.foreach { case (ref, relation) => edges += EdgeDesc(nodeId, relation, ref) } + } + + FlatModelFragment(entities.result(), points.result(), commands.result(), endpoints.result(), edges.result()) + } +} diff --git a/loading/src/main/scala/io/greenbus/loader/set/SetDiff.scala b/loading/src/main/scala/io/greenbus/loader/set/SetDiff.scala new file mode 100644 index 0000000..11ad18c --- /dev/null +++ b/loading/src/main/scala/io/greenbus/loader/set/SetDiff.scala @@ -0,0 +1,220 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.loader.set + +import java.util.UUID + +import io.greenbus.loader.set.Mdl.{ FlatModelFragment, ModelObject } + +object SetDiff { + + def checkForDuplicateKeys(flat: FlatModelFragment): Unit = { + + def checkEnt(e: ModelObject): Unit = { check(e.fields) } + + def check(fields: EntityFields): Unit = { + fields.kvs.groupBy(_._1).foreach { + case (key, tups) => + if (tups.size > 1) { + throw new LoadingException(s"Duplicate key $key for ${EntityId.prettyPrint(fields.id)}") + } + } + } + + flat.modelEntities.foreach(checkEnt) + flat.points.foreach(checkEnt) + flat.commands.foreach(checkEnt) + flat.endpoints.foreach(checkEnt) + } + + def analyzeIdentities(flat: FlatModelFragment): (Seq[UUID], Seq[String], Seq[(UUID, String)]) = { + + val (objUuidsSet, allObjNames, idTuples) = fragmentIdBreakdown(flat) + + def filterExternalRefs(refUuids: Seq[UUID], refNames: Seq[String], refFullIds: Seq[(UUID, String)]) = { + val externalUuidOnlyRefs = refUuids.filterNot(objUuidsSet.contains) + val externalNameRefs = refNames.filterNot(allObjNames.contains) + + val externalUuidsOfFullRefs = refFullIds.filterNot { case (uuid, name) => refUuids.contains(uuid) || refNames.contains(name) }.map(_._1) + + val allExternalUuidRefs = externalUuidOnlyRefs ++ externalUuidsOfFullRefs + + (allExternalUuidRefs, externalNameRefs) + } + + val allRefIds = flat.edges.flatMap { edge => Seq(edge.parent, edge.child) } + val (refUuids, refNames, refFullIds) = idReferenceBreakdown(allRefIds) + val (extEdgeRefsByUuid, extEdgeRefsByName) = filterExternalRefs(refUuids, refNames, refFullIds) + + val calcRefIds = flat.points.flatMap(p => calcHolderRefs(p.calcHolder)) + val (calcRefUuids, calcRefNames, calcRefFullIds) = idReferenceBreakdown(calcRefIds) + val (extCalcRefsByUuid, extCalcRefsByName) = filterExternalRefs(calcRefUuids, calcRefNames, calcRefFullIds) + + (extEdgeRefsByUuid ++ extCalcRefsByUuid, extEdgeRefsByName ++ extCalcRefsByName, idTuples) + } + + def resolveIdentities(flat: FlatModelFragment, foundUuids: Set[UUID], foundNames: Set[String]) = { + + val (objUuidsSet, objNamesSet, _) = fragmentIdBreakdown(flat) + + val extantUuids = foundUuids ++ objUuidsSet + val extantNames = foundNames ++ objNamesSet + + def resolveId(id: EntityId): Boolean = { + id match { + case FullEntId(uuid, name) => extantUuids.contains(uuid) || extantNames.contains(name) + case UuidEntId(uuid) => extantUuids.contains(uuid) + case NamedEntId(name) => extantNames.contains(name) + } + } + + flat.points.foreach { point => + val ids = calcHolderRefs(point.calcHolder) + ids.foreach { id => + if (!resolveId(id)) { + throw new LoadingException(s"Calculation for point ${EntityId.prettyPrint(point.fields.id)} has unresolved reference to input ${EntityId.prettyPrint(id)}") + } + } + } + + val resolvedEdges = flat.edges.flatMap { edge => + if (resolveId(edge.parent) && resolveId(edge.child)) Some(edge) else None + } + + flat.copy(edges = resolvedEdges) + } + + private def fragmentIdBreakdown(flat: FlatModelFragment): (Set[UUID], Set[String], Seq[(UUID, String)]) = { + val allFields = flat.modelEntities.map(_.fields.id) ++ + flat.points.map(_.fields.id) ++ + flat.commands.map(_.fields.id) ++ + flat.endpoints.map(_.fields.id) + + val (objNamesOnly, objFullIds) = objectIdBreakdown(allFields) + val objUuidsSet = objFullIds.map(_._1).toSet + val allObjNames = (objNamesOnly ++ objFullIds.map(_._2)).toSet + + (objUuidsSet, allObjNames, objFullIds) + } + + def objectIdBreakdown(ids: Seq[EntityId]): (Seq[String], Seq[(UUID, String)]) = { + + val namedOnly = Vector.newBuilder[String] + val fullyIdentified = Vector.newBuilder[(UUID, String)] + + ids.foreach { + case FullEntId(uuid, name) => fullyIdentified += ((uuid, name)) + case NamedEntId(name) => namedOnly += name + case _ => throw new LoadingException("IDs for model objects must include a name") + } + + (namedOnly.result(), fullyIdentified.result()) + } + + def idReferenceBreakdown(ids: Seq[EntityId]): (Seq[UUID], Seq[String], Seq[(UUID, String)]) = { + + val uuidOnly = Vector.newBuilder[UUID] + val namedOnly = Vector.newBuilder[String] + val fullyIdentified = Vector.newBuilder[(UUID, String)] + + ids.foreach { + case FullEntId(uuid, name) => fullyIdentified += ((uuid, name)) + case NamedEntId(name) => namedOnly += name + case UuidEntId(uuid) => uuidOnly += uuid + } + + (uuidOnly.result(), namedOnly.result(), fullyIdentified.result()) + } + + def checkForNameDuplicatesInFragment(names: Seq[String]): Unit = { + val grouped = names.groupBy(s => s) + + val duplicateGroups = grouped.values.filter(_.size > 1).toVector + + if (duplicateGroups.nonEmpty) { + val duplicates = duplicateGroups.map(_.head) + + throw new LoadingException("The following names were duplicated in the model: " + duplicates.mkString(", ")) + } + } + + def namesInSet(set: FlatModelFragment): Seq[String] = { + val allFields = set.modelEntities.map(_.fields) ++ set.points.map(_.fields) ++ set.commands.map(_.fields) ++ set.endpoints.map(_.fields) + + val namesInFragment = allFields.map(_.id).map { + case FullEntId(uuid, name) => name + case NamedEntId(name) => name + case UuidEntId(uuid) => throw new LoadingException("Equipment must have a name specified") + } + + namesInFragment + } + + def findUnresolvedNameReferences(set: FlatModelFragment, namesInFragmentSet: Set[String]): Seq[String] = { + + val nameOnlyEdgeReferences = set.edges.flatMap { edge => Seq(idToOnlyNameOpt(edge.parent), idToOnlyNameOpt(edge.child)).flatten } + + val unresolvedEdgeNames = nameOnlyEdgeReferences.filterNot(namesInFragmentSet.contains) + + val nameOnlyCalcReferences = set.points.map(_.calcHolder).flatMap(calcHolderToOnlyNameOpt) + + val unresolvedCalcInputNames = nameOnlyCalcReferences.filterNot(namesInFragmentSet.contains) + + unresolvedEdgeNames ++ unresolvedCalcInputNames + } + + private def calcHolderRefs(calc: CalculationHolder): Seq[EntityId] = { + calc match { + case UnresolvedCalc(_, inputs) => inputs.map(_._1) + case _ => Seq() + } + } + + private def calcHolderToOnlyNameOpt(calc: CalculationHolder): Seq[String] = { + calc match { + case UnresolvedCalc(_, inputs) => + inputs.flatMap { + case (id, _) => idToOnlyNameOpt(id) + } + case _ => Seq() + } + } + + private def idToOnlyNameOpt(id: EntityId): Option[String] = { + id match { + case NamedEntId(name) => Some(name) + case _ => None + } + } + + private def idToNameOpt(id: EntityId): Option[String] = { + id match { + case NamedEntId(name) => Some(name) + case FullEntId(_, name) => Some(name) + case _ => None + } + } + + private def idToUuidNamePair(id: EntityId): Option[(UUID, String)] = { + id match { + case FullEntId(uuid, name) => Some((uuid, name)) + case _ => None + } + } +} diff --git a/loading/src/main/scala/io/greenbus/loader/set/Tracker.scala b/loading/src/main/scala/io/greenbus/loader/set/Tracker.scala new file mode 100644 index 0000000..e493432 --- /dev/null +++ b/loading/src/main/scala/io/greenbus/loader/set/Tracker.scala @@ -0,0 +1,54 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.loader.set + +import java.io.PrintStream + +trait Tracker { + def added() + def added(n: Int) +} + +class PrintTracker(width: Int, stream: PrintStream) extends Tracker { + + private var index = 0 + + private def incr() = { + index = index + 1 + if (index > width) { + stream.append('\n') + index = 0 + } + } + + def added(n: Int) { + Range(0, n).foreach(_ => added()) + } + + def added() { + stream.append(".") + incr() + } +} + +object SilentTracker extends Tracker { + def added(): Unit = {} + + def added(n: Int): Unit = {} +} \ No newline at end of file diff --git a/loading/src/main/scala/io/greenbus/loader/set/UUIDHelpers.scala b/loading/src/main/scala/io/greenbus/loader/set/UUIDHelpers.scala new file mode 100644 index 0000000..ba9fa00 --- /dev/null +++ b/loading/src/main/scala/io/greenbus/loader/set/UUIDHelpers.scala @@ -0,0 +1,31 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.loader.set + +import java.util.UUID + +import io.greenbus.client.service.proto.Model.{ ModelID, ModelUUID } + +object UUIDHelpers { + implicit def uuidToProtoUUID(uuid: UUID): ModelUUID = ModelUUID.newBuilder().setValue(uuid.toString).build() + implicit def protoUUIDToUuid(uuid: ModelUUID): UUID = UUID.fromString(uuid.getValue) + + implicit def longToProtoId(id: Long): ModelID = ModelID.newBuilder().setValue(id.toString).build() + implicit def protoIdToLong(id: ModelID): Long = id.getValue.toLong +} diff --git a/loading/src/main/scala/io/greenbus/loader/set/Upload.scala b/loading/src/main/scala/io/greenbus/loader/set/Upload.scala new file mode 100644 index 0000000..6629574 --- /dev/null +++ b/loading/src/main/scala/io/greenbus/loader/set/Upload.scala @@ -0,0 +1,209 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.loader.set + +import java.io.File +import java.util.UUID + +import com.google.protobuf.ByteString +import org.apache.commons.io.FileUtils +import io.greenbus.msg.Session +import io.greenbus.client.service.ModelService +import io.greenbus.client.service.proto.Model.{ EntityKeyValue, StoredValue } +import io.greenbus.client.service.proto.ModelRequests._ +import io.greenbus.loader.set.Actions._ +import io.greenbus.loader.set.UUIDHelpers._ +import io.greenbus.util.Timing + +import scala.collection.JavaConversions._ +import scala.concurrent.duration._ +import scala.concurrent.{ Await, Future } + +object Upload { + + val allChunkSize = 300 + + def push(session: Session, actions: ActionsList, idTuples: Seq[(UUID, String)]) = { + + val timer = Timing.Stopwatch.start + val tracker = new PrintTracker(60, System.out) + + val modelClient = ModelService.client(session) + + val keyValuesByNameLoaded = actions.keyValuePutByNames.map(kvn => (kvn.name, kvn.key, valueHolderToStoredValue(kvn.vh))) + val keyValuesByUuidLoaded = actions.keyValuePutByUuids.map(kvu => (kvu.uuid, kvu.key, valueHolderToStoredValue(kvu.vh))) + + println("\n== Progress:") + + // DELETES + chunkedCalls(allChunkSize, actions.entityDeletes.map(_.uuid).map(uuidToProtoUUID), modelClient.delete, tracker) + chunkedCalls(allChunkSize, actions.pointDeletes.map(_.uuid).map(uuidToProtoUUID), modelClient.deletePoints, tracker) + chunkedCalls(allChunkSize, actions.commandDeletes.map(_.uuid).map(uuidToProtoUUID), modelClient.deleteCommands, tracker) + chunkedCalls(allChunkSize, actions.endpointDeletes.map(_.uuid).map(uuidToProtoUUID), modelClient.deleteEndpoints, tracker) + + val delKeyPairs = actions.keyValueDeletes.map(dkv => EntityKeyPair.newBuilder().setUuid(uuidToProtoUUID(dkv.uuid)).setKey(dkv.key).build()) + val deletedKvs = chunkedCalls(allChunkSize, delKeyPairs, modelClient.deleteEntityKeyValues, tracker) + + chunkedCalls(allChunkSize, actions.edgeDeletes.map(toTemplate), modelClient.deleteEdges, tracker) + + // ADDS/ MODIFIES + val ents = chunkedCalls(allChunkSize, actions.entityPuts.map(toTemplate), modelClient.put, tracker) + + val points = chunkedCalls(allChunkSize, actions.pointPuts.map(toTemplate), modelClient.putPoints, tracker) + + val commands = chunkedCalls(allChunkSize, actions.commandPuts.map(toTemplate), modelClient.putCommands, tracker) + + val endpoints = chunkedCalls(allChunkSize, actions.endpointPuts.map(toTemplate), modelClient.putEndpoints, tracker) + + val prevIdMap = idTuples.map(tup => (tup._2, tup._1)).toMap + + val putNameMap: Map[String, UUID] = (ents.map(obj => (obj.getName, protoUUIDToUuid(obj.getUuid))) ++ + points.map(obj => (obj.getName, protoUUIDToUuid(obj.getUuid))) ++ + commands.map(obj => (obj.getName, protoUUIDToUuid(obj.getUuid))) ++ + endpoints.map(obj => (obj.getName, protoUUIDToUuid(obj.getUuid)))).toMap + + val nameMap = prevIdMap ++ putNameMap + + def nameToUuid(name: String) = nameMap.getOrElse(name, throw new LoadingException(s"Name $name not found from object puts")) + + val kvPuts: Seq[(UUID, String, StoredValue)] = + keyValuesByNameLoaded.map { case (name, key, sv) => (nameToUuid(name), key, sv) } ++ + keyValuesByUuidLoaded + + val kvs = chunkedCalls(allChunkSize, kvPuts.map(toTemplate), modelClient.putEntityKeyValues, tracker) + + chunkedCalls(allChunkSize, actions.calcPutsByUuid.map(put => buildCalc(put.uuid, put.calc, nameMap)), modelClient.putEntityKeyValues, tracker) + chunkedCalls(allChunkSize, actions.calcPutsByName.map(put => buildCalc(nameToUuid(put.name), put.calc, nameMap)), modelClient.putEntityKeyValues, tracker) + + val resolvedEdgeAdds = actions.edgePuts.map(put => Importer.resolveEdgeDesc(put.desc, nameMap)).distinct + + chunkedCalls(allChunkSize, resolvedEdgeAdds.map(toTemplate), modelClient.putEdges, tracker) + + println("\n") + println(s"== Finished in ${timer.elapsed} ms") + } + + def chunkedCalls[A, B](chunkSize: Int, all: Seq[A], put: Seq[A] => Future[Seq[B]], tracker: Tracker): Seq[B] = { + all.grouped(chunkSize).flatMap { chunk => + val results = Await.result(put(chunk), Duration(60000, MILLISECONDS)) + tracker.added() + results + }.toVector + } + + def toTemplate(act: PutEntity): EntityTemplate = { + val b = EntityTemplate.newBuilder() + .setName(act.name) + .addAllTypes(act.types) + + act.uuidOpt.map(uuidToProtoUUID).foreach(b.setUuid) + + b.build() + } + + def toTemplate(act: PutPoint): PointTemplate = { + val entB = EntityTemplate.newBuilder() + .setName(act.name) + .addAllTypes(act.types) + + act.uuidOpt.map(uuidToProtoUUID).foreach(entB.setUuid) + + PointTemplate.newBuilder() + .setEntityTemplate(entB.build()) + .setPointCategory(act.category) + .setUnit(act.unit) + .build() + } + + def toTemplate(act: PutCommand): CommandTemplate = { + val entB = EntityTemplate.newBuilder() + .setName(act.name) + .addAllTypes(act.types) + + act.uuidOpt.map(uuidToProtoUUID).foreach(entB.setUuid) + + CommandTemplate.newBuilder() + .setEntityTemplate(entB.build()) + .setCategory(act.category) + .setDisplayName(act.displayName) + .build() + } + + def toTemplate(act: PutEndpoint): EndpointTemplate = { + val entB = EntityTemplate.newBuilder() + .setName(act.name) + .addAllTypes(act.types) + + act.uuidOpt.map(uuidToProtoUUID).foreach(entB.setUuid) + + EndpointTemplate.newBuilder() + .setEntityTemplate(entB.build()) + .setProtocol(act.protocol) + .build() + } + + def toTemplate(tup: (UUID, String, UUID)): EntityEdgeDescriptor = { + EntityEdgeDescriptor.newBuilder() + .setParentUuid(uuidToProtoUUID(tup._1)) + .setChildUuid(uuidToProtoUUID(tup._3)) + .setRelationship(tup._2) + .build() + } + def toTemplate(act: DeleteEdge): EntityEdgeDescriptor = { + EntityEdgeDescriptor.newBuilder() + .setParentUuid(uuidToProtoUUID(act.parent)) + .setChildUuid(uuidToProtoUUID(act.child)) + .setRelationship(act.relationship) + .build() + } + + def toTemplate(kv: (UUID, String, StoredValue)): EntityKeyValue = { + val (uuid, key, sv) = kv + EntityKeyValue.newBuilder() + .setUuid(uuidToProtoUUID(uuid)) + .setKey(key) + .setValue(sv) + .build() + } + + def buildCalc(uuid: UUID, calculationHolder: CalculationHolder, nameMap: Map[String, UUID]): EntityKeyValue = { + val calcDesc = Importer.resolveCalc(calculationHolder, nameMap) + EntityKeyValue.newBuilder() + .setUuid(uuidToProtoUUID(uuid)) + .setKey("calculation") + .setValue(StoredValue.newBuilder().setByteArrayValue(ByteString.copyFrom(calcDesc.toByteArray)).build()) + .build() + } + + def valueHolderToStoredValue(vh: ValueHolder): StoredValue = { + vh match { + case SimpleValue(sv) => sv + case ByteArrayValue(bytes) => StoredValue.newBuilder().setByteArrayValue(ByteString.copyFrom(bytes)).build() + case ByteArrayReference(size) => throw new LoadingException("Cannot upload a byte array referenced by size") + case FileReference(filename) => + val bytes = try { + FileUtils.readFileToByteArray(new File(filename)) + } catch { + case ex: Throwable => throw new LoadingException(s"Could not open file $filename: " + ex.getMessage) + } + StoredValue.newBuilder().setByteArrayValue(ByteString.copyFrom(bytes)).build() + } + } + +} diff --git a/mstore-sql/pom.xml b/mstore-sql/pom.xml new file mode 100755 index 0000000..8bf6377 --- /dev/null +++ b/mstore-sql/pom.xml @@ -0,0 +1,63 @@ + + 4.0.0 + + + io.greenbus + greenbus-scala-base + 3.0.0 + ../scala-base + + + greenbus-mstore-sql + jar + + + + AGPLv3 + http://www.gnu.org/licenses/agpl-3.0.txt + + + + + + + + com.mycila.maven-license-plugin + maven-license-plugin + +
../AGPL_FILE_HEADER
+
+
+ + org.apache.maven.plugins + maven-surefire-plugin + + ../ + + +
+
+ + + + io.greenbus + greenbus-client + 3.0.0 + + + io.greenbus + greenbus-sql + 3.0.0 + + + io.greenbus + greenbus-sql + 3.0.0 + test-jar + test + + + + +
diff --git a/mstore-sql/src/main/scala/io/greenbus/mstore/MeasurementValueSource.scala b/mstore-sql/src/main/scala/io/greenbus/mstore/MeasurementValueSource.scala new file mode 100644 index 0000000..34207b4 --- /dev/null +++ b/mstore-sql/src/main/scala/io/greenbus/mstore/MeasurementValueSource.scala @@ -0,0 +1,38 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.mstore + +import java.util.UUID +import io.greenbus.client.service.proto.Measurements.Measurement + +trait MeasurementValueSource { + + def get(ids: Seq[UUID]): Seq[(UUID, Measurement)] +} + +trait MeasurementHistorySource { + def getHistory(id: UUID, begin: Option[Long], end: Option[Long], limit: Int, latest: Boolean): Seq[Measurement] +} + +trait MeasurementValueSink { + def declare(points: Seq[UUID]) + def put(entries: Seq[(UUID, Measurement)]) +} + +trait MeasurementValueStore extends MeasurementValueSource with MeasurementValueSink diff --git a/mstore-sql/src/main/scala/io/greenbus/mstore/sql/MeasurementStoreSchema.scala b/mstore-sql/src/main/scala/io/greenbus/mstore/sql/MeasurementStoreSchema.scala new file mode 100644 index 0000000..a1aa557 --- /dev/null +++ b/mstore-sql/src/main/scala/io/greenbus/mstore/sql/MeasurementStoreSchema.scala @@ -0,0 +1,37 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.mstore.sql + +import org.squeryl.{ KeyedEntity, Schema } +import java.util.UUID +import org.squeryl.PrimitiveTypeMode._ + +case class CurrentValueRow(id: UUID, bytes: Option[Array[Byte]]) extends KeyedEntity[UUID] +case class HistoricalValueRow(pointId: UUID, time: Long, bytes: Array[Byte]) + +object MeasurementStoreSchema extends Schema { + + val currentValues = table[CurrentValueRow] + + val historicalValues = table[HistoricalValueRow] + + on(historicalValues)(s => declare( + columns(s.pointId.~, s.time) are (indexed))) + +} diff --git a/mstore-sql/src/main/scala/io/greenbus/mstore/sql/SqlMeasurementStore.scala b/mstore-sql/src/main/scala/io/greenbus/mstore/sql/SqlMeasurementStore.scala new file mode 100644 index 0000000..58b9fa6 --- /dev/null +++ b/mstore-sql/src/main/scala/io/greenbus/mstore/sql/SqlMeasurementStore.scala @@ -0,0 +1,151 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.mstore.sql + +import java.util.UUID +import io.greenbus.client.service.proto.Measurements.Measurement +import io.greenbus.sql.DbConnection +import org.squeryl.PrimitiveTypeMode._ +import MeasurementStoreSchema._ +import io.greenbus.mstore.{ MeasurementHistorySource, MeasurementValueStore, MeasurementValueSource } + +trait MeasurementStore { + def get(ids: Seq[UUID]): Seq[(UUID, Measurement)] + def put(entries: Seq[(UUID, Measurement)]) +} + +class SqlCurrentValueAndHistorian(sql: DbConnection) extends MeasurementValueStore with MeasurementHistorySource { + + def declare(points: Seq[UUID]) { + sql.inTransaction { + CurrentValueOperations.declare(points) + } + } + + def put(entries: Seq[(UUID, Measurement)]) { + + val withTime: Seq[(UUID, Long, Array[Byte])] = + entries.map { + case (id, meas) => + val time = if (meas.hasTime) meas.getTime else System.currentTimeMillis() + (id, time, meas.toByteArray) + } + + val withoutTime = withTime.map(tup => (tup._1, tup._3)) + + sql.inTransaction { + CurrentValueOperations.put(withoutTime) + HistoricalValueOperations.put(withTime) + } + } + + def get(ids: Seq[UUID]): Seq[(UUID, Measurement)] = { + sql.inTransaction { + CurrentValueOperations.get(ids) + } + } + + def getHistory(id: UUID, begin: Option[Long], end: Option[Long], limit: Int, latest: Boolean): Seq[Measurement] = { + sql.inTransaction { + HistoricalValueOperations.getHistory(id, begin, end, limit, latest) + } + } +} + +// Used in services that already set up a squeryl transaction +object SimpleInTransactionCurrentValueStore extends MeasurementValueSource { + def get(ids: Seq[UUID]): Seq[(UUID, Measurement)] = { + CurrentValueOperations.get(ids) + } +} +object SimpleInTransactionHistoryStore extends MeasurementHistorySource { + def getHistory(id: UUID, begin: Option[Long], end: Option[Long], limit: Int, latest: Boolean): Seq[Measurement] = { + HistoricalValueOperations.getHistory(id, begin, end, limit, latest) + } +} + +object CurrentValueOperations { + + def declare(points: Seq[UUID]) { + val distinctPoints = points.distinct + val existing = + from(currentValues)(v => + where(v.id in distinctPoints) + select (v.id)).toSet + + val inserts = distinctPoints.filterNot(existing.contains).map(CurrentValueRow(_, None)) + currentValues.insert(inserts) + } + + def put(entries: Seq[(UUID, Array[Byte])]) { + val rows = entries.map { + case (id, bytes) => CurrentValueRow(id, Some(bytes)) + } + + currentValues.forceUpdate(rows) + } + + def get(ids: Seq[UUID]): Seq[(UUID, Measurement)] = { + val results = + from(currentValues)(cv => + where((cv.id in ids) and (cv.bytes isNotNull)) + select (cv.id, cv.bytes)).toSeq + + results.map { + case (id, bytes) => (id, Measurement.parseFrom(bytes.get)) + } + } +} + +object HistoricalValueOperations { + + def put(entries: Seq[(UUID, Long, Array[Byte])]) { + val rows = entries.map { + case (id, time, bytes) => HistoricalValueRow(id, time, bytes) + } + + historicalValues.insert(rows) + } + + def getHistory(id: UUID, begin: Option[Long], end: Option[Long], limit: Int, latest: Boolean): Seq[Measurement] = { + + import org.squeryl.dsl.ast.{ OrderByArg, ExpressionNode } + def timeOrder(time: ExpressionNode) = { + if (!latest) { + new OrderByArg(time).asc + } else { + new OrderByArg(time).desc + } + } + + // Switch ordering to get either beginning of the window or end of the window + val bytes: Seq[Array[Byte]] = + from(historicalValues)(hv => + where(hv.pointId === id and + (hv.time gt begin.?) and + (hv.time lte end.?)) + select (hv.bytes) + orderBy (timeOrder(hv.time))).page(0, limit).toSeq + + val inOrder = if (!latest) bytes else bytes.reverse + + inOrder.map(Measurement.parseFrom) + } +} + diff --git a/mstore-sql/src/test/scala/io/greenbus/mstore/sql/SqlCurrentValueAndHistorianTest.scala b/mstore-sql/src/test/scala/io/greenbus/mstore/sql/SqlCurrentValueAndHistorianTest.scala new file mode 100644 index 0000000..b7aa30c --- /dev/null +++ b/mstore-sql/src/test/scala/io/greenbus/mstore/sql/SqlCurrentValueAndHistorianTest.scala @@ -0,0 +1,195 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.mstore.sql + +import io.greenbus.sql.test.{ RunTestsInsideTransaction, DatabaseUsingTestBase } +import java.util.UUID +import io.greenbus.client.service.proto.Measurements.{ Quality, Measurement } +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner + +@RunWith(classOf[JUnitRunner]) +class SqlCurrentValueAndHistorianTest extends DatabaseUsingTestBase with RunTestsInsideTransaction { + + def schemas = List(MeasurementStoreSchema) + + def buildMeas(v: Int, time: Long): Measurement = { + Measurement.newBuilder + .setType(Measurement.Type.INT) + .setIntVal(v) + .setTime(time) + .setQuality(Quality.newBuilder) + .build + } + + def simplify(m: Measurement): (Long, Long) = { + (m.getIntVal, m.getTime) + } + + def check(correct: Set[(UUID, (Long, Long))])(op: => Seq[(UUID, Measurement)]) { + val results = op + results.size should equal(correct.size) + results.map(tup => (tup._1, simplify(tup._2))).toSet should equal(correct) + } + + def checkHist(correct: Seq[(Long, Long)])(op: => Seq[Measurement]) { + val results = op + results.size should equal(correct.size) + results.map(simplify).toList should equal(correct) + } + + test("Current value") { + + val mstore = new SqlCurrentValueAndHistorian(dbConnection) + + val point01 = UUID.randomUUID() + val point02 = UUID.randomUUID() + + mstore.declare(Seq(point01, point02)) + + mstore.get(Seq(point01)).size should equal(0) + + mstore.put(Seq((point01, buildMeas(1, 44)))) + + check(Set((point01, (1, 44)))) { + mstore.get(Seq(point01)) + } + + mstore.put(Seq((point02, buildMeas(2, 55)))) + + check(Set((point02, (2, 55)))) { + mstore.get(Seq(point02)) + } + check(Set((point01, (1, 44)), (point02, (2, 55)))) { + mstore.get(Seq(point01, point02)) + } + + mstore.put(Seq((point01, buildMeas(3, 33)), (point02, buildMeas(4, 66)))) + + check(Set((point01, (3, 33)), (point02, (4, 66)))) { + mstore.get(Seq(point01, point02)) + } + } + + test("Historical values") { + + val mstore = new SqlCurrentValueAndHistorian(dbConnection) + + val point01 = UUID.randomUUID() + val point02 = UUID.randomUUID() + + mstore.getHistory(point01, None, None, 100, false).size should equal(0) + + mstore.put(Seq((point01, buildMeas(100, 1)))) + + checkHist(Seq((100, 1))) { + mstore.getHistory(point01, None, None, 100, false) + } + + mstore.put(Seq((point01, buildMeas(101, 2)))) + mstore.put(Seq((point02, buildMeas(200, 1)))) + mstore.put(Seq((point01, buildMeas(102, 3)))) + mstore.put(Seq((point02, buildMeas(201, 2)))) + mstore.put(Seq((point01, buildMeas(104, 4)))) + + checkHist(Seq((100, 1), (101, 2), (102, 3), (104, 4))) { + mstore.getHistory(point01, None, None, 100, false) + } + checkHist(Seq((200, 1), (201, 2))) { + mstore.getHistory(point02, None, None, 100, false) + } + } + + test("Historical paging") { + + val mstore = new SqlCurrentValueAndHistorian(dbConnection) + + val point01 = UUID.randomUUID() + + mstore.put(Seq((point01, buildMeas(100, 1)))) + mstore.put(Seq((point01, buildMeas(101, 2)))) + mstore.put(Seq((point01, buildMeas(102, 3)))) + mstore.put(Seq((point01, buildMeas(103, 4)))) + mstore.put(Seq((point01, buildMeas(104, 5)))) + mstore.put(Seq((point01, buildMeas(105, 6)))) + + checkHist(Seq((100, 1), (101, 2))) { + mstore.getHistory(point01, None, None, 2, false) + } + checkHist(Seq((102, 3), (103, 4))) { + mstore.getHistory(point01, Some(2), None, 2, false) + } + checkHist(Seq((104, 5), (105, 6))) { + mstore.getHistory(point01, Some(4), None, 2, false) + } + } + + test("Historical paging backwards") { + + val mstore = new SqlCurrentValueAndHistorian(dbConnection) + + val point01 = UUID.randomUUID() + + mstore.put(Seq((point01, buildMeas(100, 1)))) + mstore.put(Seq((point01, buildMeas(101, 2)))) + mstore.put(Seq((point01, buildMeas(102, 3)))) + mstore.put(Seq((point01, buildMeas(103, 4)))) + mstore.put(Seq((point01, buildMeas(104, 5)))) + mstore.put(Seq((point01, buildMeas(105, 6)))) + + // window end is >=, otherwise we lose when multi per ms + checkHist(Seq((104, 5), (105, 6))) { + mstore.getHistory(point01, None, None, 2, true) + } + checkHist(Seq((102, 3), (103, 4), (104, 5))) { + mstore.getHistory(point01, None, Some(5), 3, true) + } + checkHist(Seq((100, 1), (101, 2), (102, 3))) { + mstore.getHistory(point01, None, Some(3), 3, true) + } + } + + test("Historical limit justification") { + + val mstore = new SqlCurrentValueAndHistorian(dbConnection) + + val point01 = UUID.randomUUID() + + mstore.put(Seq((point01, buildMeas(100, 1)))) + mstore.put(Seq((point01, buildMeas(101, 2)))) + mstore.put(Seq((point01, buildMeas(102, 3)))) + mstore.put(Seq((point01, buildMeas(103, 4)))) + mstore.put(Seq((point01, buildMeas(104, 5)))) + mstore.put(Seq((point01, buildMeas(105, 6)))) + + checkHist(Seq((100, 1), (101, 2))) { + mstore.getHistory(point01, None, None, 2, false) + } + checkHist(Seq((104, 5), (105, 6))) { + mstore.getHistory(point01, None, None, 2, true) + } + checkHist(Seq((102, 3), (103, 4))) { + mstore.getHistory(point01, Some(2), Some(5), 2, false) + } + checkHist(Seq((103, 4), (104, 5))) { + mstore.getHistory(point01, Some(2), Some(5), 2, true) + } + } + +} diff --git a/pom.xml b/pom.xml new file mode 100755 index 0000000..2f129b5 --- /dev/null +++ b/pom.xml @@ -0,0 +1,310 @@ + + + + 4.0.0 + io.greenbus + greenbus-parent + pom + 3.0.0 + + + UTF-8 + 2.10.1 + 2.10 + 3.1.0 + 1.7.2 + 1.0.1 + 1.9.1 + 0.1.4 + 1.1 + 2.2 + 1.9.0 + 2.3 + 2.8.1 + 0.1.11 + 0.20 + + 3.0.0 + 0.9.5-6 + 2.2 + 1.4 + 1.5.4 + 1.5 + 2.2.2 + 3.1.0 + 1.8.5 + 0.9.27 + + 1.1 + 1.3 + 2.2 + 9.0-801.jdbc4 + 2.0.4-OSGI + + + ../ + + + + scala-base + util + client + app-framework + loading + loader-xml + cli + simulator + calculation + sql + mstore-sql + processing + services + integration + + + + + totalgrid-release + https://repo.totalgrid.org-releases + https://repo.totalgrid.org/artifactory/totalgrid-release + + false + + + + + totalgrid-snapshot + https://repo.totalgrid.org-snapshots + https://repo.totalgrid.org/artifactory/totalgrid-snapshot + + true + + + + + third-party-release + https://repo.totalgrid.org-releases + https://repo.totalgrid.org/artifactory/third-party-release + + false + + + + + third-party-snapshot + https://repo.totalgrid.org-snapshots + https://repo.totalgrid.org/artifactory/third-party-snapshot + + true + + + + + scala-tools-releases + Scala-tools Maven2 Repository + http://scala-tools.org/repo-releases + + false + + + + + Akka + Akka Maven2 Repository + http://akka.io/repository/ + + false + + + + + maven2-repository.dev.java.net + Java.net Repository for Maven + http://download.java.net/maven/2/ + default + + + + + + totalgrid-release + https://repo.totalgrid.org-releases + https://repo.totalgrid.org/artifactory/totalgrid-release + + false + + + + totalgrid-snapshot + https://repo.totalgrid.org-snapshot + https://repo.totalgrid.org/artifactory/totalgrid-snapshot + + true + + + + + third-party-release + https://repo.totalgrid.org-releases + https://repo.totalgrid.org/artifactory/third-party-release + + false + + + + + scala-tools-releases + http://scala-tools.org/repo-releases + + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + ${maven-javadoc-plugin.version} + + + + + + + + org.codehaus.mojo + cobertura-maven-plugin + 2.4 + + xml + + + + + com.mycila.maven-license-plugin + maven-license-plugin + ${maven-license-plugin.version} + + true + true + true + + + + check-headers + process-sources + + format + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.7.1 + + + + **/*.class + + brief + false + + + + + org.apache.maven.plugins + maven-source-plugin + 2.1.2 + + + + attach-sources + + jar + + + + + + + + + com.googlecode.maven-java-formatter-plugin + maven-java-formatter-plugin + 0.3.1 + + + + + format + + + + + + + **/*.java + **/*.xml + + ${main.basedir}/CodeFormat.xml + LF + + + + + + + + slf4j-simple + + + org.slf4j + slf4j-simple + ${slf4j-api.version} + + + + + javadoc + + + + org.apache.maven.plugins + maven-javadoc-plugin + ${maven-javadoc-plugin.version} + + + + attach-javadocs + + jar + + + + + + + + + + + + + junit + junit + 4.8.2 + test + + + org.slf4j + slf4j-simple + ${slf4j-api.version} + test + + + + diff --git a/processing/pom.xml b/processing/pom.xml new file mode 100755 index 0000000..4170472 --- /dev/null +++ b/processing/pom.xml @@ -0,0 +1,74 @@ + + 4.0.0 + + + io.greenbus + greenbus-scala-base + 3.0.0 + ../scala-base + + + greenbus-processing + jar + + + + AGPLv3 + http://www.gnu.org/licenses/agpl-3.0.txt + + + + + + + + com.mycila.maven-license-plugin + maven-license-plugin + +
../AGPL_FILE_HEADER
+
+
+
+
+ + + + io.greenbus + greenbus-client + 3.0.0 + + + io.greenbus + greenbus-services + 3.0.0 + + + io.greenbus + greenbus-mstore-sql + 3.0.0 + + + io.greenbus + greenbus-app-framework + 3.0.0 + + + io.greenbus.msg + greenbus-msg-qpid + 1.0.0 + + + com.typesafe.akka + akka-actor_2.10 + 2.2.0 + + + com.typesafe.akka + akka-slf4j_2.10 + 2.2.0 + + + + +
diff --git a/processing/src/main/scala/io/greenbus/measproc/ActorForwardingServiceHandler.scala b/processing/src/main/scala/io/greenbus/measproc/ActorForwardingServiceHandler.scala new file mode 100644 index 0000000..bc8dc8b --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/ActorForwardingServiceHandler.scala @@ -0,0 +1,33 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc + +import akka.actor.ActorRef +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.service.ServiceHandler +import io.greenbus.measproc.ActorForwardingServiceHandler.TypeFactory + +object ActorForwardingServiceHandler { + type TypeFactory[A] = (Array[Byte], (Array[Byte]) => Unit) => A +} +class ActorForwardingServiceHandler[A](ref: ActorRef, factory: TypeFactory[A]) extends ServiceHandler with Logging { + def handleMessage(msg: Array[Byte], responseHandler: (Array[Byte]) => Unit) { + ref ! factory(msg, responseHandler) + } +} diff --git a/processing/src/main/scala/io/greenbus/measproc/EndpointProcessor.scala b/processing/src/main/scala/io/greenbus/measproc/EndpointProcessor.scala new file mode 100644 index 0000000..87ebc7a --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/EndpointProcessor.scala @@ -0,0 +1,171 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc + +import java.util.UUID + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.client.service.proto.EventRequests.EventTemplate +import io.greenbus.client.service.proto.Measurements.Measurement +import io.greenbus.client.service.proto.Model.{ Point, ModelUUID } +import io.greenbus.client.service.proto.Processing.{ MeasOverride, TriggerSet } +import io.greenbus.jmx.MetricsManager +import io.greenbus.measproc.pipeline.ProcessingPipeline +import io.greenbus.measproc.processing.MapCache +import io.greenbus.mstore.MeasurementValueStore + +class EndpointProcessor(endpointName: String, + points: Seq[(ModelUUID, String)], + overrides: Seq[MeasOverride], + triggerSets: Seq[(ModelUUID, TriggerSet)], + store: MeasurementValueStore, + notifications: Seq[(ModelUUID, String, Measurement)] => Unit, + publishEvents: Seq[EventTemplate.Builder] => Unit) extends Logging { + + private val endpointMetrics = new EndpointMetrics(endpointName) + import endpointMetrics._ + + private var pointMap: PointMap = new PointMap(points) + + private val service = new ProcessorService(endpointName, metrics) + + private val pipeline = new ProcessingPipeline( + endpointName, + points.map(_._1.getValue), + postMeasurements, + getMeasurement, + publishEvents, + new MapCache[Boolean], + new MapCache[(Option[Measurement], Option[Measurement])]) + + overrides.foreach(pipeline.addOverride) + triggerSets.foreach { case (uuid, ts) => pipeline.addTriggerSet(uuid, ts) } + store.declare(points.map(_._1).map(ru => UUID.fromString(ru.getValue))) + + private def postMeasurements(mlist: Seq[(String, Measurement)]) { + logger.trace("Posting measurements: " + mlist) + val withIds = mlist map { case (key, m) => (UUID.fromString(key), m) } + + val count = mlist.size + putSize(count) + + storePuts(1) + storePutTime { + store.put(withIds) + } + + val measWithIds = mlist.flatMap { + case (key, m) => + val modelId = ModelUUID.newBuilder().setValue(key).build() + pointMap.idToName.get(key).map { name => + (modelId, name, m) + } + } + + notifications(measWithIds) + } + private def getMeasurement(key: String): Option[Measurement] = { + currentValueGets(1) + currentValueGetTime { + store.get(Seq(UUID.fromString(key))).headOption.map(_._2) + } + } + + def modifyLastValues(measurements: Seq[(String, Measurement)]): Unit = { + pipeline.modifyLastValues(measurements) + } + + def handle(msg: Array[Byte], response: Array[Byte] => Unit): Unit = { + service.handle(msg, response, pipeline.process, pointMap) + } + + def pointSet: Seq[UUID] = { + pointMap.all.map(tup => UUID.fromString(tup._1.getValue)) + } + + def pointAdded(point: (ModelUUID, String), measOverride: Option[MeasOverride], triggerSet: Option[TriggerSet]): Unit = { + val pointList = pointMap.all :+ point + store.declare(List(UUID.fromString(point._1.getValue))) + pointMap = new PointMap(pointList) + pipeline.updatePointsList(pointList.map(_._1.getValue)) + measOverride.foreach(pipeline.addOverride) + triggerSet.foreach(t => pipeline.addTriggerSet(point._1, t)) + + logger.debug(s"Endpoint $endpointName points updated: " + pointMap.nameToId.keySet.size) + } + + def pointRemoved(point: ModelUUID): Unit = { + val pointList = pointMap.all.filterNot { case (uuid, _) => uuid == point } + + pointMap = new PointMap(pointList) + pipeline.updatePointsList(pointList.map(_._1.getValue)) + pipeline.removeOverride(point) + pipeline.removeTriggerSet(point) + } + + def pointModified(point: Point): Unit = { + val pointTups = pointMap.all.filterNot { case (uuid, _) => uuid == point.getUuid } ++ Seq((point.getUuid, point.getName)) + pointMap = new PointMap(pointTups) + } + + def overridePut(v: MeasOverride): Unit = { + logger.debug(s"Endpoint $endpointName override added: " + pointMap.idToName.get(v.getPointUuid.getValue)) + pipeline.addOverride(v) + } + def overrideDeleted(v: MeasOverride): Unit = { + logger.debug(s"Endpoint $endpointName override removed: " + pointMap.idToName.get(v.getPointUuid.getValue)) + pipeline.removeOverride(v) + } + + def triggerSetPut(uuid: ModelUUID, v: TriggerSet): Unit = { + logger.debug(s"Endpoint $endpointName trigger set added: " + pointMap.idToName.get(uuid.getValue)) + pipeline.addTriggerSet(uuid, v) + } + + def triggerSetDeleted(uuid: ModelUUID): Unit = { + logger.debug(s"Endpoint $endpointName trigger set removed: " + pointMap.idToName.get(uuid.getValue)) + pipeline.removeTriggerSet(uuid) + } + + def register() { + metricsMgr.register() + } + + def unregister() { + metricsMgr.unregister() + } +} + +class EndpointMetrics(endpointName: String) { + val metricsMgr = MetricsManager("io.greenbus.measproc." + endpointName) + val metrics = metricsMgr.metrics("Processor") + + val currentValueGets = metrics.counter("CurrentValueGets") + val currentValueGetTime = metrics.timer("CurrentValueGetTime") + val storePuts = metrics.counter("StorePuts") + val storePutTime = metrics.timer("StorePutTime") + val putSize = metrics.average("StorePutSize") +} + +class PointMap(points: Seq[(ModelUUID, String)]) { + val nameToId: Map[String, ModelUUID] = points.map(tup => (tup._2, tup._1)).toMap + val idToName: Map[String, String] = points.map(tup => (tup._1.getValue, tup._2)).toMap + + def all: Seq[(ModelUUID, String)] = points +} \ No newline at end of file diff --git a/processing/src/main/scala/io/greenbus/measproc/EndpointStreamBootstrapper.scala b/processing/src/main/scala/io/greenbus/measproc/EndpointStreamBootstrapper.scala new file mode 100644 index 0000000..c1ccf55 --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/EndpointStreamBootstrapper.scala @@ -0,0 +1,90 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc + +import akka.actor._ +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.Session +import io.greenbus.app.actor.MessageScheduling +import io.greenbus.client.service.proto.Model.Endpoint +import io.greenbus.measproc.EndpointStreamBootstrapper.ChildFactory + +import scala.concurrent.duration._ + +object EndpointStreamBootstrapper { + val retryPeriod = Duration(3000, MILLISECONDS) + + type ChildFactory = (Endpoint, EndpointConfiguration) => Props + + private case object DoLookup + private case class GotConfiguration(config: EndpointConfiguration) + private case class ConfigurationFailure(ex: Throwable) + + def props(endpoint: Endpoint, session: Session, factory: ChildFactory): Props = { + Props(classOf[EndpointStreamBootstrapper], endpoint, session, factory) + } +} +class EndpointStreamBootstrapper(endpoint: Endpoint, session: Session, factory: ChildFactory) extends Actor with MessageScheduling with Logging { + import context.dispatcher + import io.greenbus.measproc.EndpointStreamBootstrapper._ + import io.greenbus.measproc.ServiceConfiguration._ + + // Restart us if the child restarts, otherwise the restarted child would be with the old data + override def supervisorStrategy: SupervisorStrategy = { + import akka.actor.SupervisorStrategy._ + OneForOneStrategy() { + case _: Throwable => Escalate + } + } + + private var child = Option.empty[ActorRef] + + self ! DoLookup + + def receive = { + + case DoLookup => { + logger.info(s"Requesting configuration for endpoint ${endpoint.getName}") + val configRequest = configForEndpoint(session, endpoint.getUuid) + + configRequest.onSuccess { + case config => self ! GotConfiguration(config) + } + configRequest.onFailure { + case ex: Throwable => self ! ConfigurationFailure(ex) + } + } + + case GotConfiguration(config) => { + child = Some(context.actorOf(factory(endpoint, config))) + } + + case ConfigurationFailure(exception) => { + logger.error(s"Couldn't get configuration for endpoint ${endpoint.getName}: ${exception.getMessage}") + scheduleRetry() + } + } + + private def scheduleRetry() { + context.system.scheduler.scheduleOnce( + retryPeriod, + self, + DoLookup) + } +} diff --git a/processing/src/main/scala/io/greenbus/measproc/EndpointStreamManager.scala b/processing/src/main/scala/io/greenbus/measproc/EndpointStreamManager.scala new file mode 100644 index 0000000..fe0eb98 --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/EndpointStreamManager.scala @@ -0,0 +1,798 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc + +import java.util.UUID + +import akka.actor._ +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.amqp.{ AmqpMessage, AmqpAddressedMessage, AmqpServiceOperations } +import io.greenbus.msg.{ Session, SessionUnusableException, SubscriptionBinding } +import io.greenbus.app.actor.MessageScheduling +import io.greenbus.client.exception.UnauthorizedException +import io.greenbus.client.service.FrontEndService +import io.greenbus.client.service.proto.EventRequests.EventTemplate +import io.greenbus.client.service.proto.FrontEnd.FrontEndConnectionStatus +import io.greenbus.client.service.proto.Measurements.Quality.Validity +import io.greenbus.client.service.proto.Measurements._ +import io.greenbus.client.service.proto.Model.{ Point, Endpoint, ModelUUID } +import io.greenbus.client.service.proto.Processing.{ TriggerSet, MeasOverride } +import io.greenbus.measproc.EventPublisher.EventTemplateGroup +import io.greenbus.measproc.lock.ProcessingLockService +import io.greenbus.mstore.MeasurementValueStore +import io.greenbus.services.authz.AuthLookup +import io.greenbus.services.framework.ModelNotifier +import io.greenbus.services.model.{ FrontEndModel, UUIDHelpers } +import io.greenbus.sql.DbConnection + +import scala.collection.JavaConversions._ + +import scala.concurrent.duration._ + +object EndpointStreamManager extends Logging { + + def mergeableQualityElements(meas: Measurement): (Quality.Builder, DetailQual.Builder) = { + if (meas.hasQuality) { + val detailBuilder = if (meas.getQuality.hasDetailQual) { + meas.getQuality.getDetailQual.toBuilder + } else { + DetailQual.newBuilder() + } + + (meas.getQuality.toBuilder, detailBuilder) + } else { + (Quality.newBuilder(), DetailQual.newBuilder()) + } + } + + sealed trait ConfigUpdate + case class PointAdded(point: (ModelUUID, String), measOverride: Option[MeasOverride], triggerSet: Option[TriggerSet]) extends ConfigUpdate + case class PointRemoved(point: ModelUUID) extends ConfigUpdate + case class PossiblePointNotification(uuid: ModelUUID) extends ConfigUpdate + case class PointMiss(uuid: ModelUUID) extends ConfigUpdate + case class PointModified(point: Point) extends ConfigUpdate + + case class OverridePut(v: MeasOverride) extends ConfigUpdate + case class OverrideDeleted(v: MeasOverride) extends ConfigUpdate + case class TriggerSetPut(uuid: ModelUUID, v: TriggerSet) extends ConfigUpdate + case class TriggerSetDeleted(uuid: ModelUUID) extends ConfigUpdate + + case object HeartbeatCheck + case object MarkRetry + case class RegistrationRequest(msg: Array[Byte], responseHandler: (Array[Byte]) => Unit) + case class MeasBatchRequest(msg: Array[Byte], responseHandler: (Array[Byte]) => Unit, bindingSequence: Long) + case class HeartbeatRequest(msg: Array[Byte], responseHandler: (Array[Byte]) => Unit, bindingSequence: Long) + + case class StreamRegistered(endpointId: ModelUUID, endpointName: String, address: String) + + case object DoStartup + case object DoShutdown + case object CheckLock + + sealed trait State + case object Down extends State + case object Standby extends State + case object Available extends State + case object LinkUp extends State + case object LinkHalfDown extends State + case object LinkHalfDownMarkFailed extends State + + sealed trait Data + case object NoData extends Data + case class StandbyData(processing: ProcessorComponents, config: ConfigComponents) extends Data + case class AvailableData(regServiceBinding: SubscriptionBinding, processing: ProcessorComponents, config: ConfigComponents) extends Data + case class LinkUpData(lastHeartbeat: Long, nodeId: Option[String], binding: ServiceBindingComponents, availData: AvailableData) extends Data + case class LinkHalfDownData(previousValues: Seq[(UUID, Measurement)], nodeId: Option[String], binding: ServiceBindingComponents, availData: AvailableData) extends Data + + case class ServiceBindingComponents(measBinding: SubscriptionBinding, heartbeatBinding: SubscriptionBinding, bindingSeq: Long) + case class ConfigComponents(subscriptions: Seq[SubscriptionBinding], pointSet: Seq[(ModelUUID, String)], outstandingPoints: Set[ModelUUID]) + case class ProcessorComponents(processor: EndpointProcessor, eventPublisher: ActorRef) + + case class StreamResources(session: Session, serviceOps: AmqpServiceOperations, store: MeasurementValueStore) + + def props(endpoint: Endpoint, + resources: StreamResources, + config: EndpointConfiguration, + sql: DbConnection, + auth: AuthLookup, + frontEndModel: FrontEndModel, + statusNotifier: ModelNotifier, + standbyLock: ProcessingLockService, + nodeId: String, + heartbeatTimeout: Long, + markRetryPeriodMs: Long, + standbyLockRetryPeriodMs: Long, + standbyLockExpiryDurationMs: Long): Props = { + + Props( + classOf[EndpointStreamManager], + endpoint, + resources, + config, + sql, + auth, + frontEndModel, + statusNotifier, + standbyLock, + nodeId: String, + heartbeatTimeout, + markRetryPeriodMs, + standbyLockRetryPeriodMs, + standbyLockExpiryDurationMs) + } + + def measNotificationBatch(serviceOps: AmqpServiceOperations, endpoint: Endpoint, meases: Seq[(ModelUUID, String, Measurement)]) { + if (meases.nonEmpty) { + val exchange = classOf[MeasurementNotification].getSimpleName + + val measNotifications = meases.map { + case (uuid, name, m) => + val mn = MeasurementNotification.newBuilder() + .setPointUuid(uuid) + .setPointName(name) + .setValue(m) + .build() + (uuid, name, mn) + } + + val messages = measNotifications.map { + case (uuid, name, mn) => + AmqpAddressedMessage(exchange, uuid.getValue + "." + name.replace('.', '_'), AmqpMessage(mn.toByteArray, None, None)) + } + + serviceOps.publishBatch(messages) + + val batchNotification = MeasurementBatchNotification.newBuilder() + .setEndpointUuid(endpoint.getUuid) + .addAllValues(measNotifications.map(_._3)) + .build() + + serviceOps.publishEvent(classOf[MeasurementBatchNotification].getSimpleName, batchNotification.toByteArray, endpoint.getUuid.getValue) + } + } +} + +import io.greenbus.measproc.EndpointStreamManager._ +class EndpointStreamManager( + endpoint: Endpoint, + resources: StreamResources, + config: EndpointConfiguration, + sql: DbConnection, + auth: AuthLookup, + frontEndModel: FrontEndModel, + statusNotifier: ModelNotifier, + standbyLock: ProcessingLockService, + nodeId: String, + heartbeatTimeout: Long, + markRetryPeriodMs: Long, + standbyLockRetryPeriodMs: Long, + standbyLockExpiryDurationMs: Long) + extends Actor with FSM[State, Data] with MessageScheduling with Logging { + + import context.dispatcher + + override def supervisorStrategy: SupervisorStrategy = { + import akka.actor.SupervisorStrategy._ + OneForOneStrategy(maxNrOfRetries = 3, withinTimeRange = 30.seconds) { + case _: UnauthorizedException => Escalate + case _: SessionUnusableException => Escalate + case _: Throwable => Escalate + } + } + + startWith(Down, NoData) + self ! DoStartup + + when(Down) { + case Event(DoStartup, NoData) => { + logger.debug(s"Starting processor for ${endpoint.getName}") + val (processor, eventPublisher) = setupProcessor(config) + + self ! CheckLock + goto(Standby) using StandbyData( + processing = ProcessorComponents(processor, eventPublisher), + config = ConfigComponents(Seq(config.overrideSubscription, config.triggerSetSubscription), config.points, Set.empty[ModelUUID])) + } + } + + when(Standby) { + case Event(CheckLock, data: StandbyData) => { + try { + val haveLock = standbyLock.acquireOrHold(UUIDHelpers.protoUUIDToUuid(endpoint.getUuid), nodeId, standbyLockExpiryDurationMs) + + if (haveLock) { + val registrationBinding = setupRegistrationService() + + markEndpointOffline(pointSetUuidConversion(data.config.pointSet), data.processing.processor) + + scheduleMsg(standbyLockRetryPeriodMs, CheckLock) + goto(Available) using AvailableData( + regServiceBinding = registrationBinding, + processing = data.processing, + config = data.config) + } else { + scheduleMsg(standbyLockRetryPeriodMs, CheckLock) + stay using data + } + } catch { + case ex: Throwable => + logger.error(s"Endpoint ${endpoint.getName} had error acquiring lock: " + ex) + scheduleMsg(standbyLockRetryPeriodMs, CheckLock) + stay using data + } + } + case Event(configUpdate: ConfigUpdate, data: AvailableData) => { + handleConfigUpdate(configUpdate, data.config, data.processing.processor) + stay using data + } + } + + when(Available) { + case Event(configUpdate: ConfigUpdate, data: AvailableData) => { + handleConfigUpdate(configUpdate, data.config, data.processing.processor) + stay using data + } + case Event(RegistrationRequest(msgBytes, respFunc), data: AvailableData) => { + + val nextBindingSeq = 0 + handleRegistrationAttemptWhileDown(msgBytes, respFunc, nextBindingSeq) match { + case None => + logger.debug(s"Registration failed on endpoint ${endpoint.getName}") + stay using data + case Some(result) => + logger.debug(s"Registration succeeded on endpoint ${endpoint.getName}") + + markEndpointOnline() // TODO: handle failure here + scheduleHeartbeatCheck() + goto(LinkUp) using LinkUpData( + System.currentTimeMillis(), + nodeId = result.nodeId, + binding = ServiceBindingComponents(result.measServiceBinding, result.heartbeatServiceBinding, nextBindingSeq), + availData = data) + } + + } + case Event(HeartbeatCheck, data) => { + logger.warn("Got heartbeat check in available state") + stay using data + } + case Event(CheckLock, data: AvailableData) => { + val haveLock = standbyLock.acquireOrHold(UUIDHelpers.protoUUIDToUuid(endpoint.getUuid), nodeId, standbyLockExpiryDurationMs) + if (haveLock) { + scheduleMsg(standbyLockRetryPeriodMs, CheckLock) + stay using data + } else { + logger.info(s"Endpoint ${endpoint.getName} lost lock while available; going to standby") + scheduleMsg(standbyLockRetryPeriodMs, CheckLock) + data.regServiceBinding.cancel() + goto(Standby) using StandbyData(data.processing, data.config) + } + } + } + + when(LinkUp) { + case Event(configUpdate: ConfigUpdate, data: LinkUpData) => { + handleConfigUpdate(configUpdate, data.availData.config, data.availData.processing.processor) + stay using data + } + case Event(RegistrationRequest(msgBytes, respFunc), data: LinkUpData) => { + val nextSeq = data.binding.bindingSeq + 1 + handleRegistrationAttemptWhileUp(msgBytes, respFunc, nextSeq, data.nodeId) match { + case None => + logger.debug(s"Registration failed on endpoint ${endpoint.getName}") + stay using data + case Some(result) => + logger.debug(s"Registration succeeded on endpoint ${endpoint.getName}") + + data.binding.measBinding.cancel() + data.binding.heartbeatBinding.cancel() + + val bind = ServiceBindingComponents(result.measServiceBinding, result.heartbeatServiceBinding, nextSeq) + + scheduleHeartbeatCheck() + stay using LinkUpData(System.currentTimeMillis(), nodeId = result.nodeId, binding = bind, availData = data.availData) + } + } + case Event(MeasBatchRequest(msgBytes, respFunc, bindSeq), data: LinkUpData) => { + if (bindSeq == data.binding.bindingSeq) { + data.availData.processing.processor.handle(msgBytes, respFunc) + + scheduleHeartbeatCheck() + stay using data.copy(lastHeartbeat = System.currentTimeMillis()) + } else { + stay using data + } + } + case Event(HeartbeatRequest(msgBytes, respFunc, bindSeq), data: LinkUpData) => { + if (bindSeq == data.binding.bindingSeq) { + handleHeartbeat(msgBytes, respFunc) + scheduleHeartbeatCheck() + stay using data.copy(lastHeartbeat = System.currentTimeMillis()) + } else { + stay using data + } + } + case Event(HeartbeatCheck, data: LinkUpData) => { + logger.trace(s"Endpoint ${endpoint.getName} checking heartbeat in LinkUp state") + val now = System.currentTimeMillis() + val elapsed = now - data.lastHeartbeat // TODO: sanity checks on time warping? + + if (elapsed >= heartbeatTimeout) { + try { + val prevValues: Seq[(UUID, Measurement)] = markEndpointOffline(pointSetUuidConversion(data.availData.config.pointSet), data.availData.processing.processor) + + logger.info(s"Endpoint ${endpoint.getName} accepting failover registrations") + goto(LinkHalfDown) using LinkHalfDownData(previousValues = prevValues, nodeId = data.nodeId, data.binding, data.availData) + + } catch { + case ex: Throwable => + logger.error(s"Could not mark endpoint ${endpoint.getName} as down: " + ex) + scheduleMsg(markRetryPeriodMs, MarkRetry) + + logger.info(s"Endpoint ${endpoint.getName} accepting failover registrations, but marking down failed") + goto(LinkHalfDownMarkFailed) using data + } + } else { + // this is likely an old check; an update came in since + stay using data + } + } + case Event(CheckLock, data: LinkUpData) => { + val haveLock = standbyLock.acquireOrHold(UUIDHelpers.protoUUIDToUuid(endpoint.getUuid), nodeId, standbyLockExpiryDurationMs) + if (haveLock) { + scheduleMsg(standbyLockRetryPeriodMs, CheckLock) + stay using data + } else { + logger.info(s"Endpoint ${endpoint.getName} lost lock while link up; going to standby") + data.binding.measBinding.cancel() + data.binding.heartbeatBinding.cancel() + data.availData.regServiceBinding.cancel() + + scheduleMsg(standbyLockRetryPeriodMs, CheckLock) + goto(Standby) using StandbyData(data.availData.processing, data.availData.config) + } + } + } + + when(LinkHalfDownMarkFailed) { + case Event(configUpdate: ConfigUpdate, data: LinkUpData) => { + handleConfigUpdate(configUpdate, data.availData.config, data.availData.processing.processor) + stay using data + } + case Event(MarkRetry, data: LinkUpData) => { + try { + val prevValues: Seq[(UUID, Measurement)] = markEndpointOffline(pointSetUuidConversion(data.availData.config.pointSet), data.availData.processing.processor) + + goto(LinkHalfDown) using LinkHalfDownData(previousValues = prevValues, nodeId = data.nodeId, data.binding, data.availData) + + } catch { + case ex: Throwable => + logger.error(s"Could not mark endpoint ${endpoint.getName} as down: " + ex) + scheduleMsg(markRetryPeriodMs, MarkRetry) + stay using data + } + } + case Event(RegistrationRequest(msgBytes, respFunc), data: LinkUpData) => { + + val nextSeq = data.binding.bindingSeq + 1 + handleRegistrationAttemptWhileDown(msgBytes, respFunc, nextSeq) match { + case None => + logger.debug(s"Registration failed on endpoint ${endpoint.getName}") + stay using data + case Some(result) => + logger.debug(s"Registration succeeded on endpoint ${endpoint.getName}") + + data.binding.measBinding.cancel() + data.binding.heartbeatBinding.cancel() + + val bind = ServiceBindingComponents(result.measServiceBinding, result.heartbeatServiceBinding, nextSeq) + + scheduleHeartbeatCheck() + goto(LinkUp) using LinkUpData(System.currentTimeMillis(), nodeId = data.nodeId, binding = bind, availData = data.availData) + } + } + case Event(HeartbeatRequest(msgBytes, respFunc, bindSeq), data: LinkUpData) => { + if (bindSeq == data.binding.bindingSeq) { + markEndpointOnline() + handleHeartbeat(msgBytes, respFunc) + + scheduleHeartbeatCheck() + stay using data.copy(lastHeartbeat = System.currentTimeMillis()) + } else { + stay using data + } + } + case Event(MeasBatchRequest(msgBytes, respFunc, bindSeq), data: LinkUpData) => { + if (bindSeq == data.binding.bindingSeq) { + logger.debug(s"Endpoint ${endpoint.getName} was half down and came back up") + + markEndpointOnline() + data.availData.processing.processor.handle(msgBytes, respFunc) + + scheduleHeartbeatCheck() + goto(LinkUp) using data.copy(lastHeartbeat = System.currentTimeMillis()) + } else { + stay using data + } + } + case Event(HeartbeatCheck, data: LinkUpData) => { + stay using data + } + case Event(CheckLock, data: LinkUpData) => { + val haveLock = standbyLock.acquireOrHold(UUIDHelpers.protoUUIDToUuid(endpoint.getUuid), nodeId, standbyLockExpiryDurationMs) + if (haveLock) { + scheduleMsg(standbyLockRetryPeriodMs, CheckLock) + stay using data + } else { + logger.info(s"Endpoint ${endpoint.getName} lost lock while link down; going to standby") + data.binding.measBinding.cancel() + data.binding.heartbeatBinding.cancel() + data.availData.regServiceBinding.cancel() + + scheduleMsg(standbyLockRetryPeriodMs, CheckLock) + goto(Standby) using StandbyData(data.availData.processing, data.availData.config) + } + } + } + + when(LinkHalfDown) { + case Event(configUpdate: ConfigUpdate, data: LinkHalfDownData) => { + handleConfigUpdate(configUpdate, data.availData.config, data.availData.processing.processor) + stay using data + } + case Event(RegistrationRequest(msgBytes, respFunc), data: LinkHalfDownData) => { + + val nextSeq = data.binding.bindingSeq + 1 + handleRegistrationAttemptWhileDown(msgBytes, respFunc, nextSeq) match { + case None => + logger.debug(s"Registration failed on endpoint ${endpoint.getName}") + stay using data + case Some(result) => + logger.debug(s"Registration succeeded on endpoint ${endpoint.getName}") + + data.binding.measBinding.cancel() + data.binding.heartbeatBinding.cancel() + + val bind = ServiceBindingComponents(result.measServiceBinding, result.heartbeatServiceBinding, nextSeq) + + scheduleHeartbeatCheck() + goto(LinkUp) using LinkUpData(System.currentTimeMillis(), nodeId = result.nodeId, binding = bind, availData = data.availData) + } + } + case Event(HeartbeatRequest(msgBytes, respFunc, bindSeq), data: LinkHalfDownData) => { + if (bindSeq == data.binding.bindingSeq) { + logger.debug(s"Endpoint ${endpoint.getName} was half down and came back up") + val uuidSet = data.availData.config.pointSet.map(_._1).map(UUIDHelpers.protoUUIDToUuid).toSet + val stillRelevantSet = data.previousValues.filter(kv => uuidSet.contains(kv._1)) + + markEndpointOnlineAndRestoreMeasurements(stillRelevantSet, pointSetUuidConversion(data.availData.config.pointSet), data.availData.processing.processor) + + handleHeartbeat(msgBytes, respFunc) + + scheduleHeartbeatCheck() + goto(LinkUp) using LinkUpData(lastHeartbeat = System.currentTimeMillis(), nodeId = data.nodeId, binding = data.binding, availData = data.availData) + } else { + stay using data + } + } + case Event(MeasBatchRequest(msgBytes, respFunc, bindSeq), data: LinkHalfDownData) => { + if (bindSeq == data.binding.bindingSeq) { + + logger.debug(s"Endpoint ${endpoint.getName} was half down and came back up") + val uuidSet = data.availData.config.pointSet.map(_._1).map(UUIDHelpers.protoUUIDToUuid).toSet + val stillRelevantSet = data.previousValues.filter(kv => uuidSet.contains(kv._1)) + + markEndpointOnlineAndRestoreMeasurements(stillRelevantSet, pointSetUuidConversion(data.availData.config.pointSet), data.availData.processing.processor) + data.availData.processing.processor.handle(msgBytes, respFunc) + + scheduleHeartbeatCheck() + goto(LinkUp) using LinkUpData(lastHeartbeat = System.currentTimeMillis(), nodeId = data.nodeId, binding = data.binding, availData = data.availData) + } else { + stay using data + } + } + case Event(HeartbeatCheck, data: LinkHalfDownData) => { + stay using data + } + case Event(MarkRetry, data: LinkHalfDownData) => { + stay using data + } + case Event(CheckLock, data: LinkHalfDownData) => { + val haveLock = standbyLock.acquireOrHold(UUIDHelpers.protoUUIDToUuid(endpoint.getUuid), nodeId, standbyLockExpiryDurationMs) + if (haveLock) { + scheduleMsg(standbyLockRetryPeriodMs, CheckLock) + stay using data + } else { + logger.info(s"Endpoint ${endpoint.getName} lost lock while link half down; going to standby") + data.binding.measBinding.cancel() + data.binding.heartbeatBinding.cancel() + data.availData.regServiceBinding.cancel() + + scheduleMsg(standbyLockRetryPeriodMs, CheckLock) + goto(Standby) using StandbyData(data.availData.processing, data.availData.config) + } + } + } + + private def commonCleanup(data: AvailableData): Unit = { + data.regServiceBinding.cancel() + config.edgeSubscription.cancel() + config.pointSubscription.cancel() + config.overrideSubscription.cancel() + config.triggerSetSubscription.cancel() + } + + // TODO: dry this up further + onTermination { + case StopEvent(_, Available, data: AvailableData) => + logger.info(s"Processor for endpoint '${endpoint.getName}' terminating, canceling subscriptions") + commonCleanup(data) + case StopEvent(_, LinkUp, data: LinkUpData) => + logger.info(s"Processor for endpoint '${endpoint.getName}' terminating, canceling subscriptions") + data.binding.measBinding.cancel() + data.binding.heartbeatBinding.cancel() + commonCleanup(data.availData) + case StopEvent(_, LinkHalfDown, data: LinkHalfDownData) => + logger.info(s"Processor for endpoint '${endpoint.getName}' terminating, canceling subscriptions") + data.binding.measBinding.cancel() + data.binding.heartbeatBinding.cancel() + commonCleanup(data.availData) + case StopEvent(_, LinkHalfDownMarkFailed, data: LinkUpData) => + logger.info(s"Processor for endpoint '${endpoint.getName}' terminating, canceling subscriptions") + data.binding.measBinding.cancel() + data.binding.heartbeatBinding.cancel() + commonCleanup(data.availData) + } + + initialize() + + private def handleConfigUpdate(update: ConfigUpdate, current: ConfigComponents, processor: EndpointProcessor): ConfigComponents = { + logger.trace(s"Endpoint ${endpoint.getName} got config update: " + update) + update match { + case PossiblePointNotification(pointUuid) => { + ServiceConfiguration.configForPointAdded(resources.session, pointUuid).map { + case None => + self ! PointMiss(pointUuid) + case Some((pt, overOpt, triggerOpt)) => + val tup = (pt.getUuid, pt.getName) + self ! PointAdded(tup, overOpt, triggerOpt) + } + current.copy(outstandingPoints = current.outstandingPoints + pointUuid) + } + case PointMiss(pointUuid) => { + current.copy(outstandingPoints = current.outstandingPoints - pointUuid) + } + case PointAdded(point, over, trigger) => + processor.pointAdded(point, over, trigger) + current.copy(outstandingPoints = current.outstandingPoints - point._1, pointSet = current.pointSet :+ point) + case PointRemoved(pointUuid) => + processor.pointRemoved(pointUuid) + current.copy(outstandingPoints = current.outstandingPoints - pointUuid) + case PointModified(point) => + processor.pointModified(point) + current + case OverridePut(v) => + processor.overridePut(v) + current + case OverrideDeleted(v) => + processor.overrideDeleted(v) + current + case TriggerSetPut(uuid, v) => + processor.triggerSetPut(uuid, v) + current + case TriggerSetDeleted(uuid) => + processor.triggerSetDeleted(uuid) + current + } + } + + private def scheduleHeartbeatCheck(): Unit = { + scheduleMsg(heartbeatTimeout, HeartbeatCheck) + } + + private def pointSetUuidConversion(points: Seq[(ModelUUID, String)]): Seq[(UUID, String)] = { + points.map { case (uuid, name) => (UUIDHelpers.protoUUIDToUuid(uuid), name) } + } + + private def setupProcessor(config: EndpointConfiguration): (EndpointProcessor, ActorRef) = { + val publisher = context.actorOf(EventPublisher.props(resources.session)) + + def publishEvents(events: Seq[EventTemplate.Builder]) { if (events.nonEmpty) publisher ! EventTemplateGroup(events) } + + val processor = new EndpointProcessor( + endpoint.getName, + config.points, + config.overrides, + config.triggerSets, + resources.store, + measNotificationBatch(resources.serviceOps, endpoint, _), + publishEvents) + + import io.greenbus.client.proto.Envelope.SubscriptionEventType._ + + config.pointSubscription.start { note => + note.getEventType match { + case MODIFIED => self ! PointModified(note.getValue) + case _ => // let add/removes be picked up by edge sub + } + } + + config.overrideSubscription.start { note => + note.getEventType match { + case ADDED | MODIFIED => self ! OverridePut(note.getValue) + case REMOVED => self ! OverrideDeleted(note.getValue) + } + } + + config.triggerSetSubscription.start { note => + val triggerSetOpt = ServiceConfiguration.keyValueToTriggerSet(note.getValue) + triggerSetOpt.foreach { triggerSet => + note.getEventType match { + case ADDED | MODIFIED => self ! TriggerSetPut(note.getValue.getUuid, triggerSet) + case REMOVED => self ! TriggerSetDeleted(note.getValue.getUuid) + } + } + } + + config.edgeSubscription.start { note => + note.getEventType match { + case ADDED | MODIFIED => self ! PossiblePointNotification(note.getValue.getChild) + case REMOVED => self ! PointRemoved(note.getValue.getChild) + } + } + + (processor, publisher) + } + + private def setupRegistrationService(): SubscriptionBinding = { + + logger.debug(s"Subscribing to service queue for ${endpoint.getName}") + val binding = resources.serviceOps.bindRoutedService(new ActorForwardingServiceHandler(self, RegistrationRequest)) + + logger.debug(s"Binding service queue to exchange for ${endpoint.getName}") + resources.serviceOps.bindQueue(binding.getId(), FrontEndService.Descriptors.PutFrontEndRegistration.requestId, endpoint.getUuid.getValue) + + binding + } + + private def handleRegistrationAttemptWhileDown(msg: Array[Byte], responseHandler: (Array[Byte]) => Unit, sequence: Long): Option[RegistrationResult] = { + + val measService = new ActorForwardingServiceHandler(self, MeasBatchRequest(_, _, sequence)) + val heartbeatService = new ActorForwardingServiceHandler(self, HeartbeatRequest(_, _, sequence)) + val resultHolder = new RegistrationResultHolder + + val handler = RegistrationServiceHandler.wrapped( + endpoint, + sql, + auth, + resources.serviceOps, + frontEndModel, + measService, + heartbeatService, + resultHolder, + down = true, + currentNodeId = None) + + handler.handleMessage(msg, responseHandler) + resultHolder.getOption + } + + private def handleRegistrationAttemptWhileUp(msg: Array[Byte], responseHandler: (Array[Byte]) => Unit, sequence: Long, nodeId: Option[String]): Option[RegistrationResult] = { + + val measService = new ActorForwardingServiceHandler(self, MeasBatchRequest(_, _, sequence)) + val heartbeatService = new ActorForwardingServiceHandler(self, HeartbeatRequest(_, _, sequence)) + val resultHolder = new RegistrationResultHolder + + val handler = RegistrationServiceHandler.wrapped( + endpoint, + sql, + auth, + resources.serviceOps, + frontEndModel, + measService, + heartbeatService, + resultHolder, + down = false, + currentNodeId = nodeId) + + handler.handleMessage(msg, responseHandler) + resultHolder.getOption + } + + private def handleHeartbeat(msg: Array[Byte], responseHandler: (Array[Byte]) => Unit) = { + logger.trace(s"Endpoint ${endpoint.getName} handling heartbeat") + val handler = HeartbeatServiceHandler.wrapped(endpoint, sql, frontEndModel, statusNotifier) + + handler.handleMessage(msg, responseHandler) + } + + private def markEndpointOffline(points: Seq[(UUID, String)], processor: EndpointProcessor): Seq[(UUID, Measurement)] = { + logger.debug(s"Marking Endpoint ${endpoint.getName} offline") + sql.transaction { + + val entry = Seq((UUIDHelpers.protoUUIDToUuid(endpoint.getUuid), FrontEndConnectionStatus.Status.COMMS_DOWN)) + frontEndModel.putFrontEndConnectionStatuses(statusNotifier, entry) + + val uuids = points.map(_._1) + + val currentValues = resources.store.get(uuids) + val now = System.currentTimeMillis() + + val markedOld = currentValues.map { + case (uuid, meas) => + + val (qualBuilder, detailQualBuilder) = mergeableQualityElements(meas) + + val marked = meas.toBuilder + .setQuality( + qualBuilder.setValidity(Validity.INVALID) + .setDetailQual(detailQualBuilder.setOldData(true))) + .setTime(now) + .build() + + (uuid, marked) + } + + resources.store.put(markedOld) + processor.modifyLastValues(markedOld.map(tup => (tup._1.toString, tup._2))) + + notifyFromStore(markedOld, points) + + currentValues + } + } + + private def markEndpointOnline(): Unit = { + logger.debug(s"Marking Endpoint ${endpoint.getName} online") + sql.transaction { + val entry = Seq((UUIDHelpers.protoUUIDToUuid(endpoint.getUuid), FrontEndConnectionStatus.Status.COMMS_UP)) + frontEndModel.putFrontEndConnectionStatuses(statusNotifier, entry) + } + } + + private def markEndpointOnlineAndRestoreMeasurements(previousValues: Seq[(UUID, Measurement)], points: Seq[(UUID, String)], processor: EndpointProcessor): Unit = { + logger.debug(s"Marking Endpoint ${endpoint.getName} online and restoring measurement quality") + + val now = System.currentTimeMillis() + sql.transaction { + + val entry = Seq((UUIDHelpers.protoUUIDToUuid(endpoint.getUuid), FrontEndConnectionStatus.Status.COMMS_UP)) + frontEndModel.putFrontEndConnectionStatuses(statusNotifier, entry) + + val restoredMeases = previousValues.map { + case (uuid, meas) => + val updatedTimeMeas = meas.toBuilder.setTime(now).build() + (uuid, updatedTimeMeas) + } + + resources.store.put(restoredMeases) + processor.modifyLastValues(restoredMeases.map(tup => (tup._1.toString, tup._2))) + + notifyFromStore(restoredMeases, points) + } + } + + private def notifyFromStore(values: Seq[(UUID, Measurement)], pointList: Seq[(UUID, String)]): Unit = { + val uuidToName = pointList.map(tup => (tup._1, tup._2)).toMap + + val notifications = values.flatMap { case (uuid, m) => uuidToName.get(uuid).map { name => (UUIDHelpers.uuidToProtoUUID(uuid), name, m) } } + + measNotificationBatch(resources.serviceOps, endpoint, notifications) + } +} diff --git a/processing/src/main/scala/io/greenbus/measproc/EventPublisher.scala b/processing/src/main/scala/io/greenbus/measproc/EventPublisher.scala new file mode 100644 index 0000000..5ba6b26 --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/EventPublisher.scala @@ -0,0 +1,112 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc + +import io.greenbus.msg.Session +import akka.actor.{ Actor, Props } +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.client.exception.{ ServiceException, UnauthorizedException } +import java.util.concurrent.TimeoutException +import io.greenbus.client.service.EventService +import scala.concurrent.duration._ +import io.greenbus.client.service.proto.EventRequests.EventTemplate + +object EventPublisher { + + val pubRetryMs = 1000 + + case class EventTemplateGroup(events: Seq[EventTemplate.Builder]) + + def props(session: Session): Props = { + Props(classOf[EventPublisher], session) + } +} + +class EventPublisher(session: Session) extends Actor with Logging { + import EventPublisher._ + + private case object PostSuccess + private case class PostFailure(ex: Throwable) + private case object PublishRetry + + private var queued = List.empty[EventTemplate] + private var outstanding = List.empty[EventTemplate] + + def receive = { + case EventTemplateGroup(events) => + logger.trace(s"Received events to publish") + events.foreach(queued ::= _.build()) + if (outstanding.isEmpty) { + publish() + } + + case PostSuccess => + logger.trace(s"Published events") + outstanding = Nil + if (queued.nonEmpty) { + publish() + } + + case PostFailure(error) => { + error match { + case ex: UnauthorizedException => throw ex + case ex: ServiceException => + logger.warn(s"Publishing failure: " + ex.getMessage) + scheduleRetry() + case ex: TimeoutException => + logger.warn(s"Publishing timeout") + scheduleRetry() + case ex => throw ex + } + } + + case PublishRetry => + if (queued.nonEmpty || outstanding.nonEmpty) { + publish() + } + } + + private def scheduleRetry() { + logger.warn(s"Publishing failure, retrying...") + scheduleMsg(pubRetryMs, PublishRetry) + } + + private def publish() { + val client = EventService.client(session) + + outstanding :::= queued.reverse + queued = Nil + + logger.trace("Publishing the following events: " + outstanding) + + val postFut = client.postEvents(outstanding) + + import context.dispatcher + postFut.onSuccess { case result => self ! PostSuccess } + postFut.onFailure { case ex => self ! PostFailure(ex) } + } + + private def scheduleMsg(timeMs: Long, msg: AnyRef) { + import context.dispatcher + context.system.scheduler.scheduleOnce( + Duration(timeMs, MILLISECONDS), + self, + msg) + } +} \ No newline at end of file diff --git a/processing/src/main/scala/io/greenbus/measproc/HeartbeatServiceHandler.scala b/processing/src/main/scala/io/greenbus/measproc/HeartbeatServiceHandler.scala new file mode 100644 index 0000000..7c127ba --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/HeartbeatServiceHandler.scala @@ -0,0 +1,85 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc + +import io.greenbus.msg.service.ServiceHandler +import io.greenbus.client.exception.BadRequestException +import io.greenbus.client.proto.Envelope +import io.greenbus.client.service.FrontEndService +import io.greenbus.client.service.proto.FrontEndRequests.{ PutFrontEndConnectionStatusRequest, PutFrontEndConnectionStatusResponse } +import io.greenbus.client.service.proto.Model.Endpoint +import io.greenbus.services.framework._ +import io.greenbus.services.model.FrontEndModel +import io.greenbus.services.model.UUIDHelpers._ +import io.greenbus.sql.DbConnection + +import scala.collection.JavaConversions._ + +object HeartbeatServiceHandler { + + def wrapped( + endpoint: Endpoint, + sql: DbConnection, + frontEndModel: FrontEndModel, + notifier: ModelNotifier): ServiceHandler = { + + val handler = new HeartbeatServiceHandler(endpoint, sql, frontEndModel, notifier) + + new EnvelopeParsingHandler( + new DecodingServiceHandler(FrontEndService.Descriptors.PutFrontEndConnectionStatuses, + new ModelErrorTransformingHandler(handler))) + } + +} + +class HeartbeatServiceHandler( + endpoint: Endpoint, + sql: DbConnection, + frontEndModel: FrontEndModel, + notifier: ModelNotifier) extends TypedServiceHandler[PutFrontEndConnectionStatusRequest, PutFrontEndConnectionStatusResponse] { + + def handle(request: PutFrontEndConnectionStatusRequest, headers: Map[String, String], responseHandler: (Response[PutFrontEndConnectionStatusResponse]) => Unit) { + + val results = sql.transaction { + + if (request.getUpdatesCount == 0) { + throw new BadRequestException("Must include request content") + } + + val updates = request.getUpdatesList.toSeq + + val modelUpdates = updates.map { update => + if (!update.hasEndpointUuid) { + throw new BadRequestException("Must include endpoint uuid") + } + if (!update.hasStatus) { + throw new BadRequestException("Must include communication status") + } + + (protoUUIDToUuid(update.getEndpointUuid), update.getStatus) + } + + frontEndModel.putFrontEndConnectionStatuses(notifier, modelUpdates, None) + + } + + val response = PutFrontEndConnectionStatusResponse.newBuilder().addAllResults(results).build + responseHandler(Success(Envelope.Status.OK, response)) + } +} diff --git a/processing/src/main/scala/io/greenbus/measproc/MeasurementProcessor.scala b/processing/src/main/scala/io/greenbus/measproc/MeasurementProcessor.scala new file mode 100644 index 0000000..c0d895a --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/MeasurementProcessor.scala @@ -0,0 +1,114 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc + +import java.util.UUID + +import akka.actor._ +import com.typesafe.config.ConfigFactory +import io.greenbus.app.actor.{ AllEndpointsStrategy, ConnectedApplicationManager, EndpointCollectionManager, EndpointMonitor } +import io.greenbus.client.service.proto.FrontEnd.FrontEndConnectionStatus +import io.greenbus.client.service.proto.Model.Endpoint +import io.greenbus.client.service.{ FrontEndService, MeasurementService } +import io.greenbus.measproc.EndpointStreamManager.StreamResources +import io.greenbus.measproc.lock.TransactionPostgresLockModel +import io.greenbus.msg.Session +import io.greenbus.msg.amqp.AmqpServiceOperations +import io.greenbus.mstore.sql.SqlCurrentValueAndHistorian +import io.greenbus.services.authz.DefaultAuthLookup +import io.greenbus.services.core.FrontEndConnectionStatusSubscriptionDescriptor +import io.greenbus.services.framework.{ ModelEventMapper, SimpleModelNotifier, SubscriptionChannelManager } +import io.greenbus.services.model.SquerylFrontEndModel +import io.greenbus.sql.{ DbConnection, DbConnector, SqlSettings } + +object MeasurementProcessor { + + private def buildModelNotifier(serviceOps: AmqpServiceOperations) = { + + val commsStatus = new SubscriptionChannelManager(FrontEndConnectionStatusSubscriptionDescriptor, serviceOps, classOf[FrontEndConnectionStatus].getSimpleName) + + val eventMapper = new ModelEventMapper + eventMapper.register(classOf[FrontEndConnectionStatus], commsStatus) + + new SimpleModelNotifier(eventMapper, serviceOps, (_, _) => {}) + } + + def buildProcessor( + amqpConfigPath: String, + sqlConfigPath: String, + userConfigPath: String, + heartbeatTimeoutMs: Long, + nodeId: String, + standbyLockRetryPeriodMs: Long = 5000, + standbyLockExpiryDurationMs: Long = 12500): Props = { + + val collectionStrategy = new AllEndpointsStrategy + val markRetryTimeoutMs = 2000 + + def connectSql(): DbConnection = { + val config = SqlSettings.load(sqlConfigPath) + DbConnector.connect(config) + } + + def processFactory(session: Session, serviceOps: AmqpServiceOperations, sql: DbConnection): Props = { + + serviceOps.declareExchange(FrontEndService.Descriptors.PutFrontEndRegistration.requestId) + serviceOps.declareExchange(MeasurementService.Descriptors.PostMeasurements.requestId) + serviceOps.declareExchange(FrontEndService.Descriptors.PutFrontEndConnectionStatuses.requestId) + + val resources = StreamResources(session, serviceOps, new SqlCurrentValueAndHistorian(sql)) + + val notifier = buildModelNotifier(serviceOps) + + val lockService = new TransactionPostgresLockModel(sql) + + def bootstrapperFactory(endpoint: Endpoint, config: EndpointConfiguration): Props = { + EndpointStreamManager.props(endpoint, resources, config, sql, DefaultAuthLookup, SquerylFrontEndModel, notifier, lockService, + nodeId, heartbeatTimeoutMs, markRetryTimeoutMs, standbyLockRetryPeriodMs, standbyLockExpiryDurationMs) + } + + def endMgrFactory(endpoint: Endpoint, session: Session, serviceOps: AmqpServiceOperations): Props = { + EndpointStreamBootstrapper.props(endpoint, session, bootstrapperFactory) + } + + EndpointCollectionManager.props(collectionStrategy, session, serviceOps, None, + EndpointMonitor.props(_, _, _, _, endMgrFactory)) + } + + ConnectedApplicationManager.props("Measurement Processor", amqpConfigPath, userConfigPath, processFactory(_, _, connectSql())) + } + + def main(args: Array[String]) { + + val heartbeatTimeoutMs = 10000 + + val rootConfig = ConfigFactory.load() + val slf4jConfig = ConfigFactory.parseString("""akka { loggers = ["akka.event.slf4j.Slf4jLogger"] }""") + val akkaConfig = slf4jConfig.withFallback(rootConfig) + val system = ActorSystem("measproc", akkaConfig) + + val baseDir = Option(System.getProperty("io.greenbus.config.base")).getOrElse("") + val amqpConfigPath = Option(System.getProperty("io.greenbus.config.amqp")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.msg.amqp.cfg") + val sqlConfigPath = Option(System.getProperty("io.greenbus.config.sql")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.sql.cfg") + val userConfigPath = Option(System.getProperty("io.greenbus.config.user")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.user.cfg") + + val mgr = system.actorOf(buildProcessor(amqpConfigPath, sqlConfigPath, userConfigPath, heartbeatTimeoutMs, UUID.randomUUID().toString)) + } +} + diff --git a/processing/src/main/scala/io/greenbus/measproc/ProcessorService.scala b/processing/src/main/scala/io/greenbus/measproc/ProcessorService.scala new file mode 100644 index 0000000..f9528a5 --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/ProcessorService.scala @@ -0,0 +1,114 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc + +import io.greenbus.client.proto.Envelope.{ ServiceResponse, ServiceRequest } +import io.greenbus.client.exception.BadRequestException +import io.greenbus.client.service.proto.MeasurementRequests.PostMeasurementsRequest +import io.greenbus.client.proto.Envelope +import com.google.protobuf.InvalidProtocolBufferException +import com.typesafe.scalalogging.slf4j.Logging +import scala.collection.JavaConversions._ +import io.greenbus.client.service.proto.Measurements.Measurement +import io.greenbus.jmx.Metrics + +class ProcessorService(endpoint: String, metrics: Metrics) extends Logging { + + private val handleTime = metrics.average("BatchHandleTime") + private val batchSize = metrics.average("BatchSize") + private val measHandledCount = metrics.counter("MeasHandledCount") + private val badRequestCount = metrics.average("BadRequestCount") + private val serviceErrorCount = metrics.average("ServiceErrorCount") + + def handle( + bytes: Array[Byte], + responseHandler: Array[Byte] => Unit, + process: (Seq[(String, Measurement)], Option[Long]) => Unit, + pointMap: PointMap) { + + val startTime = System.currentTimeMillis() + + try { + val requestEnvelope = ServiceRequest.parseFrom(bytes) + + if (!requestEnvelope.hasPayload) { + throw new BadRequestException("Must include request payload") + } + val payload = requestEnvelope.getPayload.toByteArray + + val request = PostMeasurementsRequest.parseFrom(payload) + if (!request.hasBatch) { + throw new BadRequestException("Must include measurement batch") + } + + val batch = request.getBatch + if (batch.getNamedMeasurementsCount == 0 && batch.getPointMeasurementsCount == 0) { + logger.debug(s"Empty measurement batch for endpoint $endpoint") + } else { + + if (batch.getPointMeasurementsList.exists(!_.hasPointUuid)) { + throw new BadRequestException("Must include point uuids for all id-based measurements") + } + if (batch.getNamedMeasurementsList.exists(!_.hasPointName)) { + throw new BadRequestException("Must include point name for all name-based measurements") + } + + val wallTime = if (batch.hasWallTime) Some(batch.getWallTime) else None + val idList = batch.getPointMeasurementsList.map(pm => (pm.getPointUuid.getValue, pm.getValue)).toSeq + val nameList = batch.getNamedMeasurementsList.flatMap(nm => pointMap.nameToId.get(nm.getPointName).map(uuid => (uuid.getValue, nm.getValue))).toSeq + + val allMeas = idList ++ nameList + val count = allMeas.size + batchSize(count) + measHandledCount(count) + + process(allMeas, wallTime) + } + + responseHandler(ServiceResponse.newBuilder().setStatus(Envelope.Status.OK).build().toByteArray) + + } catch { + case ex: BadRequestException => + badRequestCount(1) + logger.warn("Bad client request: " + ex.getMessage) + val response = buildErrorEnvelope(Envelope.Status.BAD_REQUEST, ex.getMessage) + responseHandler(response.toByteArray) + case protoEx: InvalidProtocolBufferException => + badRequestCount(1) + logger.warn("Error parsing client request: " + protoEx.getMessage) + val response = buildErrorEnvelope(Envelope.Status.BAD_REQUEST, "Couldn't parse service request.") + responseHandler(response.toByteArray) + case ex: Throwable => + serviceErrorCount(1) + logger.error("Internal service error: " + ex) + val response = buildErrorEnvelope(Envelope.Status.INTERNAL_ERROR, "Internal service error.") + responseHandler(response.toByteArray) + } + + handleTime((System.currentTimeMillis() - startTime).toInt) + + } + + def buildErrorEnvelope(status: Envelope.Status, message: String): ServiceResponse = { + ServiceResponse.newBuilder() + .setStatus(status) + .setErrorMessage(message) + .build() + } +} diff --git a/processing/src/main/scala/io/greenbus/measproc/RegistrationServiceHandler.scala b/processing/src/main/scala/io/greenbus/measproc/RegistrationServiceHandler.scala new file mode 100644 index 0000000..b6614be --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/RegistrationServiceHandler.scala @@ -0,0 +1,174 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc + +import java.util.UUID + +import io.greenbus.msg.SubscriptionBinding +import io.greenbus.msg.amqp.AmqpServiceOperations +import io.greenbus.msg.service.ServiceHandler +import io.greenbus.client.exception.{ BadRequestException, InternalServiceException } +import io.greenbus.client.proto.Envelope +import io.greenbus.client.proto.Envelope.ServiceResponse +import io.greenbus.client.service.{ FrontEndService, MeasurementService } +import io.greenbus.client.service.proto.CommandRequests.PostCommandRequestRequest +import io.greenbus.client.service.proto.FrontEndRequests.{ PutFrontEndRegistrationRequest, PutFrontEndRegistrationResponse, FrontEndRegistrationTemplate } +import io.greenbus.client.service.proto.Model.Endpoint +import io.greenbus.services.authz.{ EntityFilter, AuthLookup } +import io.greenbus.services.core.FrontEndServices +import io.greenbus.services.framework._ +import io.greenbus.services.model.FrontEndModel +import io.greenbus.services.model.FrontEndModel.FrontEndConnectionTemplate +import io.greenbus.services.model.UUIDHelpers._ +import io.greenbus.sql.DbConnection + +case class RegistrationResult(address: String, measServiceBinding: SubscriptionBinding, heartbeatServiceBinding: SubscriptionBinding, nodeId: Option[String]) + +class RegistrationResultHolder { + private var resultOpt = Option.empty[RegistrationResult] + def set(result: RegistrationResult): Unit = { resultOpt = Some(result) } + def getOption: Option[RegistrationResult] = resultOpt +} + +object RegistrationServiceHandler { + + def wrapped(endpoint: Endpoint, + sql: DbConnection, + auth: AuthLookup, + ops: AmqpServiceOperations, + frontEndModel: FrontEndModel, + measServiceHandler: ServiceHandler, + heartbeatServiceHandler: ServiceHandler, + resultHolder: RegistrationResultHolder, + down: Boolean, + currentNodeId: Option[String]): ServiceHandler = { + + val handler = new RegistrationServiceHandler( + endpoint, + sql, + auth, + ops, + frontEndModel, + measServiceHandler, + heartbeatServiceHandler, + resultHolder, + down, + currentNodeId) + + new EnvelopeParsingHandler( + new DecodingServiceHandler(FrontEndService.Descriptors.PutFrontEndRegistration, + new ModelErrorTransformingHandler(handler))) + } + + val unavailableResponse = { + ServiceResponse.newBuilder() + .setStatus(Envelope.Status.LOCKED) + .setErrorMessage("FEP already registered") + .build() + } + + val unavailableResponseBytes = { + unavailableResponse.toByteArray + } + +} + +class RegistrationServiceHandler( + endpoint: Endpoint, + sql: DbConnection, + auth: AuthLookup, + ops: AmqpServiceOperations, + frontEndModel: FrontEndModel, + measServiceHandler: ServiceHandler, + heartbeatServiceHandler: ServiceHandler, + resultHolder: RegistrationResultHolder, + down: Boolean, + currentNodeId: Option[String]) + extends TypedServiceHandler[PutFrontEndRegistrationRequest, PutFrontEndRegistrationResponse] { + + def handle(request: PutFrontEndRegistrationRequest, headers: Map[String, String], responseHandler: (Response[PutFrontEndRegistrationResponse]) => Unit): Unit = { + + sql.transaction { + val authContext = auth.validateAuth(headers) + val filter = authContext.authorize(FrontEndServices.frontEndRegistrationResource, "update") + + if (!request.hasRequest) { + throw new BadRequestException("Must include at least one front end connection request") + } + + val template = request.getRequest + + if (!template.hasEndpointUuid) { + throw new BadRequestException("Must include endpoint uuid") + } + + if (template.getEndpointUuid != endpoint.getUuid) { + throw new BadRequestException("Incorrect endpoint uuid for service address") + } + + val nodeIdOpt = if (template.hasFepNodeId) Some(template.getFepNodeId) else None + + val holdingLockOpt = if (template.hasHoldingLock) Some(template.getHoldingLock) else None + + if (!down) { + if (nodeIdOpt == currentNodeId || holdingLockOpt == Some(true)) { + makeRegistration(template, filter, nodeIdOpt, responseHandler) + } else { + responseHandler(Failure(Envelope.Status.LOCKED, "FEP already registered")) + } + } else { + makeRegistration(template, filter, nodeIdOpt, responseHandler) + } + } + } + + private def makeRegistration(template: FrontEndRegistrationTemplate, filter: Option[EntityFilter], requestNodeId: Option[String], responseHandler: (Response[PutFrontEndRegistrationResponse]) => Unit): Unit = { + + val cmdAddr = if (template.hasCommandAddress) Some(template.getCommandAddress) else None + + val streamAddress = UUID.randomUUID().toString + + val modelTemplate = FrontEndConnectionTemplate(endpoint.getUuid, streamAddress, cmdAddr) + + val result = frontEndModel.putFrontEndConnections(NullModelNotifier, Seq(modelTemplate), filter).headOption.getOrElse { + throw new InternalServiceException("Frontend registration could not be put in database") + } + + cmdAddr.foreach { addr => + ops.bindQueue(addr, classOf[PostCommandRequestRequest].getSimpleName, addr) + } + + val measStreamBinding = ops.bindRoutedService(measServiceHandler) + val heartbeatBinding = ops.bindRoutedService(heartbeatServiceHandler) + try { + ops.bindQueue(measStreamBinding.getId(), MeasurementService.Descriptors.PostMeasurements.requestId, streamAddress) + ops.bindQueue(heartbeatBinding.getId(), FrontEndService.Descriptors.PutFrontEndConnectionStatuses.requestId, streamAddress) + } catch { + case ex: Throwable => + measStreamBinding.cancel() + heartbeatBinding.cancel() + throw ex + } + + resultHolder.set(RegistrationResult(streamAddress, measStreamBinding, heartbeatBinding, requestNodeId)) + + val response = PutFrontEndRegistrationResponse.newBuilder().setResults(result).build + responseHandler(Success(Envelope.Status.OK, response)) + } +} diff --git a/processing/src/main/scala/io/greenbus/measproc/ServiceConfiguration.scala b/processing/src/main/scala/io/greenbus/measproc/ServiceConfiguration.scala new file mode 100644 index 0000000..be10c9b --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/ServiceConfiguration.scala @@ -0,0 +1,188 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.{ Session, Subscription } +import io.greenbus.client.service.proto.Model._ +import io.greenbus.client.service.proto.ModelRequests._ +import io.greenbus.client.service.proto.Processing.{ MeasOverride, OverrideNotification, TriggerSet } +import io.greenbus.client.service.proto.ProcessingRequests.OverrideSubscriptionQuery +import io.greenbus.client.service.{ ModelService, ProcessingService } + +import scala.collection.JavaConversions._ +import scala.concurrent.{ ExecutionContext, Future } + +case class EndpointConfiguration(points: Seq[(ModelUUID, String)], + pointSubscription: Subscription[PointNotification], + overrides: Seq[MeasOverride], + overrideSubscription: Subscription[OverrideNotification], + triggerSets: Seq[(ModelUUID, TriggerSet)], + triggerSetSubscription: Subscription[EntityKeyValueNotification], + edgeSubscription: Subscription[EntityEdgeNotification]) + +object ServiceConfiguration extends Logging { + + val triggerSetKey = "triggerSet" + + def endpointEdgesSubscription(session: Session, endpointId: ModelUUID)(implicit context: ExecutionContext): Future[Subscription[EntityEdgeNotification]] = { + val modelClient = ModelService.client(session) + + val edgeQuery = EntityEdgeSubscriptionQuery.newBuilder() + .addFilters(EntityEdgeFilter.newBuilder() + .setParentUuid(endpointId) + .setRelationship("source") + .setDistance(1) + .build()) + .build() + + val edgeSubFut = modelClient.subscribeToEdges(edgeQuery) + + edgeSubFut.map(_._2) + } + + def keyValueToTriggerSet(kvp: EntityKeyValue): Option[TriggerSet] = { + if (kvp.hasValue && kvp.getValue.hasByteArrayValue) { + try { + Some(TriggerSet.parseFrom(kvp.getValue.getByteArrayValue)) + } catch { + case ex: Throwable => + logger.warn(s"Trigger set for uuid ${kvp.getUuid} couldn't parse") + None + } + } else { + logger.warn(s"Trigger set for uuid ${kvp.getUuid} had no byte value") + None + } + } + def keyValueToUuidAndTriggerSet(kvp: EntityKeyValue): Option[(ModelUUID, TriggerSet)] = { + keyValueToTriggerSet(kvp).map(c => (kvp.getUuid, c)) + } + + def configForPointAdded(session: Session, uuid: ModelUUID)(implicit context: ExecutionContext): Future[Option[(Point, Option[MeasOverride], Option[TriggerSet])]] = { + val modelClient = ModelService.client(session) + val procClient = ProcessingService.client(session) + + val keys = EntityKeySet.newBuilder().addUuids(uuid).build() + + val keyPair = EntityKeyPair.newBuilder().setUuid(uuid).setKey(triggerSetKey).build() + + modelClient.getPoints(keys).flatMap { points => + points.headOption match { + case None => Future.successful(None) + case Some(pt) => + (procClient.getOverrides(keys) zip modelClient.getEntityKeyValues(Seq(keyPair))).map { + case (over, trigger) => + Some((pt, over.headOption, trigger.headOption.flatMap(keyValueToTriggerSet))) + } + } + } + + } + + def subsForEndpoint(session: Session, endpointId: ModelUUID)(implicit executor: ExecutionContext): Future[(Subscription[PointNotification], Subscription[OverrideNotification], Subscription[EntityKeyValueNotification])] = { + val modelClient = ModelService.client(session) + val procClient = ProcessingService.client(session) + + val pointSubQuery = PointSubscriptionQuery.newBuilder().addEndpointUuids(endpointId).build() + val overSubQuery = OverrideSubscriptionQuery.newBuilder().addEndpointUuids(endpointId).build() + val triggerSubQuery = EntityKeyValueSubscriptionQuery.newBuilder().addEndpointUuids(endpointId).build() + + val pointSubFut = modelClient.subscribeToPoints(pointSubQuery) + + val overSubFut = procClient.subscribeToOverrides(overSubQuery) + + val triggerSubFut = modelClient.subscribeToEntityKeyValues(triggerSubQuery) + + def cancelSub[A, B](result: (A, Subscription[B])) { result._2.cancel() } + + pointSubFut.onFailure { case _ => triggerSubFut.foreach(cancelSub); overSubFut.foreach(cancelSub) } + overSubFut.onFailure { case _ => triggerSubFut.foreach(cancelSub); pointSubFut.foreach(cancelSub) } + triggerSubFut.onFailure { case _ => overSubFut.foreach(cancelSub); pointSubFut.foreach(cancelSub) } + + (pointSubFut zip (overSubFut zip triggerSubFut)).map { + case ((_, pointSub), ((_, overSub), (_, triggerSub))) => (pointSub, overSub, triggerSub) + } + } + + def currentSetsForEndpoint(session: Session, endpointId: ModelUUID)(implicit executor: ExecutionContext): Future[(Seq[Entity], Seq[MeasOverride], Seq[(ModelUUID, TriggerSet)])] = { + + val modelClient = ModelService.client(session) + val procClient = ProcessingService.client(session) + + val pageSize = Int.MaxValue // TODO: Breaks on large endpoints? + + val pointQueryTemplate = EntityRelationshipFlatQuery.newBuilder() + .addStartUuids(endpointId) + .setRelationship("source") + .setDescendantOf(true) + .addEndTypes("Point") + .setPagingParams( + EntityPagingParams.newBuilder() + .setPageSize(pageSize)) + .build() + + val entFuture = modelClient.relationshipFlatQuery(pointQueryTemplate) + + val overFut = entFuture.flatMap { pointEntities => + if (pointEntities.isEmpty) { + Future.successful(Nil) + } else { + val pointSet = EntityKeySet.newBuilder().addAllUuids(pointEntities.map(_.getUuid)).build() + procClient.getOverrides(pointSet) + } + } + + val triggerFut: Future[Seq[(ModelUUID, TriggerSet)]] = entFuture.flatMap { pointEntities => + if (pointEntities.isEmpty) { + Future.successful(Nil) + } else { + val keyValues = pointEntities.map(e => EntityKeyPair.newBuilder().setUuid(e.getUuid).setKey(triggerSetKey).build()) + + modelClient.getEntityKeyValues(keyValues).map(_.flatMap(keyValueToUuidAndTriggerSet)) + } + } + + (entFuture zip (overFut zip triggerFut)).map { + case (ents, (overs, triggers)) => (ents, overs, triggers) + } + } + + def configForEndpoint(session: Session, endpointId: ModelUUID)(implicit executor: ExecutionContext): Future[EndpointConfiguration] = { + val configSubFut = subsForEndpoint(session, endpointId) + + val edgeSubFut = endpointEdgesSubscription(session, endpointId) + + configSubFut.onFailure { case _ => edgeSubFut.foreach(_.cancel()) } + edgeSubFut.onFailure { case _ => configSubFut.foreach { case (pointSub, overSub, triggerSub) => pointSub.cancel(); overSub.cancel(); triggerSub.cancel() } } + + (configSubFut zip edgeSubFut).flatMap { + case ((pointSub, overSub, triggerSub), edgeSub) => + + val currentFut = currentSetsForEndpoint(session, endpointId) + + currentFut.onFailure { case _ => overSub.cancel(); triggerSub.cancel(); edgeSub.cancel() } + + currentFut.map { + case (ents, overs, triggers) => + EndpointConfiguration(ents.map(p => (p.getUuid, p.getName)), pointSub, overs, overSub, triggers, triggerSub, edgeSub) + } + } + } +} diff --git a/processing/src/main/scala/io/greenbus/measproc/json/JsonEndpointConfiguration.scala b/processing/src/main/scala/io/greenbus/measproc/json/JsonEndpointConfiguration.scala new file mode 100644 index 0000000..6c652cc --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/json/JsonEndpointConfiguration.scala @@ -0,0 +1,35 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.json + +object JsonEndpointConfiguration { + import play.api.libs.json._ + implicit val writer = Json.writes[JsonEndpointConfiguration] + implicit val reader = Json.reads[JsonEndpointConfiguration] + + def load(input: Array[Byte]): Option[JsonEndpointConfiguration] = { + + val jsObj = Json.parse(input) + + val jsonConfigOpt = Json.fromJson(jsObj)(JsonEndpointConfiguration.reader).asOpt + + jsonConfigOpt + } +} +case class JsonEndpointConfiguration(endpointWhitelist: Option[Seq[String]]) \ No newline at end of file diff --git a/processing/src/main/scala/io/greenbus/measproc/lock/ProcessingLockService.scala b/processing/src/main/scala/io/greenbus/measproc/lock/ProcessingLockService.scala new file mode 100644 index 0000000..b7c47c7 --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/lock/ProcessingLockService.scala @@ -0,0 +1,75 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.lock + +import java.sql.SQLDataException +import java.util.UUID + +import org.squeryl.PrimitiveTypeMode._ +import io.greenbus.services.data.{ ProcessingLockSchema, ProcessingLock } +import ProcessingLockSchema._ +import org.squeryl.Session +import io.greenbus.sql.DbConnection + +trait ProcessingLockService { + def acquireOrHold(uuid: UUID, nodeId: String, expiryDurationMs: Long): Boolean +} + +object PostgresLockModel extends ProcessingLockService { + + private def selectNow(): Long = { + val s = Session.currentSession + + val st = s.connection.prepareStatement("select now()") + val rs = st.executeQuery() + + if (!rs.next()) { + throw new SQLDataException("select now() failed") + } + rs.getTimestamp(1).getTime + } + + def acquireOrHold(uuid: UUID, nodeId: String, expiryDurationMs: Long): Boolean = { + + val current = processingLocks.where(r => r.id === uuid).forUpdate.toVector + + val dbNow = selectNow() + + current.headOption match { + case None => + processingLocks.insert(ProcessingLock(uuid, nodeId, dbNow)) + true + case Some(row) => + if (row.nodeId == nodeId || dbNow - row.lastCheckIn > expiryDurationMs) { + processingLocks.update(ProcessingLock(uuid, nodeId, dbNow)) + true + } else { + false + } + } + } +} + +class TransactionPostgresLockModel(sql: DbConnection) extends ProcessingLockService { + def acquireOrHold(uuid: UUID, nodeId: String, expiryDurationMs: Long): Boolean = { + sql.transaction { + PostgresLockModel.acquireOrHold(uuid, nodeId, expiryDurationMs) + } + } +} diff --git a/processing/src/main/scala/io/greenbus/measproc/pipeline/ProcessingPipeline.scala b/processing/src/main/scala/io/greenbus/measproc/pipeline/ProcessingPipeline.scala new file mode 100644 index 0000000..cbe0f85 --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/pipeline/ProcessingPipeline.scala @@ -0,0 +1,181 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.pipeline + +import io.greenbus.measproc.processing._ +import io.greenbus.client.service.proto.Measurements.Measurement +import io.greenbus.jmx.MetricsManager +import scala.annotation.tailrec +import io.greenbus.client.service.proto.Processing.{ TriggerSet, MeasOverride } +import io.greenbus.client.service.proto.EventRequests.EventTemplate +import io.greenbus.client.service.proto.Model.ModelUUID + +class ProcessingPipeline( + endpointName: String, + pointKeys: Seq[String], + postMeasurements: Seq[(String, Measurement)] => Unit, + getMeasurement: String => Option[Measurement], + eventBatch: Seq[EventTemplate.Builder] => Unit, + triggerStateCache: ObjectCache[Boolean], + overrideValueCache: ObjectCache[(Option[Measurement], Option[Measurement])]) { + + private val metricsMgr = MetricsManager("io.greenbus.measproc", endpointName) + + private val lastValueCache = new MapCache[Measurement] + + private var eventBuffer = List.empty[EventTemplate.Builder] + + private val whitelistProcessor = new MeasurementWhiteList(pointKeys, metricsMgr.metrics("Whitelist")) + + private val overrideProcessor = new OverrideProcessor(handleRestoreOnOverrideRemove, handleEngFromOverrides, handleTestReplaceValue, overrideValueCache, getCurrentValue, metricsMgr.metrics("Overrides")) + + private val triggerFactory = new TriggerProcessingFactory(eventSink, lastValueCache) + private val triggerProcessor = new TriggerProcessor(triggerFactory, triggerStateCache, metricsMgr.metrics("Triggers")) + + private val processingSteps = List(whitelistProcessor, overrideProcessor, triggerProcessor) + + @tailrec + private def processSteps(key: String, m: Measurement, steps: List[ProcessingStep]): Option[Measurement] = { + steps match { + case Nil => Some(m) + case head :: tail => + head.process(key, m) match { + case None => None + case Some(result) => processSteps(key, result, tail) + } + } + } + + def modifyLastValues(measurements: Seq[(String, Measurement)]): Unit = { + measurements.foreach { case (key, m) => lastValueCache.put(key, m) } + } + + def process(measurements: Seq[(String, Measurement)], batchTime: Option[Long]) { + + val results = measurements.flatMap { + case (uuid, measurement) => + processSteps(uuid.toString, measurement, processingSteps).map { result => + (uuid, result) + } + } + + lazy val now = System.currentTimeMillis() + val timeResolved = results.map { + case (key, m) => + if (!m.hasTime) { + val time = batchTime getOrElse now + val withTime = Measurement.newBuilder().mergeFrom(m).setTime(time).build() + (key, withTime) + } else { + (key, m) + } + } + + postMeasurements(timeResolved) + val events = popEvents() + eventBatch(events) + } + + def updatePointsList(points: Seq[String]) { + whitelistProcessor.updatePointList(points.map(_.toString)) + lastValueCache.reset() + } + + def addOverride(over: MeasOverride) { + overrideProcessor.add(over) + } + def removeOverride(over: MeasOverride) { + overrideProcessor.remove(over) + } + def removeOverride(point: ModelUUID) { + overrideProcessor.remove(point) + } + + def addTriggerSet(uuid: ModelUUID, set: TriggerSet) { + triggerProcessor.add(uuid, set) + } + def removeTriggerSet(point: ModelUUID) { + triggerProcessor.remove(point) + } + + private def popEvents(): Seq[EventTemplate.Builder] = { + val events = eventBuffer.reverse + eventBuffer = List.empty[EventTemplate.Builder] + events + } + + private def eventSink(event: EventTemplate.Builder) { + eventBuffer ::= event + } + + private def getCurrentValue(pointKey: String): Option[Measurement] = { + getMeasurement(pointKey) + } + + private def handleTestReplaceValue(pointKey: String, replaceValue: Measurement, previousValueOpt: Option[Measurement], alreadyReplaced: Boolean): Unit = { + + val processedOpt = triggerProcessor.process(pointKey, replaceValue) + + processedOpt match { + case None => { + if (!alreadyReplaced) { + previousValueOpt match { + case Some(prev) => + postMeasurements(Seq((pointKey, OverrideProcessor.transformNIS(prev)))) + case None => + postMeasurements(Seq((pointKey, OverrideProcessor.blankNIS()))) + } + } + } + case Some(processed) => + postMeasurements(Seq((pointKey, OverrideProcessor.transformSubstituted(processed)))) + } + + val events = popEvents() + if (events.nonEmpty) { + eventBatch(events) + } + } + + private def handleRestoreOnOverrideRemove(pointKey: String, previouslyPublishedOpt: Option[Measurement], latestUpdateOpt: Option[Measurement]) { + + val latestProcessedOpt = latestUpdateOpt.flatMap(m => triggerProcessor.process(pointKey, m)) + + latestProcessedOpt match { + case None => { + // no update or update was suppressed, restore previously published + previouslyPublishedOpt.foreach(m => postMeasurements(Seq((pointKey, m)))) + } + case Some(latestProcessed) => { + postMeasurements(Seq((pointKey, latestProcessed))) + } + } + + // fire events even if suppressed + val events = popEvents() + if (events.nonEmpty) { + eventBatch(events) + } + } + + private def handleEngFromOverrides(pointKey: String, measurement: Measurement) { + postMeasurements(Seq((pointKey, measurement))) + } + +} diff --git a/processing/src/main/scala/io/greenbus/measproc/processing/Action.scala b/processing/src/main/scala/io/greenbus/measproc/processing/Action.scala new file mode 100755 index 0000000..b6d4c69 --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/processing/Action.scala @@ -0,0 +1,95 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.processing + +import io.greenbus.client.service.proto.Measurements._ + +object Action { + type Evaluation = (Measurement) => Measurement + + sealed abstract class ActivationType { + def apply(state: Boolean, prev: Boolean) = activated(state, prev) + def activated(state: Boolean, prev: Boolean): Boolean + } + object High extends ActivationType { + def activated(state: Boolean, prev: Boolean) = state + } + object Low extends ActivationType { + def activated(state: Boolean, prev: Boolean) = !state + } + object Rising extends ActivationType { + def activated(state: Boolean, prev: Boolean) = (state && !prev) + } + object Falling extends ActivationType { + def activated(state: Boolean, prev: Boolean) = (!state && prev) + } + object Transition extends ActivationType { + def activated(state: Boolean, prev: Boolean) = (state && !prev) || (!state && prev) + } +} + +/** + * Container for processor actions, maintains the logic necessary to determine if + * an action should be executed. + * + */ +trait Action { + /** + * Name of the action + */ + val name: String + + /** + * Conditional action evaluation. Actions define what combination of previous state and current state + * cause activation. + * + * @param m Measurement to be acted upon + * @param state Current state of the trigger condition this action is associated with + * @param prev Previous state of the trigger condition this action is associated with + * @return Result of processing + */ + def process(m: Measurement, state: Boolean, prev: Boolean): Option[Measurement] +} + +/** + * Implementation of action processing container. + */ +class BasicAction(val name: String, disabled: Boolean, activation: Action.ActivationType, eval: Action.Evaluation) + extends Action { + + def process(m: Measurement, state: Boolean, prev: Boolean): Option[Measurement] = { + if (!disabled && activation(state, prev)) + Some(eval(m)) + else + Some(m) + } + override def toString = s"[BasicAction,disabled=$disabled,activation=$activation,eval=$eval]" +} + +class SuppressAction(val name: String, disabled: Boolean, activation: Action.ActivationType) extends Action { + + def process(m: Measurement, state: Boolean, prev: Boolean): Option[Measurement] = { + if (!disabled && activation(state, prev)) + None + else + Some(m) + } + override def toString = s"[SuppressAction,disabled=$disabled,activation=$activation]" +} + diff --git a/processing/src/main/scala/io/greenbus/measproc/processing/ActionFactory.scala b/processing/src/main/scala/io/greenbus/measproc/processing/ActionFactory.scala new file mode 100755 index 0000000..0786903 --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/processing/ActionFactory.scala @@ -0,0 +1,206 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.processing + +import io.greenbus.client.service.proto.EventRequests.EventTemplate +import io.greenbus.client.service.proto.Events._ +import io.greenbus.client.service.proto.Measurements._ +import io.greenbus.client.service.proto.Model.ModelUUID +import io.greenbus.client.service.proto.Processing.{ Action => ActionProto, ActivationType => TypeProto, LinearTransform => LinearProto } + +import scala.collection.JavaConversions._ + +/** + * Trigger/Action factory with constructor dependencies. + */ +class TriggerProcessingFactory(protected val publishEvent: EventTemplate.Builder => Unit, protected val lastCache: ObjectCache[Measurement]) + extends ProcessingResources + with TriggerFactory + with ActionFactory + +/** + * Internal interface for Trigger/Action factory dependencies + */ +trait ProcessingResources { + protected val publishEvent: (EventTemplate.Builder) => Unit + protected val lastCache: ObjectCache[Measurement] +} + +/** + * Factory for Action implementation objects + */ +trait ActionFactory { self: ProcessingResources => + import io.greenbus.measproc.processing.Actions._ + import io.greenbus.measproc.processing.TriggerFactory._ + + /** + * Creates Action objects given protobuf representation + * @param proto Action configuration proto + * @return Constructed action + */ + def buildAction(proto: ActionProto, pointUuid: ModelUUID): Action = { + val name = proto.getActionName + val typ = convertActivation(proto.getType) + val disabled = proto.getDisabled + + if (proto.hasSuppress && proto.getSuppress) { + new SuppressAction(name, disabled, typ) + } else { + val eval = if (proto.hasLinearTransform) { + new LinearTransform(proto.getLinearTransform.getScale, proto.getLinearTransform.getOffset, proto.getLinearTransform.getForceToDouble) + } else if (proto.hasQualityAnnotation) { + new AnnotateQuality(proto.getQualityAnnotation) + } else if (proto.hasStripValue && proto.getStripValue) { + new StripValue + } else if (proto.hasSetBool) { + new SetBool(proto.getSetBool) + } else if (proto.hasEvent) { + new EventGenerator(publishEvent, proto.getEvent.getEventType, pointUuid) + } else if (proto.hasBoolTransform) { + new BoolEnumTransformer(proto.getBoolTransform.getFalseString, proto.getBoolTransform.getTrueString) + } else if (proto.hasIntTransform) { + import scala.collection.JavaConversions._ + val otherwiseValOpt = if (proto.getIntTransform.hasDefaultValue) Some(proto.getIntTransform.getDefaultValue) else None + val intMapping = proto.getIntTransform.getMappingsList.toList.map { vm => vm.getValue -> vm.getString }.toMap + new IntegerEnumTransformer(intMapping, otherwiseValOpt) + } else { + throw new Exception("Must specify at least one action") + } + + new BasicAction(name, disabled, typ, eval) + } + } +} + +object Actions { + + class AnnotateQuality(qual: Quality) extends Action.Evaluation { + def apply(m: Measurement): Measurement = { + Measurement.newBuilder(m).setQuality(Quality.newBuilder(m.getQuality).mergeFrom(qual)).build + } + } + class StripValue extends Action.Evaluation { + def apply(m: Measurement): Measurement = { + Measurement.newBuilder(m) + .clearDoubleVal + .clearIntVal + .clearStringVal + .clearBoolVal + .setType(Measurement.Type.NONE) + .build + } + } + class SetBool(b: Boolean) extends Action.Evaluation { + def apply(m: Measurement): Measurement = { + Measurement.newBuilder(m) + .clearDoubleVal + .clearIntVal + .clearStringVal + .setBoolVal(b) + .setType(Measurement.Type.BOOL) + .build + } + } + class LinearTransform(scale: Double, offset: Double, forceToDouble: Boolean) extends Action.Evaluation { + def apply(m: Measurement): Measurement = { + m.getType match { + case Measurement.Type.DOUBLE => + if (m.hasDoubleVal) Measurement.newBuilder(m).setDoubleVal(m.getDoubleVal * scale + offset).build else m + case Measurement.Type.INT => + if (m.hasIntVal) { + val scaledValue = m.getIntVal * scale + offset + if (!forceToDouble) Measurement.newBuilder(m).setIntVal(scaledValue.toLong).build + else Measurement.newBuilder(m).setDoubleVal(scaledValue).setType(Measurement.Type.DOUBLE).build + } else m + case _ => m + } + } + } + + private def attribute[A](name: String, obj: A): Attribute = { + val b = Attribute.newBuilder().setName(name) + obj match { + case v: Int => b.setValueSint64(v) + case v: Long => b.setValueSint64(v) + case v: Double => b.setValueDouble(v) + case v: Boolean => b.setValueBool(v) + case v: String => b.setValueString(v) + } + b.build() + } + + class EventGenerator(out: EventTemplate.Builder => Unit, eventType: String, pointUuid: ModelUUID) + extends Action.Evaluation { + + def apply(m: Measurement): Measurement = { + + val validity = attribute("validity", m.getQuality.getValidity.toString) + + val valueAttr = m.getType match { + case Measurement.Type.INT => Some(attribute("value", m.getIntVal)) + case Measurement.Type.DOUBLE => Some(attribute("value", m.getDoubleVal)) + case Measurement.Type.BOOL => Some(attribute("value", m.getBoolVal)) + case Measurement.Type.STRING => Some(attribute("value", m.getStringVal)) + case Measurement.Type.NONE => None + } + + val attrs = Seq(validity) ++ Seq(valueAttr).flatten + + val builder = EventTemplate.newBuilder + .setEventType(eventType) + .setEntityUuid(pointUuid) + .addAllArgs(attrs) + if (m.getIsDeviceTime) builder.setDeviceTime(m.getTime) + out(builder) + m + } + } + + class BoolEnumTransformer(falseString: String, trueString: String) extends Action.Evaluation { + def apply(m: Measurement): Measurement = { + if (!m.hasBoolVal) { + // TODO: handle non boolean measurements in enum transform + m + } else { + m.toBuilder.setType(Measurement.Type.STRING) + .setStringVal(if (m.getBoolVal) trueString else falseString).build + } + } + } + + // integer as in "not floating point", not integer as in 2^32, values on measurements are actually Longs + class IntegerEnumTransformer(mapping: Map[Long, String], otherWiseOpt: Option[String]) extends Action.Evaluation { + def apply(m: Measurement): Measurement = { + + def buildStr(str: String) = m.toBuilder.setStringVal(str).setType(Measurement.Type.STRING).build + + if (!m.hasIntVal) { + // TODO: handle non int measurements in int transform + m + } else { + mapping.get(m.getIntVal) match { + case Some(s) => buildStr(s) + case None => otherWiseOpt.map(buildStr).getOrElse(m) + } + + } + } + } + +} \ No newline at end of file diff --git a/processing/src/main/scala/io/greenbus/measproc/processing/FilterTrigger.scala b/processing/src/main/scala/io/greenbus/measproc/processing/FilterTrigger.scala new file mode 100644 index 0000000..416351b --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/processing/FilterTrigger.scala @@ -0,0 +1,110 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.processing + +import io.greenbus.client.service.proto.Measurements.{ DetailQual, Quality, Measurement } +import io.greenbus.client.service.proto.Processing.{ Filter => FilterProto } +import FilterProto.{ FilterType => Type } +import io.greenbus.util.Optional._ + +object FilterTrigger { + + def apply(config: FilterProto, cache: ObjectCache[Measurement]) = { + val cond = config.getType match { + case Type.DUPLICATES_ONLY => new NoDuplicates + case Type.DEADBAND => new Deadband(optGet(config.hasDeadbandValue, config.getDeadbandValue).getOrElse(0)) + } + new FilterTrigger(cache, cond) + } + + sealed trait Filter { + def allow(m: Measurement, current: Measurement): Boolean + } + + class NoDuplicates extends Filter { + def allow(m: Measurement, current: Measurement) = { + optGet(m.hasIntVal, m.getIntVal) != optGet(current.hasIntVal, current.getIntVal) || + optGet(m.hasDoubleVal, m.getDoubleVal) != optGet(current.hasDoubleVal, current.getDoubleVal) || + optGet(m.hasBoolVal, m.getBoolVal) != optGet(current.hasBoolVal, current.getBoolVal) || + optGet(m.hasStringVal, m.getStringVal) != optGet(current.hasStringVal, current.getStringVal) + } + } + + class Deadband(band: Double) extends Filter { + def allow(m: Measurement, current: Measurement) = { + if (m.hasDoubleVal && current.hasDoubleVal) { + math.abs(m.getDoubleVal - current.getDoubleVal) > band + } else if (m.hasIntVal && current.hasIntVal) { + math.abs(m.getIntVal - current.getIntVal).asInstanceOf[Double] > band + } else { + true + } + } + } + + def sameDetailQuality(lhs: Option[DetailQual], rhs: Option[DetailQual]): Boolean = { + (lhs, rhs) match { + case (None, None) => true + case (Some(l), Some(r)) => sameDetailQuality(l, r) + case _ => false + } + } + + def sameDetailQuality(lhs: DetailQual, rhs: DetailQual): Boolean = { + optGet(lhs.hasOverflow, lhs.getOverflow) == optGet(rhs.hasOverflow, rhs.getOverflow) && + optGet(lhs.hasOutOfRange, lhs.getOutOfRange) == optGet(rhs.hasOutOfRange, rhs.getOutOfRange) && + optGet(lhs.hasBadReference, lhs.getBadReference) == optGet(rhs.hasBadReference, rhs.getBadReference) && + optGet(lhs.hasOscillatory, lhs.getOscillatory) == optGet(rhs.hasOscillatory, rhs.getOscillatory) && + optGet(lhs.hasFailure, lhs.getFailure) == optGet(rhs.hasFailure, rhs.getFailure) && + optGet(lhs.hasOldData, lhs.getOldData) == optGet(rhs.hasOldData, rhs.getOldData) && + optGet(lhs.hasInconsistent, lhs.getInconsistent) == optGet(rhs.hasInconsistent, rhs.getInconsistent) && + optGet(lhs.hasInaccurate, lhs.getInaccurate) == optGet(rhs.hasInaccurate, rhs.getInaccurate) + } + + def sameQuality(lhs: Quality, rhs: Quality): Boolean = { + optGet(lhs.hasValidity, lhs.getValidity) == optGet(rhs.hasValidity, rhs.getValidity) && + optGet(lhs.hasSource, lhs.getSource) == optGet(rhs.hasSource, rhs.getSource) && + optGet(lhs.hasTest, lhs.getTest) == optGet(rhs.hasTest, rhs.getTest) && + optGet(lhs.hasOperatorBlocked, lhs.getOperatorBlocked) == optGet(rhs.hasOperatorBlocked, rhs.getOperatorBlocked) && + sameDetailQuality(optGet(lhs.hasDetailQual, lhs.getDetailQual), optGet(rhs.hasDetailQual, rhs.getDetailQual)) + } +} + +class FilterTrigger(cache: ObjectCache[Measurement], band: FilterTrigger.Filter) extends Trigger.KeyedCondition { + import FilterTrigger.sameQuality + + def apply(pointKey: String, m: Measurement, prev: Boolean): Boolean = { + cache.get(pointKey) match { + case None => { + cache.put(pointKey, m) + true + } + case Some(current) => { + if (!sameQuality(m.getQuality, current.getQuality) || + band.allow(m, current)) { + + cache.put(pointKey, m) + true + } else { + false + } + } + } + } +} \ No newline at end of file diff --git a/processing/src/main/scala/io/greenbus/measproc/processing/MeasurementWhiteList.scala b/processing/src/main/scala/io/greenbus/measproc/processing/MeasurementWhiteList.scala new file mode 100755 index 0000000..6f8082e --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/processing/MeasurementWhiteList.scala @@ -0,0 +1,54 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.processing + +import io.greenbus.client.service.proto.Measurements.Measurement +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.jmx.Metrics + +/** + * checks to see if the measurements are on the whitelist provided with the endpoint and filters + * out the unexpected measurements and adds a log message indicating what is being ignored. + */ +class MeasurementWhiteList(expectedKeys: Seq[String], metrics: Metrics) + extends ProcessingStep with Logging { + + private var whiteList: Set[String] = expectedKeys.toSet + private var ignored = Set.empty[String] + + private val ignoredMeasurements = metrics.counter("ignoredMeasurements") + + def process(pointKey: String, measurement: Measurement): Option[Measurement] = { + if (whiteList.contains(pointKey)) { + Some(measurement) + } else { + ignoredMeasurements(1) + if (!ignored.contains(pointKey)) { + ignored += pointKey + logger.info("Ignoring unexpected measurement: " + pointKey) + } + None + } + } + + def updatePointList(expectedPoints: Seq[String]) { + whiteList = expectedPoints.toSet + ignored = Set.empty[String] + } +} \ No newline at end of file diff --git a/processing/src/main/scala/io/greenbus/measproc/processing/ObjectCache.scala b/processing/src/main/scala/io/greenbus/measproc/processing/ObjectCache.scala new file mode 100644 index 0000000..940aad8 --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/processing/ObjectCache.scala @@ -0,0 +1,51 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.processing + +trait ObjectCache[A] { + + def put(values: Seq[(String, A)]): Unit = values.foreach { case (k, v) => put(k, v) } + + def put(key: String, value: A): Unit + + def get(name: String): Option[A] + + def delete(name: String) +} + +class MapCache[A] extends ObjectCache[A] { + + private var map = Map.empty[String, A] + + def put(key: String, value: A) { + map += (key -> value) + } + + def get(name: String): Option[A] = { + map.get(name) + } + + def delete(name: String) { + map -= name + } + + def reset() { + map = Map.empty[String, A] + } +} \ No newline at end of file diff --git a/processing/src/main/scala/io/greenbus/measproc/processing/OverrideProcessor.scala b/processing/src/main/scala/io/greenbus/measproc/processing/OverrideProcessor.scala new file mode 100755 index 0000000..fd8dbce --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/processing/OverrideProcessor.scala @@ -0,0 +1,178 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.processing + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.client.service.proto.Processing.MeasOverride +import io.greenbus.client.service.proto.Measurements.{ DetailQual, Quality, Measurement } +import io.greenbus.jmx.Metrics +import io.greenbus.client.service.proto.Model.ModelUUID + +object OverrideProcessor { + def transformSubstituted(meas: Measurement): Measurement = { + val q = Quality.newBuilder(meas.getQuality).setSource(Quality.Source.SUBSTITUTED).setOperatorBlocked(true) + val now = System.currentTimeMillis + Measurement.newBuilder(meas).setQuality(q).setTime(now).setSystemTime(now).build + } + + def transformNIS(meas: Measurement): Measurement = { + val dq = DetailQual.newBuilder(meas.getQuality.getDetailQual).setOldData(true) + val q = Quality.newBuilder(meas.getQuality).setDetailQual(dq).setOperatorBlocked(true) + val now = System.currentTimeMillis + Measurement.newBuilder(meas).setQuality(q).setTime(now).setSystemTime(now).build + } + + def blankNIS(): Measurement = { + val dq = DetailQual.newBuilder().setOldData(true) + val q = Quality.newBuilder().setDetailQual(dq).setOperatorBlocked(true) + val now = System.currentTimeMillis + Measurement.newBuilder().setType(Measurement.Type.NONE).setQuality(q).setTime(now).setSystemTime(now).build + } +} + +// TODO: OLD should not be set until a new field measurement comes in (61850-7-3). +class OverrideProcessor( + handleRemove: (String, Option[Measurement], Option[Measurement]) => Unit, + publishEng: (String, Measurement) => Unit, + handleTest: (String, Measurement, Option[Measurement], Boolean) => Unit, + maskedValueCache: ObjectCache[(Option[Measurement], Option[Measurement])], + current: String => Option[Measurement], + metrics: Metrics) + extends ProcessingStep with Logging { + + import OverrideProcessor._ + + private var blockedPointMap = scala.collection.immutable.Map[String, Option[Measurement]]() + + private val measSupressed = metrics.counter("measSupressed") + private val overrideCurrentValueMiss = metrics.counter("overrideCurrentValueMiss") + private val overridenCacheMiss = metrics.counter("overridenCacheMiss") + private val overridesActive = metrics.gauge("overridesActive") + + // cache is (published, latest subsequently arriving unpublished) + // if the later arrival is suppressed by triggers, the previously published value is restored + + def process(pointKey: String, measurement: Measurement): Option[Measurement] = { + blockedPointMap.get(pointKey) match { + case None => Some(measurement) + case Some(_) => { + measSupressed(1) + maskedValueCache.get(pointKey) match { + case None => maskedValueCache.put(pointKey, (None, Some(measurement))) + case Some((publishedOpt, _)) => maskedValueCache.put(pointKey, (publishedOpt, Some(measurement))) + } + None + } + } + } + + def add(over: MeasOverride) { + val pointUuid = over.getPointUuid + val pointKey = pointUuid.getValue + val currentlyNIS = blockedPointMap.contains(pointKey) + val replaceMeas = if (over.hasMeasurement) Some(over.getMeasurement) else None + val isTest = over.hasTestValue && over.getTestValue + + logger.debug("Adding measurement override on: " + pointKey) + + (currentlyNIS, replaceMeas) match { + + // new NIS request, no replace specified + case (false, None) => { + val updateValue = cacheCurrent(pointKey).map(transformNIS).getOrElse(blankNIS()) + blockedPointMap += (pointKey -> None) + publishEng(pointKey, updateValue) + } + + // new NIS request, replace specified + case (false, Some(repl)) => { + val currentOpt = cacheCurrent(pointKey) + blockedPointMap += (pointKey -> replaceMeas) + if (isTest) { + handleTest(pointKey, repl, currentOpt, false) + } else { + publishEng(pointKey, transformSubstituted(repl)) + } + } + + // point already NIS, no replace specified + case (true, None) => logger.debug("NIS to point already NIS, ignoring") + + // point already NIS, replace specified, treat as simple replace + case (true, Some(repl)) => { + blockedPointMap += (pointKey -> replaceMeas) + if (isTest) { + handleTest(pointKey, repl, None, true) + } else { + publishEng(pointKey, transformSubstituted(repl)) + } + } + } + + updateMetrics() + } + + def remove(uuid: ModelUUID) { + val pointKey = uuid.getValue + + logger.debug("Removing measurement override on: " + pointKey) + + blockedPointMap -= pointKey + updateMetrics() + maskedValueCache.get(pointKey) match { + case None => + overridenCacheMiss(1) + case Some((publishedOpt, latestOpt)) => { + val now = System.currentTimeMillis + + val publishUpdatedOpt = publishedOpt.map(cached => Measurement.newBuilder(cached).setTime(now).build()) + val latestUpdatedOpt = latestOpt.map(cached => Measurement.newBuilder(cached).setTime(now).build()) + + handleRemove(pointKey, publishUpdatedOpt, latestUpdatedOpt) + + maskedValueCache.delete(pointKey) + } + } + } + + def remove(over: MeasOverride) { + remove(over.getPointUuid) + } + + def clear() { + blockedPointMap = scala.collection.immutable.Map[String, Option[Measurement]]() + updateMetrics() + } + + private def updateMetrics() = { + overridesActive(blockedPointMap.size) + } + + private def cacheCurrent(pointKey: String): Option[Measurement] = { + current(pointKey) match { + case Some(curr) => + maskedValueCache.put(pointKey, (Some(curr), None)) + Some(curr) + case None => + overrideCurrentValueMiss(1) + None + } + } + +} \ No newline at end of file diff --git a/processing/src/main/scala/io/greenbus/measproc/processing/ProcessingStep.scala b/processing/src/main/scala/io/greenbus/measproc/processing/ProcessingStep.scala new file mode 100644 index 0000000..ed553f4 --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/processing/ProcessingStep.scala @@ -0,0 +1,25 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.processing + +import io.greenbus.client.service.proto.Measurements.Measurement + +trait ProcessingStep { + def process(pointKey: String, measurement: Measurement): Option[Measurement] +} diff --git a/processing/src/main/scala/io/greenbus/measproc/processing/Trigger.scala b/processing/src/main/scala/io/greenbus/measproc/processing/Trigger.scala new file mode 100755 index 0000000..83a03c5 --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/processing/Trigger.scala @@ -0,0 +1,141 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.processing + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.client.service.proto.Measurements._ +import io.greenbus.measproc.processing.Trigger.{ KeyedCondition, SimpleCondition } +import io.greenbus.util.Optional._ + +object Trigger extends Logging { + + /** + * Super-type for condition logic. + */ + //type Condition = (String, Measurement, Boolean) => Boolean + + sealed trait Condition + trait SimpleCondition extends Condition { + def apply(m: Measurement, b: Boolean): Boolean + } + trait KeyedCondition extends Condition { + def apply(key: String, m: Measurement, b: Boolean): Boolean + } + + /** + * Helper function to extract analog values from measurements + * @param m Measurement + * @return Value in form of Double, or None + */ + def analogValue(m: Measurement): Option[Double] = { + optGet(m.hasDoubleVal, m.getDoubleVal) orElse + optGet(m.hasIntVal, m.getIntVal.asInstanceOf[Double]) + } + + /** + * Evaluates a list of triggers against a measurement + * @param m Measurement input + * @param cache Previous trigger state cache + * @param triggers Triggers associated with the measurement point. + * @return Result of trigger/action processing. + */ + def processAll(pointKey: String, m: Measurement, cache: ObjectCache[Boolean], triggers: List[Trigger]): Option[Measurement] = { + val r = triggers.foldLeft(m) { (meas, trigger) => + // Evaluate trigger + logger.trace("Applying trigger: " + trigger + " to meas: " + meas) + + trigger.process(pointKey, meas, cache) match { + case None => return None + case Some((result, true)) => return Some(result) + case Some((result, false)) => result + } + } + Some(r) + } + +} + +/** + * Interface for conditional measurement processing logic + */ +trait Trigger { + /** + * Conditionally process input measurement. May raise flag to stop further processing. + * + * @param pointKey Point of measurement + * @param m Input measurement. + * @param cache Previous trigger state cache. + * @return Measurement result, flag to stop further processing. + */ + def process(pointKey: String, m: Measurement, cache: ObjectCache[Boolean]): Option[(Measurement, Boolean)] +} + +/** + * Implementation of conditional measurement processing logic. Maintains a condition function + * and a list of actions to perform. Additionally may stop processing when condition is in a + * given state. + */ +class BasicTrigger( + cacheId: String, + conditions: List[Trigger.Condition], + actions: List[Action], + stopProcessing: Option[Action.ActivationType]) + extends Trigger with Logging { + + def process(pointKey: String, m: Measurement, cache: ObjectCache[Boolean]): Option[(Measurement, Boolean)] = { + + // Get the previous state of this trigger + logger.trace("CacheId: " + cacheId + ", Prev cache: " + cache.get(cacheId)) + val prev = cache.get(cacheId) getOrElse false + + // Evaluate the current state + //info("Conditions: " + conditions) + val state = if (conditions.isEmpty) { + true + } else { + conditions.forall { + case cond: SimpleCondition => cond(m, prev) + case cond: KeyedCondition => cond(pointKey, m, prev) + } + } + //else conditions.forall(_(m, prev)) + + // Store the state in the previous state cache + cache.put(cacheId, state) + + // Allow actions to determine if they should evaluate, roll up a result measurement + //info("Trigger state: " + state) + + def evalActions(meas: Measurement, a: List[Action]): Option[Measurement] = a match { + case Nil => Some(meas) + case head :: tail => { + head.process(meas, state, prev).flatMap(next => evalActions(next, tail)) + } + } + + evalActions(m, actions).map { result => + // Check stop processing flag (default to continue processing) + val stopProc = stopProcessing.map(_(state, prev)) getOrElse false + (result, stopProc) + } + } + + override def toString = cacheId + " (" + actions.mkString(", ") + ")" +} + diff --git a/processing/src/main/scala/io/greenbus/measproc/processing/TriggerFactory.scala b/processing/src/main/scala/io/greenbus/measproc/processing/TriggerFactory.scala new file mode 100644 index 0000000..d738add --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/processing/TriggerFactory.scala @@ -0,0 +1,200 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.processing + +import java.util.UUID + +import io.greenbus.client.service.proto.Measurements._ +import io.greenbus.client.service.proto.Model.ModelUUID +import io.greenbus.client.service.proto.Processing.{ Action => ActionProto, ActivationType => TypeProto, AnalogLimit => AnalogLimitProto, Trigger => TriggerProto } +import io.greenbus.util.Optional._ + +import scala.collection.JavaConversions._ + +object TriggerFactory { + import io.greenbus.measproc.processing.Triggers._ + + /** + * Converts proto activation type to scala activation type + * @param proto Proto activation type + * @return Scala activation type + */ + def convertActivation(proto: TypeProto) = proto match { + case TypeProto.HIGH => Action.High + case TypeProto.LOW => Action.Low + case TypeProto.RISING => Action.Rising + case TypeProto.FALLING => Action.Falling + case TypeProto.TRANSITION => Action.Transition + } + + /** + * Constructs either an upper,lower or, most commonly, range condition + * @param proto Proto configuration + * @return Trigger condition + */ + def limitCondition(proto: AnalogLimitProto): Trigger.Condition = { + // TODO: do we need non-deadbanded limit checks, does deadband == 0 work for all floats + if (proto.hasLowerLimit && proto.hasUpperLimit) { + if (proto.hasDeadband) + new RangeLimitDeadband(proto.getUpperLimit, proto.getLowerLimit, proto.getDeadband) + else + new RangeLimit(proto.getUpperLimit, proto.getLowerLimit) + } else if (proto.hasUpperLimit()) { + if (proto.hasDeadband) + new UpperLimitDeadband(proto.getUpperLimit, proto.getDeadband) + else + new UpperLimit(proto.getUpperLimit) + } else if (proto.hasLowerLimit()) { + if (proto.hasDeadband) + new LowerLimitDeadband(proto.getLowerLimit, proto.getDeadband) + else + new LowerLimit(proto.getLowerLimit) + } else throw new IllegalArgumentException("Upper and Lower limit not set in AnalogLimit") + } +} + +/** + * Factory for trigger implementation objects. + */ +trait TriggerFactory { self: ActionFactory with ProcessingResources => + import io.greenbus.measproc.processing.TriggerFactory._ + import io.greenbus.measproc.processing.Triggers._ + + /** + * Creates trigger objects given protobuf configuration. + * @param proto Configuration object + * @param pointKey Associated measurement point + * @return Implementation object + */ + def buildTrigger(proto: TriggerProto, pointKey: String, pointUuid: ModelUUID): Trigger = { + val cacheId = UUID.randomUUID().toString //TODO: this won't work for persistent caches + val stopProc = proto.hasStopProcessingWhen thenGet convertActivation(proto.getStopProcessingWhen) + val conditions = List( + optGet(proto.hasAnalogLimit, proto.getAnalogLimit).map(limitCondition), + optGet(proto.hasQuality, proto.getQuality).map(new QualityCondition(_)), + optGet(proto.hasValueType, proto.getValueType).map(new TypeCondition(_)), + optGet(proto.hasBoolValue, proto.getBoolValue).map(new BoolValue(_)), + optGet(proto.hasIntValue, proto.getIntValue).map(new IntegerValue(_)), + optGet(proto.hasStringValue, proto.getStringValue).map(new StringValue(_)), + optGet(proto.hasFilter, proto.getFilter).map(FilterTrigger(_, lastCache))).flatten + + val actions = proto.getActionsList.toList.map(buildAction(_, pointUuid)) + new BasicTrigger(cacheId, conditions, actions, stopProc) + } +} + +/** + * Implementations of corresponding proto Trigger types (see Triggers.proto) + */ +object Triggers { + class BoolValue(b: Boolean) extends Trigger.SimpleCondition { + def apply(m: Measurement, prev: Boolean): Boolean = { + (m.getType == Measurement.Type.BOOL) && + (m.getBoolVal == b) + } + } + + // integer as in "not floating point", not integer as in 2^32, values on measurements are actually Longs + class IntegerValue(i: Long) extends Trigger.SimpleCondition { + def apply(m: Measurement, prev: Boolean): Boolean = { + (m.getType == Measurement.Type.INT) && + (m.getIntVal == i) + } + } + + class StringValue(s: String) extends Trigger.SimpleCondition { + def apply(m: Measurement, prev: Boolean): Boolean = { + (m.getType == Measurement.Type.STRING) && + (m.getStringVal == s) + } + } + + class RangeLimit(upper: Double, lower: Double) extends Trigger.SimpleCondition { + def apply(m: Measurement, prev: Boolean): Boolean = { + val x = Trigger.analogValue(m) getOrElse { return false } + x <= lower || x >= upper + } + } + + class RangeLimitDeadband(upper: Double, lower: Double, deadband: Double) extends Trigger.SimpleCondition { + def apply(m: Measurement, prev: Boolean): Boolean = { + val x = Trigger.analogValue(m) getOrElse { return false } + (prev && (x <= (lower + deadband) || x >= (upper - deadband))) || (!prev && (x <= lower || x >= upper)) + } + } + + class UpperLimitDeadband(limit: Double, deadband: Double) extends Trigger.SimpleCondition { + def apply(m: Measurement, prev: Boolean): Boolean = { + val x = Trigger.analogValue(m) getOrElse { return false } + (prev && x >= (limit - deadband)) || (!prev && x >= limit) + } + } + class UpperLimit(limit: Double) extends Trigger.SimpleCondition { + def apply(m: Measurement, prev: Boolean): Boolean = { + val x = Trigger.analogValue(m) getOrElse { return false } + x >= limit + } + } + + class LowerLimitDeadband(limit: Double, deadband: Double) extends Trigger.SimpleCondition { + def apply(m: Measurement, prev: Boolean): Boolean = { + val x = Trigger.analogValue(m) getOrElse { return false } + (prev && x <= (limit + deadband)) || (!prev && x <= limit) + } + } + class LowerLimit(limit: Double) extends Trigger.SimpleCondition { + def apply(m: Measurement, prev: Boolean): Boolean = { + val x = Trigger.analogValue(m) getOrElse { return false } + x <= limit + } + } + + def detailQualityMatches(lhs: DetailQual, rhs: DetailQual): Boolean = { + lhs.hasOverflow && rhs.hasOverflow && (lhs.getOverflow == rhs.getOverflow) || + lhs.hasOutOfRange && rhs.hasOutOfRange && (lhs.getOutOfRange == rhs.getOutOfRange) || + lhs.hasBadReference && rhs.hasBadReference && (lhs.getBadReference == rhs.getBadReference) || + lhs.hasOscillatory && rhs.hasOscillatory && (lhs.getOscillatory == rhs.getOscillatory) || + lhs.hasFailure && rhs.hasFailure && (lhs.getFailure == rhs.getFailure) || + lhs.hasOldData && rhs.hasOldData && (lhs.getOldData == rhs.getOldData) || + lhs.hasInconsistent && rhs.hasInconsistent && (lhs.getInconsistent == rhs.getInconsistent) || + lhs.hasInaccurate && rhs.hasInaccurate && (lhs.getInaccurate == rhs.getInaccurate) + } + + def qualityMatches(qual: Quality, q: Quality): Boolean = { + qual.hasValidity && q.hasValidity && (qual.getValidity == q.getValidity) || + qual.hasSource && q.hasSource && (qual.getSource == q.getSource) || + qual.hasTest && q.hasTest && (qual.getTest == q.getTest) || + qual.hasOperatorBlocked && q.hasOperatorBlocked && (qual.getOperatorBlocked == q.getOperatorBlocked) || + qual.hasDetailQual && q.hasDetailQual && detailQualityMatches(qual.getDetailQual, q.getDetailQual) + } + + class QualityCondition(qual: Quality) extends Trigger.SimpleCondition { + + def apply(m: Measurement, prev: Boolean): Boolean = { + qualityMatches(qual, m.getQuality) + } + } + + class TypeCondition(typ: Measurement.Type) extends Trigger.SimpleCondition { + def apply(m: Measurement, prev: Boolean): Boolean = { + m.getType == typ + } + } +} + diff --git a/processing/src/main/scala/io/greenbus/measproc/processing/TriggerProcessor.scala b/processing/src/main/scala/io/greenbus/measproc/processing/TriggerProcessor.scala new file mode 100644 index 0000000..7592af2 --- /dev/null +++ b/processing/src/main/scala/io/greenbus/measproc/processing/TriggerProcessor.scala @@ -0,0 +1,78 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.processing + +import collection.JavaConversions._ +import collection.immutable + +import com.typesafe.scalalogging.slf4j.Logging + +import io.greenbus.client.service.proto.Measurements.Measurement +import io.greenbus.client.service.proto.Processing.TriggerSet +import io.greenbus.jmx.Metrics +import io.greenbus.client.service.proto.Model.ModelUUID + +class TriggerProcessor( + protected val factory: TriggerFactory, + protected val stateCache: ObjectCache[Boolean], + metrics: Metrics) + extends ProcessingStep + with Logging { + + protected var map = immutable.Map[String, List[Trigger]]() + + private val triggersActive = metrics.gauge("triggersActive") + + def process(pointKey: String, measurement: Measurement): Option[Measurement] = { + val triggerList = map.get(pointKey) + + triggerList match { + case Some(triggers) => + logger.trace("Applying triggers: " + triggers.size + " to meas: " + measurement) + val res = Trigger.processAll(pointKey, measurement, stateCache, triggers) + logger.trace("Trigger result: " + res) + res + case None => + Some(measurement) + } + } + + def add(uuid: ModelUUID, set: TriggerSet) { + logger.trace("TriggerSet received: " + set) + val pointKey = uuid.getValue + val trigList = set.getTriggersList.toList.map(proto => factory.buildTrigger(proto, pointKey, uuid)) + map += (pointKey -> trigList) + updateMetrics() + } + + def remove(uuid: ModelUUID) { + logger.trace("TriggerSet removed: " + uuid.getValue) + map -= uuid.getValue + updateMetrics() + } + + def clear() { + map = immutable.Map[String, List[Trigger]]() + updateMetrics() + } + + private def updateMetrics() { + triggersActive(map.size) + } +} diff --git a/processing/src/test/scala/io/greenbus/measproc/pipeline/ProcessingPipelineTest.scala b/processing/src/test/scala/io/greenbus/measproc/pipeline/ProcessingPipelineTest.scala new file mode 100755 index 0000000..b2d8845 --- /dev/null +++ b/processing/src/test/scala/io/greenbus/measproc/pipeline/ProcessingPipelineTest.scala @@ -0,0 +1,339 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.pipeline + +import org.scalatest.FunSuite +import org.scalatest.matchers.ShouldMatchers +import org.scalatest.junit.JUnitRunner +import org.junit.runner.RunWith + +import scala.collection.mutable + +import io.greenbus.client.service.proto.Measurements.{ Quality, Measurement } +import io.greenbus.measproc.processing.MockObjectCache +import io.greenbus.measproc.processing.ProtoHelper._ +import io.greenbus.client.service.proto.EventRequests.EventTemplate + +@RunWith(classOf[JUnitRunner]) +class ProcessingPipelineTest extends FunSuite with ShouldMatchers { + + class TestRig { + val measQueue = mutable.Queue[(String, Measurement)]() + val eventQueue = mutable.Queue[EventTemplate.Builder]() + val measCache = new MockObjectCache[Measurement] + val overCache = new MockObjectCache[(Option[Measurement], Option[Measurement])] + val stateCache = new MockObjectCache[Boolean] + + def publish(meases: Seq[(String, Measurement)]) { + + } + + val proc = new ProcessingPipeline( + "endpoint01", + List("meas01"), + measQueue.enqueue, + measCache.get, + eventQueue.enqueue, + stateCache, + overCache) + + def process(key: String, m: Measurement) { + proc.process(Seq((key, m)), None) + } + } + + test("Typical") { + val r = new TestRig + r.proc.addTriggerSet(fakeUuid("meas01"), triggerSet) + + val m = makeAnalog(5.3, 0) + r.process("meas01", m) + + r.measQueue.length should equal(1) + val (key, meas) = r.measQueue.dequeue + key should equal("meas01") + checkGood(meas) + + r.eventQueue.length should equal(0) + r.stateCache.putQueue.length should equal(2) + } + + test("Failure") { + val r = new TestRig + r.proc.addTriggerSet(fakeUuid("meas01"), triggerSet) + + val m = makeAnalog(-5.3, 0) + + // First bad RLC check + r.process("meas01", m) + r.measQueue.length should equal(1) + checkStripped(r.measQueue.dequeue._2) + r.eventQueue.length should equal(1) + r.eventQueue.dequeue.getEventType should equal("event01") + + // Check second bad value doesn't generate event + r.process("meas01", m) + r.measQueue.length should equal(1) + checkStripped(r.measQueue.dequeue._2) + r.eventQueue.length should equal(0) + + // Check deadband still bad + val m2 = makeAnalog(4.2, 0) + r.process("meas01", m2) + r.measQueue.length should equal(1) + checkStripped(r.measQueue.dequeue._2) + r.eventQueue.length should equal(0) + + // Check return to normal + val m3 = makeAnalog(5.3, 0) + r.process("meas01", m3) + r.measQueue.length should equal(1) + checkGood(r.measQueue.dequeue._2) + r.eventQueue.length should equal(1) + r.eventQueue.dequeue.getEventType should equal("event02") + } + + def checkPrevInPutQueue(r: TestRig, key: String, meas: Measurement): Unit = { + val (overKey, (pubMeasOpt, latestMeasOpt)) = r.overCache.putQueue.dequeue + pubMeasOpt.isEmpty should equal(false) + pubMeasOpt.get should equal(meas) + overKey should equal(key) + latestMeasOpt.isEmpty should equal(true) + } + + def checkLatestInPutQueue(r: TestRig, key: String, published: Measurement, latest: Measurement): Unit = { + val (overKey, (pubMeasOpt, latestMeasOpt)) = r.overCache.putQueue.dequeue + overKey should equal(key) + + pubMeasOpt.isEmpty should equal(false) + pubMeasOpt.get should equal(published) + + latestMeasOpt.isEmpty should equal(false) + latestMeasOpt.get should equal(latest) + } + + test("NIS") { + val r = new TestRig + r.proc.addTriggerSet(fakeUuid("meas01"), triggerSet) + + val m = makeAnalog(5.3, 0) + r.process("meas01", m) + + r.measQueue.length should equal(1) + val (key, meas) = r.measQueue.dequeue() + checkGood(meas) + + r.measCache.put("meas01", meas) + + r.proc.addOverride(makeNIS("meas01")) + + { + r.overCache.putQueue.length should equal(1) + checkPrevInPutQueue(r, key, meas) + } + + r.measQueue.length should equal(1) + val (nisedKey, nised) = r.measQueue.dequeue + nised.getQuality.getOperatorBlocked should equal(true) + nised.getQuality.getDetailQual.getOldData should equal(true) + + val m2 = makeAnalog(48, 0) + r.process("meas01", m2) + r.measQueue.length should equal(0) + + { + r.overCache.putQueue.length should equal(1) + checkLatestInPutQueue(r, key, meas, m2) + } + + r.proc.removeOverride(makeNIS("meas01")) + r.measQueue.length should equal(1) + checkGood(r.measQueue.dequeue._2, 48) + r.overCache.delQueue.length should equal(1) + r.overCache.delQueue.dequeue should equal("meas01") + } + + test("un-NIS same meas") { + val r = new TestRig + r.proc.addTriggerSet(fakeUuid("meas01"), triggerSet) + + val m = makeAnalog(5.3, 0) + r.process("meas01", m) + + r.measQueue.length should equal(1) + val (key, meas) = r.measQueue.dequeue() + checkGood(meas) + + r.measCache.put("meas01", meas) + + r.proc.addOverride(makeNIS("meas01")) + + { + r.overCache.putQueue.length should equal(1) + checkPrevInPutQueue(r, key, meas) + } + + r.measQueue.length should equal(1) + val (nisedKey, nised) = r.measQueue.dequeue + checkTransformedValue(nised, 5.3) + nised.getQuality.getOperatorBlocked should equal(true) + nised.getQuality.getDetailQual.getOldData should equal(true) + + r.proc.removeOverride(makeNIS("meas01")) + r.measQueue.length should equal(1) + checkGood(r.measQueue.dequeue._2, 5.3) + r.overCache.delQueue.length should equal(1) + r.overCache.delQueue.dequeue should equal("meas01") + } + + test("replace") { + val r = new TestRig + r.proc.addTriggerSet(fakeUuid("meas01"), triggerSet) + + val m = makeAnalog(5.3, 0) + r.process("meas01", m) + + r.measQueue.length should equal(1) + val (key, meas) = r.measQueue.dequeue() + checkGood(meas) + + r.measCache.put("meas01", meas) + + r.proc.addOverride(makeOverride("meas01", 33.0)) + + { + r.overCache.putQueue.length should equal(1) + checkPrevInPutQueue(r, key, meas) + } + + r.measQueue.length should equal(1) + val (replacedKey, replaced) = r.measQueue.dequeue + replaced.getDoubleVal should equal(33.0) + replaced.getQuality.getOperatorBlocked should equal(true) + replaced.getQuality.getSource should equal(Quality.Source.SUBSTITUTED) + + val m2 = makeAnalog(48, 0) + r.process("meas01", m2) + r.measQueue.length should equal(0) + + { + r.overCache.putQueue.length should equal(1) + checkLatestInPutQueue(r, key, meas, m2) + } + + r.proc.removeOverride(makeNIS("meas01")) + r.measQueue.length should equal(1) + checkGood(r.measQueue.dequeue._2, 48) + r.overCache.delQueue.length should equal(1) + r.overCache.delQueue.dequeue should equal("meas01") + } + + test("un-replace same meas") { + val r = new TestRig + r.proc.addTriggerSet(fakeUuid("meas01"), triggerSet) + + val m = makeAnalog(5.3, 0) + r.process("meas01", m) + + r.measQueue.length should equal(1) + val (key, meas) = r.measQueue.dequeue() + checkGood(meas) + + r.measCache.put("meas01", meas) + + r.proc.addOverride(makeOverride("meas01", 33.0)) + + { + r.overCache.putQueue.length should equal(1) + checkPrevInPutQueue(r, key, meas) + } + + r.measQueue.length should equal(1) + val (replacedKey, replaced) = r.measQueue.dequeue + replaced.getDoubleVal should equal(33.0) + replaced.getQuality.getOperatorBlocked should equal(true) + replaced.getQuality.getSource should equal(Quality.Source.SUBSTITUTED) + + r.proc.removeOverride(makeNIS("meas01")) + r.measQueue.length should equal(1) + checkGood(r.measQueue.dequeue._2, 5.3) + r.overCache.delQueue.length should equal(1) + r.overCache.delQueue.dequeue should equal("meas01") + } + + test("un-replace publishes event") { + val r = new TestRig + r.proc.addTriggerSet(fakeUuid("meas01"), triggerSet) + + val m = makeAnalog(5.3, 0) + r.process("meas01", m) + + r.measQueue.length should equal(1) + val (key, meas) = r.measQueue.dequeue() + checkGood(meas) + + r.measCache.put("meas01", meas) + + r.proc.addOverride(makeOverride("meas01", 33.0)) + + { + r.overCache.putQueue.length should equal(1) + checkPrevInPutQueue(r, key, meas) + } + + r.measQueue.length should equal(1) + val (replacedKey, replaced) = r.measQueue.dequeue + replaced.getDoubleVal should equal(33.0) + replaced.getQuality.getOperatorBlocked should equal(true) + replaced.getQuality.getSource should equal(Quality.Source.SUBSTITUTED) + + val m2 = makeAnalog(-3.0, 0) + r.process("meas01", m2) + r.measQueue.length should equal(0) + + { + r.overCache.putQueue.length should equal(1) + checkLatestInPutQueue(r, key, meas, m2) + } + + r.proc.removeOverride(makeNIS("meas01")) + r.overCache.delQueue.length should equal(1) + r.overCache.delQueue.dequeue should equal("meas01") + + r.measQueue.length should equal(1) + checkStripped(r.measQueue.dequeue._2) + r.eventQueue.length should equal(1) + r.eventQueue.dequeue.getEventType should equal("event01") + } + + def checkTransformedValue(m: Measurement, value: Double): Unit = { + m.getType should equal(Measurement.Type.DOUBLE) + m.getDoubleVal should equal(value * 10 + 50000) + } + + def checkGood(m: Measurement, value: Double = 5.3) { + m.getType should equal(Measurement.Type.DOUBLE) + m.getDoubleVal should equal(value * 10 + 50000) + } + def checkStripped(m: Measurement) { + m.getType should equal(Measurement.Type.NONE) + m.hasDoubleVal should equal(false) + m.getQuality.getValidity should equal(Quality.Validity.QUESTIONABLE) + } +} diff --git a/processing/src/test/scala/io/greenbus/measproc/processing/ActionFrameworkTest.scala b/processing/src/test/scala/io/greenbus/measproc/processing/ActionFrameworkTest.scala new file mode 100755 index 0000000..2db0a8f --- /dev/null +++ b/processing/src/test/scala/io/greenbus/measproc/processing/ActionFrameworkTest.scala @@ -0,0 +1,102 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.processing + +import org.scalatest.FunSuite +import org.scalatest.matchers.ShouldMatchers +import org.scalatest.junit.JUnitRunner +import org.junit.runner.RunWith + +import scala.collection.mutable +import io.greenbus.client.service.proto.Measurements.Measurement +import ProtoHelper._ + +@RunWith(classOf[JUnitRunner]) +class ActionFrameworkTest extends FunSuite with ShouldMatchers { + import Action._ + + class TestRig { + val evalCalls = mutable.Queue[Measurement]() + + def eval(ret: Measurement): Action.Evaluation = (m: Measurement) => { + evalCalls enqueue m + ret + } + + def action(act: ActivationType, disabled: Boolean, ret: Measurement) = + new BasicAction("action01", disabled, act, eval(ret)) + } + + test("Disabled") { + val r = new TestRig + val input = makeAnalog(5.3) + val output = makeAnalog(5300.0) + val result = r.action(High, true, output).process(input, true, true).get + result should equal(input) + r.evalCalls.length should equal(0) + } + + def scenario(state: Boolean, prev: Boolean, act: ActivationType, works: Boolean) = { + val r = new TestRig + val input = makeAnalog(5.3) + val output = makeAnalog(5300.0) + val result = r.action(act, false, output).process(input, state, prev).get + if (works) { + result should equal(output) + r.evalCalls.length should equal(1) + r.evalCalls.dequeue should equal(input) + } else { + result should equal(input) + r.evalCalls.length should equal(0) + } + } + + def matrix(act: ActivationType, col: Tuple4[Boolean, Boolean, Boolean, Boolean]) = { + scenario(true, true, act, col._1) + scenario(true, false, act, col._2) + scenario(false, true, act, col._3) + scenario(false, false, act, col._4) + } + + test("High") { + // prev: true false true false + // now: true true false false + matrix(High, (true, true, false, false)) + } + test("Low") { + // prev: true false true false + // now: true true false false + matrix(Low, (false, false, true, true)) + } + test("Rising") { + // prev: true false true false + // now: true true false false + matrix(Rising, (false, true, false, false)) + } + test("Falling") { + // prev: true false true false + // now: true true false false + matrix(Falling, (false, false, true, false)) + } + test("Transition") { + // prev: true false true false + // now: true true false false + matrix(Transition, (false, true, true, false)) + } +} \ No newline at end of file diff --git a/processing/src/test/scala/io/greenbus/measproc/processing/ActionsTest.scala b/processing/src/test/scala/io/greenbus/measproc/processing/ActionsTest.scala new file mode 100755 index 0000000..81f39e0 --- /dev/null +++ b/processing/src/test/scala/io/greenbus/measproc/processing/ActionsTest.scala @@ -0,0 +1,61 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.processing + +import org.scalatest.matchers.ShouldMatchers +import org.scalatest.FunSuite +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner +import ProtoHelper._ + +@RunWith(classOf[JUnitRunner]) +class ActionsTest extends FunSuite with ShouldMatchers { + + test("BoolTransform") { + val transformer = new Actions.BoolEnumTransformer("CLOSED", "OPEN") + + transformer.apply(makeBool(true)).getStringVal should equal("OPEN") + transformer.apply(makeBool(false)).getStringVal should equal("CLOSED") + + transformer.apply(makeInt(0)).getStringVal should equal("") + } + + test("IntTransform") { + val map = List((-1).toLong -> "Disabled", 0.toLong -> "Searching").toMap + val transformer = new Actions.IntegerEnumTransformer(map, Some("Otherwise")) + + transformer.apply(makeInt(-1)).getStringVal should equal("Disabled") + transformer.apply(makeInt(0)).getStringVal should equal("Searching") + + transformer.apply(makeInt(10)).getStringVal should equal("Otherwise") + } + + test("Suppression") { + + val suppressor = new SuppressAction("testAction", false, Action.High) + + val m = makeInt(5) + + suppressor.process(m, false, false) should equal(Some(m)) + suppressor.process(m, true, false) should equal(None) + suppressor.process(m, false, true) should equal(Some(m)) + suppressor.process(m, true, true) should equal(None) + + } +} \ No newline at end of file diff --git a/processing/src/test/scala/io/greenbus/measproc/processing/FilterTriggerTest.scala b/processing/src/test/scala/io/greenbus/measproc/processing/FilterTriggerTest.scala new file mode 100644 index 0000000..c449a63 --- /dev/null +++ b/processing/src/test/scala/io/greenbus/measproc/processing/FilterTriggerTest.scala @@ -0,0 +1,155 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.processing + +import java.util.UUID + +import org.scalatest.matchers.ShouldMatchers +import org.scalatest.junit.JUnitRunner +import org.junit.runner.RunWith +import org.scalatest.FunSuite +import io.greenbus.client.service.proto.Measurements.{ DetailQual, Quality, Measurement } +import io.greenbus.client.service.proto.Measurements.Quality.Validity +import io.greenbus.client.service.proto.Model.ModelUUID +import io.greenbus.client.service.proto.Processing.{ Trigger => TriggerProto, Filter => FilterProto, Action => ActionProto, ActivationType } +import ProtoHelper._ +import io.greenbus.client.service.proto.EventRequests.EventTemplate + +@RunWith(classOf[JUnitRunner]) +class FilterTriggerTest extends FunSuite with ShouldMatchers { + + test("Factory") { + val cache = new MockObjectCache[Measurement]() + def publish(ev: EventTemplate.Builder): Unit = {} + + val fac = new TriggerProcessingFactory(publish, cache) + + val stateCache = new MockObjectCache[Boolean] + + val proto = TriggerProto.newBuilder + .setTriggerName("testTrigger") + .setFilter( + FilterProto.newBuilder + .setType(FilterProto.FilterType.DUPLICATES_ONLY)) + .addActions( + ActionProto.newBuilder + .setSuppress(true) + .setActionName("action01") + .setType(ActivationType.LOW)) + .build + + val trig = fac.buildTrigger(proto, "point01", ModelUUID.newBuilder().setValue(UUID.randomUUID().toString).build()) + + val m = makeInt(10) + trig.process("test01", m, stateCache) should equal(Some((m, false))) + + trig.process("test01", m, stateCache) should equal(None) + + } + + class Fixture(band: FilterTrigger.Filter) { + val cache = new MockObjectCache[Measurement]() + val t = new FilterTrigger(cache, band) + + def sendAndBlock(key: String, m: Measurement) { + t.apply(key, m, false) should equal(false) + cache.putQueue.size should equal(0) + } + def sendAndReceive(key: String, m: Measurement) { + t.apply(key, m, false) should equal(true) + cache.putQueue.size should equal(1) + cache.putQueue.dequeue should equal(key, m) + } + } + + test("Duplicates first through") { + val f = new Fixture(new FilterTrigger.NoDuplicates) + + val m = makeAnalog(4.234) + + f.sendAndReceive("test01", m) + + f.sendAndBlock("test01", m) + } + + test("No duplicates") { + val f = new Fixture(new FilterTrigger.NoDuplicates) + + val m = makeAnalog(4.234) + + f.cache.update("test01", m) + + f.sendAndBlock("test01", m) + + f.sendAndReceive("test01", makeAnalog(3.3)) + } + + test("Duplicates") { + duplicateTest(makeAnalog(4.234)) + duplicateTest(makeInt(10)) + duplicateTest(makeBool(true)) + duplicateTest(makeString("string")) + } + + def duplicateTest(m: Measurement) { + val f = new Fixture(new FilterTrigger.NoDuplicates) + f.cache.update("test01", m) + f.sendAndBlock("test01", m) + } + + test("Isolate quality") { + val f = new Fixture(new FilterTrigger.NoDuplicates) + + val m = makeAnalog(4.234) + f.cache.update("test01", m) + + val m2 = Measurement.newBuilder(m).setQuality(Quality.newBuilder.setDetailQual(DetailQual.newBuilder).setValidity(Validity.INVALID)).build + f.sendAndReceive("test01", m2) + } + + test("Ints") { + val f = new Fixture(new FilterTrigger.Deadband(2)) + + val m = makeInt(10) + + f.sendAndReceive("test01", m) + + f.sendAndBlock("test01", makeInt(11)) + + f.sendAndBlock("test01", makeInt(12)) + + f.sendAndReceive("test01", makeInt(13)) + + f.sendAndReceive("test01", makeInt(10)) + } + + test("Double") { + val f = new Fixture(new FilterTrigger.Deadband(1.5)) + + f.sendAndReceive("test01", makeAnalog(10.01)) + + f.sendAndBlock("test01", makeAnalog(11.01)) + + f.sendAndBlock("test01", makeAnalog(11.51)) + + f.sendAndReceive("test01", makeAnalog(11.52)) + + f.sendAndReceive("test01", makeAnalog(9.99)) + } +} \ No newline at end of file diff --git a/processing/src/test/scala/io/greenbus/measproc/processing/MockObjectCache.scala b/processing/src/test/scala/io/greenbus/measproc/processing/MockObjectCache.scala new file mode 100755 index 0000000..e0c1c05 --- /dev/null +++ b/processing/src/test/scala/io/greenbus/measproc/processing/MockObjectCache.scala @@ -0,0 +1,45 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.processing + +import scala.collection.mutable + +class MockObjectCache[A] extends ObjectCache[A] { + + val delQueue = mutable.Queue[String]() + val putQueue = mutable.Queue[(String, A)]() + + val map = mutable.Map[String, A]() + + def update(name: String, obj: A) { + map.update(name, obj) + } + + def put(name: String, obj: A) { + putQueue.enqueue((name, obj)) + map.update(name, obj) + } + def get(name: String): Option[A] = { + map.get(name) + } + def delete(name: String) { + delQueue.enqueue(name) + map -= name + } +} \ No newline at end of file diff --git a/processing/src/test/scala/io/greenbus/measproc/processing/OverrideProcessorTest.scala b/processing/src/test/scala/io/greenbus/measproc/processing/OverrideProcessorTest.scala new file mode 100755 index 0000000..60fac52 --- /dev/null +++ b/processing/src/test/scala/io/greenbus/measproc/processing/OverrideProcessorTest.scala @@ -0,0 +1,246 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.processing + +import io.greenbus.client.service.proto.Measurements +import io.greenbus.client.service.proto.Measurements.Measurement +import io.greenbus.client.service.proto.Model.ModelUUID +import io.greenbus.client.service.proto.Processing.MeasOverride +import io.greenbus.jmx.{ Metrics, MetricsContainer } +import io.greenbus.measproc.processing.ProtoHelper._ +import org.junit.runner.RunWith +import org.scalatest.FunSuite +import org.scalatest.junit.JUnitRunner +import org.scalatest.matchers.ShouldMatchers + +import scala.collection.mutable + +@RunWith(classOf[JUnitRunner]) +class OverrideProcessorTest extends FunSuite with ShouldMatchers { + + class TestRig { + + val measQueue = new mutable.Queue[(String, Measurement)]() + + //val overCache = new MockObjectCache[(Measurement, Boolean)] + val overCache = new MockObjectCache[(Option[Measurement], Option[Measurement])] + val measCache = new MockObjectCache[Measurement] + + val metrics = Metrics(MetricsContainer()) + + def publish(key: String, m: Measurement): Unit = { + measQueue.enqueue((key, m)) + } + def handleRestore(key: String, prevPublishedOpt: Option[Measurement], latestOpt: Option[Measurement]): Unit = { + val vOpt = latestOpt orElse prevPublishedOpt + vOpt.foreach(m => measQueue.enqueue((key, m))) + } + + def handleTestReplaceValue(pointKey: String, replaceValue: Measurement, previousValueOpt: Option[Measurement], alreadyReplaced: Boolean): Unit = { + } + + val proc = new OverrideProcessor(handleRestore, publish, handleTestReplaceValue, overCache, measCache.get, metrics) + + def configure(config: List[MeasOverride]) { + config.foreach(proc.add) + } + + def sendAndCheckMeas(key: String, m: Measurement) { + proc.process(key, m) should equal(Some(m)) + overCache.putQueue.length should equal(0) + } + + def sendAndCheckOver(key: String, m: Measurement) { + proc.process(key, m) should equal(None) + val (qKey, (qPublishedOpt, qLatestOpt)) = overCache.putQueue.dequeue() + qKey should equal(key) + qLatestOpt.isEmpty should equal(false) + checkSame(m, qLatestOpt.get) + } + def receiveAndCheckMeas(key: String, orig: Measurement) { + val (qKey, qMeas) = measQueue.dequeue() + qKey should equal(key) + checkSameExceptTimeIsGreater(orig, qMeas) + } + def receiveAndCheckOver(key: String, pubOpt: Option[Measurement], latestOpt: Option[Measurement]) { + val (qKey, (qPublishedOpt, qLatestOpt)) = overCache.putQueue.dequeue() + qKey should equal(key) + + checkOptMeas(pubOpt, qPublishedOpt) + checkOptMeas(latestOpt, qLatestOpt) + } + def checkNISPublished(key: String, orig: Measurement) { + val (qKey, qMeas) = measQueue.dequeue() + qKey should equal(key) + checkNIS(orig, qMeas) + } + def checkReplacePublished(key: String, repl: Measurement) { + val (qKey, qMeas) = measQueue.dequeue() + qKey should equal(key) + checkReplaced(repl, qMeas) + } + + def checkOptMeas(aOpt: Option[Measurement], bOpt: Option[Measurement]): Unit = { + (aOpt, bOpt) match { + case (Some(a), Some(b)) => checkSame(a, b) + case (None, None) => + case _ => assert(false) + } + } + } + + def makeOverride(name: String): MeasOverride = makeOverride(name, 0) + + def makeNIS(key: String) = { + MeasOverride.newBuilder.setPointUuid(ModelUUID.newBuilder().setValue(key)).build + } + def makeOverride(key: String, value: Double): MeasOverride = { + MeasOverride.newBuilder + .setPointUuid(ModelUUID.newBuilder().setValue(key)) + .setMeasurement(Measurement.newBuilder + .setTime(85) + .setType(Measurement.Type.DOUBLE) + .setDoubleVal(value) + .setQuality(Measurements.Quality.newBuilder.setDetailQual(Measurements.DetailQual.newBuilder))) + .build + } + + def checkSame(m: Measurement, meas: Measurement): Unit = { + m.getType should equal(meas.getType) + m.getDoubleVal should equal(meas.getDoubleVal) + m.getTime should equal(meas.getTime) + m.getQuality should equal(meas.getQuality) + } + + def checkSameExceptTimeIsGreater(orig: Measurement, meas: Measurement): Unit = { + meas.getType should equal(orig.getType) + meas.getDoubleVal should equal(orig.getDoubleVal) + meas.getQuality should equal(orig.getQuality) + meas.getTime should be >= (orig.getTime) + } + + def sameExceptQualityAndTimeIsGreater(orig: Measurement, pub: Measurement): Unit = { + pub.getType should equal(orig.getType) + pub.getDoubleVal should equal(orig.getDoubleVal) + pub.getTime should be >= (orig.getTime) + } + def checkNIS(orig: Measurement, pub: Measurement): Unit = { + sameExceptQualityAndTimeIsGreater(orig, pub) + pub.getQuality.getDetailQual.getOldData should equal(true) + pub.getQuality.getOperatorBlocked should equal(true) + } + + def checkReplaced(repl: Measurement, pub: Measurement): Unit = { + sameExceptQualityAndTimeIsGreater(repl, pub) + pub.getQuality.getDetailQual.getOldData should equal(false) + pub.getQuality.getSource should equal(Measurements.Quality.Source.SUBSTITUTED) + } + + test("NullOverride") { + val r = new TestRig + r.configure(Nil) + r.sendAndCheckMeas("meas01", makeAnalog(5.3)) + } + + test("Preconfig") { + val r = new TestRig + val config = List(makeOverride("meas01", 89)) + r.configure(config) + r.checkReplacePublished("meas01", config(0).getMeasurement) + r.sendAndCheckOver("meas01", makeAnalog(5.3)) + } + + test("Multiple") { + val config = List(makeOverride("meas01", 89), makeOverride("meas02", 44)) + val r = new TestRig + r.configure(config) + + r.checkReplacePublished("meas01", config(0).getMeasurement) + r.checkReplacePublished("meas02", config(1).getMeasurement) + r.sendAndCheckOver("meas01", makeAnalog(5.3)) + r.sendAndCheckOver("meas02", makeAnalog(5.3)) + r.sendAndCheckMeas("meas03", makeAnalog(5.3)) + } + + test("NISWithReplace") { + val r = new TestRig + r.configure(Nil) + val orig = makeAnalog(5.3) + r.measCache.update("meas01", orig) + val over = makeOverride("meas01", 89) + r.proc.add(over) + r.checkReplacePublished("meas01", over.getMeasurement) + r.receiveAndCheckOver("meas01", Some(orig), None) + r.sendAndCheckOver("meas01", makeAnalog(44)) + } + + test("NISThenReplace") { + val r = new TestRig + r.configure(Nil) + val orig = makeAnalog(5.3) + r.measCache.update("meas01", orig) + val nis = makeNIS("meas01") + r.proc.add(nis) + r.checkNISPublished("meas01", orig) + r.receiveAndCheckOver("meas01", Some(orig), None) + + val replace = makeOverride("meas01", 89) + r.proc.add(replace) + r.checkReplacePublished("meas01", replace.getMeasurement) + + r.sendAndCheckOver("meas01", makeAnalog(44)) + } + + test("DoubleAdd") { + val r = new TestRig + r.configure(Nil) + + val orig = makeAnalog(5.3) + r.measCache.update("meas01", orig) + val over = makeOverride("meas01", 89) + r.proc.add(over) + r.checkReplacePublished("meas01", over.getMeasurement) + r.receiveAndCheckOver("meas01", Some(orig), None) + r.sendAndCheckOver("meas01", makeAnalog(44)) + + // On the second add, we shouldn't override the cached "real" value + val over2 = makeOverride("meas01", 55) + r.proc.add(over2) + r.checkReplacePublished("meas01", over2.getMeasurement) + r.overCache.putQueue.length should equal(0) + r.sendAndCheckOver("meas01", makeAnalog(23)) + } + + test("Removed") { + val config = List(makeOverride("meas01", 89)) + val r = new TestRig + r.configure(config) + + r.checkReplacePublished("meas01", config(0).getMeasurement) + val orig = makeAnalog(5.3) + r.overCache.update("meas01", (Some(orig), None)) + val nisRemove = makeNIS("meas01") + r.proc.remove(nisRemove) + r.receiveAndCheckMeas("meas01", orig) + r.overCache.delQueue.length should equal(1) + r.overCache.delQueue.dequeue should equal("meas01") + r.sendAndCheckMeas("meas01", makeAnalog(33)) + } +} + diff --git a/processing/src/test/scala/io/greenbus/measproc/processing/ProtoHelper.scala b/processing/src/test/scala/io/greenbus/measproc/processing/ProtoHelper.scala new file mode 100755 index 0000000..8b61d65 --- /dev/null +++ b/processing/src/test/scala/io/greenbus/measproc/processing/ProtoHelper.scala @@ -0,0 +1,154 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.processing + +import io.greenbus.client.service.proto.Model.{ ModelUUID, Entity } +import io.greenbus.client.service.proto.Measurements.{ Quality, DetailQual, Measurement } +import io.greenbus.client.service.proto.Processing.{ TriggerSet, Trigger => ProtoTrigger, Action => ProtoAction, MeasOverride } + +object ProtoHelper { + + def makeAnalog(value: Double, time: Long = System.currentTimeMillis): Measurement = { + val m = Measurement.newBuilder + m.setTime(time) + m.setSystemTime(time) + m.setType(Measurement.Type.DOUBLE) + m.setDoubleVal(value) + m.setQuality(makeNominalQuality) + m.build + } + + def makeInt(value: Long, time: Long = System.currentTimeMillis): Measurement = { + Measurement.newBuilder + .setTime(time) + .setSystemTime(time) + .setType(Measurement.Type.INT) + .setIntVal(value) + .setQuality(makeNominalQuality) + .build + + } + def makeBool(value: Boolean, time: Long = System.currentTimeMillis): Measurement = { + Measurement.newBuilder + .setTime(time) + .setSystemTime(time) + .setType(Measurement.Type.BOOL) + .setBoolVal(value) + .setQuality(makeNominalQuality) + .build + + } + + def makeString(value: String, time: Long = System.currentTimeMillis): Measurement = { + Measurement.newBuilder + .setTime(time) + .setSystemTime(time) + .setType(Measurement.Type.STRING) + .setStringVal(value) + .setQuality(makeNominalQuality) + .build + } + + def makeNominalQuality() = { + Quality.newBuilder.setDetailQual(DetailQual.newBuilder).build + } + + def makeAbnormalQuality() = { + Quality.newBuilder.setDetailQual(DetailQual.newBuilder).setValidity(Quality.Validity.INVALID).build + } + + def updateQuality(m: Measurement, q: Quality): Measurement = { + m.toBuilder.setQuality(q).build + } + + def makeNodeById(nodeId: String): Entity = { + Entity.newBuilder.setUuid(ModelUUID.newBuilder.setValue(nodeId)).build + } + + def makeNodeById(nodeId: ModelUUID): Entity = { + Entity.newBuilder.setUuid(nodeId).build + } + + def makeNodeByName(name: String): Entity = { + Entity.newBuilder.setName(name).build + } + + def makeNIS(key: String) = { + MeasOverride.newBuilder.setPointUuid(ModelUUID.newBuilder().setValue(key)).build + } + def makeOverride(key: String, value: Double): MeasOverride = { + MeasOverride.newBuilder + .setPointUuid(ModelUUID.newBuilder().setValue(key)) + .setMeasurement(Measurement.newBuilder + .setTime(85) + .setSystemTime(85) + .setType(Measurement.Type.DOUBLE) + .setDoubleVal(value) + .setQuality(Quality.getDefaultInstance)) + .build + } + + def fakeUuid(str: String) = ModelUUID.newBuilder().setValue(str).build() + + def triggerSet: TriggerSet = { + TriggerSet.newBuilder + .addTriggers(triggerRlcLow("meas01")) + .addTriggers(triggerTransformation("meas01")) + .build + } + def triggerRlcLow(measName: String): ProtoTrigger = { + import io.greenbus.client.service.proto.Processing._ + ProtoTrigger.newBuilder + .setTriggerName("rlclow") + .setStopProcessingWhen(ActivationType.HIGH) + .setAnalogLimit(AnalogLimit.newBuilder.setLowerLimit(0).setDeadband(5)) + .addActions( + ProtoAction.newBuilder + .setActionName("strip") + .setType(ActivationType.HIGH) + .setStripValue(true)) + .addActions( + ProtoAction.newBuilder + .setActionName("qual") + .setType(ActivationType.HIGH) + .setQualityAnnotation(Quality.newBuilder.setValidity(Quality.Validity.QUESTIONABLE))) + .addActions( + ProtoAction.newBuilder + .setActionName("eventrise") + .setType(ActivationType.RISING) + .setEvent(EventGeneration.newBuilder.setEventType("event01"))) + .addActions( + ProtoAction.newBuilder + .setActionName("eventfall") + .setType(ActivationType.FALLING) + .setEvent(EventGeneration.newBuilder.setEventType("event02"))) + .build + } + def triggerTransformation(measName: String): ProtoTrigger = { + import io.greenbus.client.service.proto.Processing._ + ProtoTrigger.newBuilder + .setTriggerName("trans") + .addActions( + ProtoAction.newBuilder + .setActionName("linear") + .setType(ActivationType.HIGH) + .setLinearTransform(LinearTransform.newBuilder.setScale(10).setOffset(50000))) + .build + } +} diff --git a/processing/src/test/scala/io/greenbus/measproc/processing/TriggerFrameworkTest.scala b/processing/src/test/scala/io/greenbus/measproc/processing/TriggerFrameworkTest.scala new file mode 100755 index 0000000..83b2fdd --- /dev/null +++ b/processing/src/test/scala/io/greenbus/measproc/processing/TriggerFrameworkTest.scala @@ -0,0 +1,178 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.processing + +import org.scalatest.FunSuite +import org.scalatest.matchers.ShouldMatchers +import org.scalatest.junit.JUnitRunner +import org.junit.runner.RunWith +import ProtoHelper._ + +import scala.collection.mutable +import io.greenbus.client.service.proto.Measurements.Measurement + +@RunWith(classOf[JUnitRunner]) +class TriggerFrameworkTest extends FunSuite with ShouldMatchers { + + class TestRig { + val triggerCalls = mutable.Queue[Measurement]() + val conditionCalls = mutable.Queue[(Measurement, Boolean)]() + val actionCalls = mutable.Queue[(Measurement, Boolean, Boolean)]() + + def trigger(ret: Measurement, stop: Boolean): Trigger = new MockTrigger(ret, stop) + class MockTrigger(ret: Measurement, stop: Boolean) extends Trigger { + def process(key: String, m: Measurement, cache: ObjectCache[Boolean]) = { + triggerCalls enqueue m + Some((ret, stop)) + } + } + + def condition(ret: Boolean): Trigger.Condition = new MockCondition(ret) + class MockCondition(ret: Boolean) extends Trigger.SimpleCondition { + def apply(m: Measurement, prev: Boolean): Boolean = { + conditionCalls.enqueue((m, prev)) + ret + } + } + def action(ret: Measurement): Action = new MockAction(ret) + class MockAction(ret: Measurement, var disabled: Boolean = false, val name: String = "action01") extends Action { + def process(m: Measurement, state: Boolean, prev: Boolean): Option[Measurement] = { + actionCalls.enqueue((m, state, prev)) + Some(ret) + } + } + + val cache = new MockObjectCache[Boolean] + } + + test("Test Cache Lookup") { + val r = new TestRig + + val input = makeAnalog(5.3) + val trigger = new BasicTrigger("meas01.trig01", List(r.condition(true)), Nil, None) + r.cache.update("meas01.trig01", true) + val (result, stop) = trigger.process("meas01", input, r.cache).get + + r.conditionCalls.length should equal(1) + r.conditionCalls.dequeue should equal((input, true)) + r.cache.putQueue.length should equal(1) + r.cache.putQueue.dequeue should equal(("meas01.trig01", true)) + } + + test("testMultipleTriggers") { + val r = new TestRig + + val input = makeAnalog(5.3) + val output1 = makeAnalog(5300.0) + val output2 = makeAnalog(0.053) + + val triggers = List(r.trigger(output1, false), r.trigger(output2, false)) + + val result = Trigger.processAll("meas01", input, r.cache, triggers).get + + r.triggerCalls.length should equal(2) + r.triggerCalls.dequeue should equal(input) + r.triggerCalls.dequeue should equal(output1) + result should equal(output2) + } + + test("testMultipleTriggerShortCircuit") { + val r = new TestRig + + val input = makeAnalog(5.3) + val output1 = makeAnalog(5300.0) + val output2 = makeAnalog(0.053) + + val triggers = List(r.trigger(output1, true), r.trigger(output2, false)) + + val result = Trigger.processAll("meas01", input, r.cache, triggers).get + + r.triggerCalls.length should equal(1) + r.triggerCalls.dequeue should equal(input) + result should equal(output1) + } + + test("testMultipleConditions") { + val r = new TestRig + + val input = makeAnalog(5.3) + val output = makeAnalog(5300.0) + val trigger = new BasicTrigger("meas01.trig01", List(r.condition(true), r.condition(true)), List(r.action(output)), None) + val (result, stop) = trigger.process("meas01", input, r.cache).get + + r.conditionCalls.length should equal(2) + r.conditionCalls.dequeue should equal((input, false)) + r.conditionCalls.dequeue should equal((input, false)) + + r.actionCalls.length should equal(1) + + result should equal(output) + } + test("testMultipleConditionAnd") { + val r = new TestRig + + val input = makeAnalog(5.3) + val output = makeAnalog(5300.0) + val trigger = new BasicTrigger("meas01.trig01", List(r.condition(true), r.condition(false)), List(r.action(output)), None) + val (result, stop) = trigger.process("meas01", input, r.cache).get + + r.conditionCalls.length should equal(2) + r.conditionCalls.dequeue should equal((input, false)) + r.conditionCalls.dequeue should equal((input, false)) + + r.actionCalls.length should equal(1) + r.actionCalls.dequeue should equal((input, false, false)) + } + + test("testMultipleConditionShortCircuit") { + val r = new TestRig + + val input = makeAnalog(5.3) + val output = makeAnalog(5300.0) + val trigger = new BasicTrigger("meas01.trig01", List(r.condition(false), r.condition(true)), List(r.action(output)), None) + val (result, stop) = trigger.process("meas01", input, r.cache).get + + r.conditionCalls.length should equal(1) + r.conditionCalls.dequeue should equal((input, false)) + + r.actionCalls.length should equal(1) + r.actionCalls.dequeue should equal((input, false, false)) + } + + test("testMultipleActions") { + val r = new TestRig + + val input = makeAnalog(5.3) + val output1 = makeAnalog(5300.0) + val output2 = makeAnalog(0.053) + + val trigger = new BasicTrigger("meas01.trig01", List(r.condition(true)), List(r.action(output1), r.action(output2)), None) + val (result, stop) = trigger.process("meas01", input, r.cache).get + + r.conditionCalls.length should equal(1) + r.conditionCalls.dequeue should equal((input, false)) + r.cache.putQueue.length should equal(1) + r.cache.putQueue.dequeue should equal(("meas01.trig01", true)) + + r.actionCalls.length should equal(2) + r.actionCalls.dequeue should equal((input, true, false)) + r.actionCalls.dequeue should equal((output1, true, false)) + result should equal(output2) + } +} diff --git a/processing/src/test/scala/io/greenbus/measproc/processing/WhitelistTest.scala b/processing/src/test/scala/io/greenbus/measproc/processing/WhitelistTest.scala new file mode 100755 index 0000000..433e24f --- /dev/null +++ b/processing/src/test/scala/io/greenbus/measproc/processing/WhitelistTest.scala @@ -0,0 +1,47 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.measproc.processing + +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner +import org.scalatest.FunSuite +import org.scalatest.matchers.ShouldMatchers +import scala.collection.mutable +import ProtoHelper._ +import io.greenbus.jmx.{ MetricsContainer, Metrics } + +@RunWith(classOf[JUnitRunner]) +class WhitelistTest extends FunSuite with ShouldMatchers { + test("Ignores meases") { + val metrics = Metrics(MetricsContainer()) + + val filter = new MeasurementWhiteList(List("ok1", "ok2"), metrics) + + val m = makeAnalog(100) + + filter.process("ok1", m) should equal(Some(m)) + filter.process("ok2", m) should equal(Some(m)) + + filter.process("bad1", m) should equal(None) + filter.process("bad2", m) should equal(None) + filter.process("bad1", m) should equal(None) + + filter.process("ok1", m) should equal(Some(m)) + } +} \ No newline at end of file diff --git a/protoc-gen-msgdoc b/protoc-gen-msgdoc new file mode 100755 index 0000000..5ae0cea --- /dev/null +++ b/protoc-gen-msgdoc @@ -0,0 +1,5 @@ +#! /bin/sh + +JAR=~/.m2/repository/io/greenbus/msg/greenbus-msg-compiler/1.0.0/greenbus-msg-compiler-1.0.0-jar-with-dependencies.jar + +java -Dapi.version="3.0.0" -cp $JAR io.greenbus.msg.compiler.doc.Compiler 2> msgdocprotoc.log diff --git a/protoc-gen-msgjava b/protoc-gen-msgjava new file mode 100755 index 0000000..6fa05a2 --- /dev/null +++ b/protoc-gen-msgjava @@ -0,0 +1,5 @@ +#! /bin/sh + +JAR=~/.m2/repository/io/greenbus/msg/greenbus-msg-compiler/1.0.0/greenbus-msg-compiler-1.0.0-jar-with-dependencies.jar + +java -DgenTarget=java -jar ${JAR} diff --git a/protoc-gen-msgscala b/protoc-gen-msgscala new file mode 100755 index 0000000..ff64671 --- /dev/null +++ b/protoc-gen-msgscala @@ -0,0 +1,5 @@ +#! /bin/sh + +JAR=~/.m2/repository/io/greenbus/msg/greenbus-msg-compiler/1.0.0/greenbus-msg-compiler-1.0.0-jar-with-dependencies.jar + +java -DgenTarget=scala -jar ${JAR} diff --git a/scala-base/pom.xml b/scala-base/pom.xml new file mode 100755 index 0000000..6044280 --- /dev/null +++ b/scala-base/pom.xml @@ -0,0 +1,98 @@ + + + 4.0.0 + greenbus-scala-base + pom + + + io.greenbus + greenbus-parent + 3.0.0 + ../ + + + + + + + net.alchim31.maven + scala-maven-plugin + ${scala-maven-plugin.version} + + + + scala-compile-first + process-resources + + add-source + compile + + + + scala-test-compile + process-test-resources + + testCompile + + + + + + + -Xmx1024m + + false + true + + + + + org.scalariform + scalariform-maven-plugin + ${scalariform-maven-plugin.version} + + + + format + + + + + + + + + + org.scala-lang + scala-library + ${scala.version} + compile + + + + org.slf4j + slf4j-api + ${slf4j-api.version} + compile + + + + com.typesafe + scalalogging-slf4j_${scala.annotation} + ${scalalogging.version} + compile + + + + org.scalatest + scalatest_${scala.annotation} + ${scalatest.version} + test + + + + + + + diff --git a/services/pom.xml b/services/pom.xml new file mode 100755 index 0000000..6461016 --- /dev/null +++ b/services/pom.xml @@ -0,0 +1,93 @@ + + 4.0.0 + + + io.greenbus + greenbus-scala-base + 3.0.0 + ../scala-base + + + greenbus-services + jar + + + + AGPLv3 + http://www.gnu.org/licenses/agpl-3.0.txt + + + + + + + + com.mycila.maven-license-plugin + maven-license-plugin + +
../AGPL_FILE_HEADER
+
+
+ + org.apache.maven.plugins + maven-surefire-plugin + + ../ + + +
+
+ + + + io.greenbus + greenbus-client + 3.0.0 + + + io.greenbus + greenbus-mstore-sql + 3.0.0 + + + io.greenbus + greenbus-sql + 3.0.0 + + + io.greenbus.msg + greenbus-msg-qpid + 1.0.0 + + + com.typesafe.akka + akka-actor_2.10 + 2.2.0 + + + com.typesafe.akka + akka-slf4j_2.10 + 2.2.0 + + + commons-cli + commons-cli + 1.2 + + + commons-codec + commons-codec + ${commons-codec.version} + + + io.greenbus + greenbus-sql + 3.0.0 + test-jar + test + + + + +
diff --git a/services/src/main/scala/io/greenbus/services/AuthenticationModule.scala b/services/src/main/scala/io/greenbus/services/AuthenticationModule.scala new file mode 100644 index 0000000..8a179c9 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/AuthenticationModule.scala @@ -0,0 +1,32 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services + +import java.util.UUID + +import scala.concurrent.Future + +sealed trait AuthenticationModule + +trait SqlAuthenticationModule extends AuthenticationModule { + def authenticate(name: String, password: String): (Boolean, Option[UUID]) +} +trait AsyncAuthenticationModule extends AuthenticationModule { + def authenticate(name: String, password: String): Future[Boolean] +} \ No newline at end of file diff --git a/services/src/main/scala/io/greenbus/services/CoreServices.scala b/services/src/main/scala/io/greenbus/services/CoreServices.scala new file mode 100644 index 0000000..04f6958 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/CoreServices.scala @@ -0,0 +1,141 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services + +import java.util.concurrent.ExecutorService + +import akka.actor.ActorSystem +import com.typesafe.config.ConfigFactory +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.client.ServiceConnection +import io.greenbus.client.proto.Envelope.SubscriptionEventType +import io.greenbus.client.service.proto.Events.{ Alarm, AlarmNotification, Event, EventNotification } +import io.greenbus.client.service.proto.FrontEnd.FrontEndConnectionStatus +import io.greenbus.client.service.proto.Measurements.{ MeasurementBatchNotification, MeasurementNotification } +import io.greenbus.client.service.proto.Model._ +import io.greenbus.client.service.proto.Processing.OverrideNotification +import io.greenbus.jmx.MetricsManager +import io.greenbus.mstore.sql.{ SimpleInTransactionCurrentValueStore, SimpleInTransactionHistoryStore } +import io.greenbus.services.authz.{ AuthLookup, DefaultAuthLookup } +import io.greenbus.services.core._ +import io.greenbus.services.framework._ +import io.greenbus.services.model._ +import io.greenbus.sql.DbConnection + +object CoreServices extends Logging { + + def main(args: Array[String]) { + val rootConfig = ConfigFactory.load() + val slf4jConfig = ConfigFactory.parseString("""akka { loggers = ["akka.event.slf4j.Slf4jLogger"] }""") + val akkaConfig = slf4jConfig.withFallback(rootConfig) + val system = ActorSystem("services", akkaConfig) + + val baseDir = Option(System.getProperty("io.greenbus.config.base")).getOrElse("") + val amqpConfigPath = Option(System.getProperty("io.greenbus.config.amqp")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.msg.amqp.cfg") + val sqlConfigPath = Option(System.getProperty("io.greenbus.config.sql")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.sql.cfg") + + val mgr = system.actorOf(ServiceManager.props(amqpConfigPath, sqlConfigPath, runServices)) + } + + def runServices(sql: DbConnection, conn: ServiceConnection, exe: ExecutorService) { + + val marshaller = new ServiceMarshaller { + def run[U](runner: => U) { + exe.submit(new Runnable { + def run() { + runner + } + }) + } + } + + val authModule: AuthenticationModule = new SquerylAuthModule + val authLookup: AuthLookup = DefaultAuthLookup + + val metricsMgr = MetricsManager("io.greenbus.services") + + val measChannelMgr = new SubscriptionChannelBinderOnly(conn.serviceOperations, classOf[MeasurementNotification].getSimpleName) + val measBatchChannelMgr = new SubscriptionChannelBinderOnly(conn.serviceOperations, classOf[MeasurementBatchNotification].getSimpleName) + + val entitySubMgr = new SubscriptionChannelManager(EntitySubscriptionDescriptor, conn.serviceOperations, classOf[EntityNotification].getSimpleName) + val entityEdgeSubMgr = new SubscriptionChannelManager(EntityEdgeSubscriptionDescriptor, conn.serviceOperations, classOf[EntityEdgeNotification].getSimpleName) + val keyValueSubMgr = new SubscriptionChannelManager(EntityKeyValueSubscriptionDescriptor, conn.serviceOperations, classOf[EntityKeyValueNotification].getSimpleName) + + val overSubMgr = new SubscriptionChannelManager(OverrideSubscriptionDescriptor, conn.serviceOperations, classOf[OverrideNotification].getSimpleName) + + val endpointMgr = new SubscriptionChannelManager(EndpointSubscriptionDescriptor, conn.serviceOperations, classOf[EndpointNotification].getSimpleName) + val pointMgr = new SubscriptionChannelManager(PointSubscriptionDescriptor, conn.serviceOperations, classOf[PointNotification].getSimpleName) + val commandMgr = new SubscriptionChannelManager(CommandSubscriptionDescriptor, conn.serviceOperations, classOf[CommandNotification].getSimpleName) + val commStatusMgr = new SubscriptionChannelManager(FrontEndConnectionStatusSubscriptionDescriptor, conn.serviceOperations, classOf[FrontEndConnectionStatus].getSimpleName) + + val eventMgr = new SubscriptionChannelManager(EventSubscriptionDescriptor, conn.serviceOperations, classOf[EventNotification].getSimpleName) + val alarmMgr = new SubscriptionChannelManager(AlarmSubscriptionDescriptor, conn.serviceOperations, classOf[AlarmNotification].getSimpleName) + + val eventMapper = new ModelEventMapper + eventMapper.register(classOf[Entity], entitySubMgr) + eventMapper.register(classOf[EntityEdge], entityEdgeSubMgr) + eventMapper.register(classOf[EntityKeyValueWithEndpoint], keyValueSubMgr) + eventMapper.register(classOf[OverrideWithEndpoint], overSubMgr) + eventMapper.register(classOf[Endpoint], endpointMgr) + eventMapper.register(classOf[Point], pointMgr) + eventMapper.register(classOf[Command], commandMgr) + eventMapper.register(classOf[Event], eventMgr) + eventMapper.register(classOf[Alarm], alarmMgr) + eventMapper.register(classOf[FrontEndConnectionStatus], commStatusMgr) + + val transSource = ServiceTransactionSource(sql, conn.serviceOperations, authLookup, eventMapper) + val registry = new FullServiceRegistry(sql, conn.serviceOperations, authLookup, eventMapper, metricsMgr) + + val simpleModelNotifier = new SimpleModelNotifier(eventMapper, conn.serviceOperations, ServiceTransactionSource.notificationObserver) + + val loginServices = new LoginServices(registry, sql, authModule, SquerylAuthModel, SquerylEventAlarmModel, simpleModelNotifier) + + val entityServices = new EntityServices(registry, SquerylEntityModel, SquerylFrontEndModel, SquerylEventAlarmModel, entitySubMgr, keyValueSubMgr, entityEdgeSubMgr, endpointMgr, pointMgr, commandMgr) + + val authServices = new AuthServices(registry, SquerylAuthModel) + + val frontEndServices = new FrontEndServices(registry, conn.serviceOperations, SquerylFrontEndModel, commStatusMgr) + + val processingServices = new ProcessingServices(registry, SquerylProcessingModel, SquerylEventAlarmModel, SquerylFrontEndModel, overSubMgr) + + val measurementServices = new MeasurementServices(registry, SimpleInTransactionCurrentValueStore, SimpleInTransactionHistoryStore, measChannelMgr, measBatchChannelMgr, SquerylFrontEndModel) + + val commandServices = new CommandServices(registry, conn.session, transSource, SquerylCommandModel, SquerylFrontEndModel, SquerylEventAlarmModel) + + val eventAlarmServices = new EventAlarmServices(registry, SquerylEventAlarmModel, SquerylEntityModel, eventMgr, alarmMgr) + + logger.info("Binding services") + registry.getRegistered.foreach { + case (requestId, handler) => + conn.serviceOperations.bindCompetingService(new MarshallingHandler(marshaller, handler), requestId) + } + + metricsMgr.register() + + logger.info("Services bound") + } +} + +object NotificationConversions { + def protoType: ModelEvent => SubscriptionEventType = { + case Created => SubscriptionEventType.ADDED + case Updated => SubscriptionEventType.MODIFIED + case Deleted => SubscriptionEventType.REMOVED + } +} diff --git a/services/src/main/scala/io/greenbus/services/ResetDatabase.scala b/services/src/main/scala/io/greenbus/services/ResetDatabase.scala new file mode 100644 index 0000000..47d4cc3 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/ResetDatabase.scala @@ -0,0 +1,94 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.sql.{ DbConnector, SqlSettings, DbConnection } +import io.greenbus.services.model.{ EventSeeding, ModelAuthSeeder, AuthSeedData } +import io.greenbus.services.data.{ ProcessingLockSchema, ServicesSchema } +import io.greenbus.mstore.sql.MeasurementStoreSchema +import org.apache.commons.cli +import org.apache.commons.cli.{ HelpFormatter, Options } + +object ResetDatabase extends Logging { + + val forceLongFlag = "force" + val forceFlag = "f" + + def buildOptions: Options = { + val opts = new Options + opts.addOption("h", "help", false, "Display this help text") + opts.addOption(new cli.Option(forceFlag, forceLongFlag, false, "Skip confirmation prompt")) + opts + } + + def reset(sqlConfigPath: String) { + val sqlSettings = SqlSettings.load(sqlConfigPath) + val conn = DbConnector.connect(sqlSettings) + reset(conn) + } + + def reset(conn: DbConnection) { + conn.transaction { + ServicesSchema.reset() + MeasurementStoreSchema.drop + MeasurementStoreSchema.create + + ProcessingLockSchema.reset() + + AuthSeedData.seed(ModelAuthSeeder, "system") + EventSeeding.seed() + } + } + + def main(args: Array[String]) { + + val options = buildOptions + val parser = new cli.BasicParser + val line = parser.parse(options, args) + + def printHelp() { + (new HelpFormatter).printHelp("", options) + } + + if (line.hasOption("h")) { + printHelp() + } else { + + val force = line.hasOption(forceLongFlag) || line.hasOption(forceFlag) + if (!force) { + println("Proceed with modifications? (y/N)") + val answer = readLine() + if (!Set("y", "yes").contains(answer.trim.toLowerCase)) { + System.exit(0) + } + } + + try { + val baseDir = Option(System.getProperty("io.greenbus.config.base")).getOrElse("") + val sqlConfigPath = Option(System.getProperty("io.greenbus.config.sql")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.sql.cfg") + reset(sqlConfigPath) + } catch { + case ex: Throwable => + logger.error("Failure: " + ex) + println("Error: " + ex.getMessage) + } + } + } +} diff --git a/services/src/main/scala/io/greenbus/services/ServiceManager.scala b/services/src/main/scala/io/greenbus/services/ServiceManager.scala new file mode 100644 index 0000000..e3bed39 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/ServiceManager.scala @@ -0,0 +1,111 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services + +import akka.actor._ +import com.typesafe.scalalogging.slf4j.Logging +import scala.concurrent.duration._ +import java.util.concurrent.{ ExecutorService, Executors } +import io.greenbus.msg.amqp.AmqpSettings +import io.greenbus.sql.{ DbConnector, SqlSettings, DbConnection } +import scala.concurrent.ExecutionContext.Implicits.global +import io.greenbus.msg.qpid.QpidBroker +import io.greenbus.client.ServiceConnection + +object ServiceManager { + + sealed trait State + case object Down extends State + case object Running extends State + + sealed trait Data + case object NoData extends Data + case class Resources(sql: DbConnection, conn: ServiceConnection, exe: ExecutorService) extends Data + + case object AttemptConnect + case class LostBrokerConnection(expected: Boolean) + + type ServiceRunner = (DbConnection, ServiceConnection, ExecutorService) => Unit + + def props(amqpConfigPath: String, sqlConfigPath: String, runner: ServiceRunner): Props = + Props(classOf[ServiceManager], amqpConfigPath, sqlConfigPath, runner) +} + +import ServiceManager._ +class ServiceManager(amqpConfigPath: String, sqlConfigPath: String, runner: ServiceRunner) extends Actor with FSM[State, Data] with Logging { + + private def connectSql(): DbConnection = { + val config = SqlSettings.load(sqlConfigPath) + DbConnector.connect(config) + } + + startWith(Down, NoData) + + when(Down) { + case Event(AttemptConnect, NoData) => { + try { + val conn = ServiceConnection.connect(AmqpSettings.load(amqpConfigPath), QpidBroker, 5000) + val exe = Executors.newCachedThreadPool() + val sql = connectSql() + + conn.addConnectionListener { expected => + self ! LostBrokerConnection(expected) + } + + runner(sql, conn, exe) + + goto(Running) using Resources(sql, conn, exe) + + } catch { + case ex: Throwable => { + logger.error("Couldn't initialize services: " + ex.getMessage) + scheduleRetry() + stay using NoData + } + } + } + } + + when(Running) { + case Event(LostBrokerConnection(expected), res: Resources) => + res.exe.shutdown() + scheduleRetry() + goto(Down) using NoData + } + + override def preStart() { + self ! AttemptConnect + } + + onTermination { + case StopEvent(_, _, res: Resources) => + res.conn.disconnect() + res.exe.shutdown() + } + + private def scheduleRetry() { + context.system.scheduler.scheduleOnce( + Duration(3000, MILLISECONDS), + self, + AttemptConnect) + } + + initialize() +} + diff --git a/services/src/main/scala/io/greenbus/services/authz/AuthLookup.scala b/services/src/main/scala/io/greenbus/services/authz/AuthLookup.scala new file mode 100644 index 0000000..f3ab66e --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/authz/AuthLookup.scala @@ -0,0 +1,185 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.authz + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.client.exception.{ ForbiddenException, UnauthorizedException } +import io.greenbus.services.model.SquerylAuthModel +import io.greenbus.client.service.proto.Auth.{ EntitySelector, Permission, PermissionSet } +import com.google.protobuf.InvalidProtocolBufferException +import scala.collection.JavaConversions._ +import java.util.UUID + +trait AuthLookup { + def validateAuth(headers: Map[String, String]): AuthContext +} + +object DefaultAuthLookup extends AuthLookup with Logging { + + def validateAuth(headers: Map[String, String]): AuthContext = { + + val token = headers.getOrElse("AUTH_TOKEN", throw new UnauthorizedException("Must include AUTH_TOKEN header")) + + val (agentId, agentName, permBytes) = SquerylAuthModel.authLookup(token).getOrElse { + throw new UnauthorizedException("Invalid auth token") + } + + val permissions = try { + permBytes.map(PermissionSet.parseFrom) + } catch { + case protoEx: InvalidProtocolBufferException => + logger.error(s"Couldn't parse permission sets for agent '$agentName'") + throw new ForbiddenException("Invalid permission sets, see system administrator") + } + + new DefaultAuthContext(agentId, agentName, permissions) + } +} + +trait AuthContext { + def authorize(resource: String, action: String): Option[EntityFilter] + def authorizeUnconditionallyOnly(resource: String, action: String): Unit + def authorizeAndCheckSelfOnly(resource: String, action: String): Boolean + def agentName: String + def agentId: UUID +} + +class DefaultAuthContext(val agentId: UUID, val agentName: String, sets: Seq[PermissionSet]) extends AuthContext with Logging { + + private lazy val allPermissions: Seq[Permission] = sets.flatMap(_.getPermissionsList.toSeq) + + def authorizeAndCheckSelfOnly(resource: String, action: String): Boolean = { + + val applicable = allPermissions.filter { p => + p.getResourcesList.exists(r => r == "*" || r == resource) && + p.getActionsList.exists(a => a == "*" || a == action) + } + + // Deny all by default + if (applicable.isEmpty) { + throw new ForbiddenException(s"Insufficient permissions for $resource:$action") + } + val (allows, denies) = applicable.partition(_.getAllow) + + // Allow must be unconditional or 'self', deny is impossible because agent sets can't be defined other than self + if (denies.nonEmpty) { + throw new ForbiddenException(s"Insufficient permissions for $resource:$action") + } + + val blanketAllow = allows.exists(a => a.getSelectorsCount == 0 || a.getSelectorsList.forall(_.getStyle == "*")) + + if (blanketAllow) { + false + } else { + val selfSpecified = allows.forall(p => p.getSelectorsList.forall(s => s.getStyle == "self")) + + // If there is some other selector (and not blanket allow) we can't authorize this + if (selfSpecified) true else throw { new ForbiddenException(s"Insufficient permissions for $resource:$action") } + } + } + + def authorizeUnconditionallyOnly(resource: String, action: String): Unit = { + + val applicable = allPermissions.filter { p => + p.getResourcesList.exists(r => r == "*" || r == resource) && + p.getActionsList.exists(a => a == "*" || a == action) + } + + // Deny all by default + if (applicable.isEmpty) { + throw new ForbiddenException(s"Insufficient permissions for $resource:$action") + } + val (allows, denies) = applicable.partition(_.getAllow) + + // Allow must be unconditional or 'self', deny is impossible because agent sets can't be defined other than self + if (denies.nonEmpty) { + throw new ForbiddenException(s"Insufficient permissions for $resource:$action") + } + + val blanketAllow = allows.exists(a => a.getSelectorsCount == 0 || a.getSelectorsList.forall(_.getStyle == "*")) + + if (!blanketAllow) { + throw new ForbiddenException(s"Insufficient permissions for $resource:$action") + } + } + + def authorize(resource: String, action: String): Option[EntityFilter] = { + + val applicable = allPermissions.filter { p => + p.getResourcesList.exists(r => r == "*" || r == resource) && + p.getActionsList.exists(a => a == "*" || a == action) + } + + // Deny all by default + if (applicable.isEmpty) { + throw new ForbiddenException(s"Insufficient permissions for $resource:$action") + } + val (allows, denies) = applicable.partition(_.getAllow) + + // Blanket denial of this resource/action + if (denies.exists(p => p.getSelectorsCount == 0 || p.getSelectorsList.exists(_.getStyle == "*"))) { + throw new ForbiddenException(s"Insufficient permissions for $resource:$action") + } + + val blanketAllow = allows.forall(a => a.getSelectorsCount == 0 || a.getSelectorsList.forall(_.getStyle == "*")) + + // Blanket permission of this resource/action + if (denies.isEmpty && blanketAllow) { + None + } else { + // Resource-conditional permissions + val denySelectors = denies.flatMap(_.getSelectorsList).map(interpretSelector) + val allowSelectors = allows.flatMap(_.getSelectorsList).filterNot(_.getStyle == "*").map(interpretSelector) + + val filter = ResourceSelector.buildFilter(denySelectors, allowSelectors) + Some(filter) + } + } + + private def interpretSelector(selector: EntitySelector): ResourceSelector = { + + // Problems here are a data input problem with the auth system + def invalid(msg: String): Throwable = { + logger.error(msg) + new ForbiddenException("Invalid permissions sets, see system administrator") + } + + if (!selector.hasStyle) { + throw invalid(s"Permission set for '$agentName' has entity selector missing style") + } + selector.getStyle.trim match { + case "*" => throw new IllegalStateException("Should have filtered out '*' entity selectors") + case "type" => { + if (selector.getArgumentsCount == 0) { + throw invalid(s"Selector for '$agentName' is 'type' but has no arguments") + } + TypeSelector(selector.getArgumentsList.toSeq) + } + case "parent" => { + if (selector.getArgumentsCount == 0) { + throw invalid(s"Selector for '$agentName' is 'parent' but has no arguments") + } + ParentSelector(selector.getArgumentsList.toSeq) + } + case other => { + throw invalid(s"Unknown selector type for '$agentName': $other") + } + } + } +} \ No newline at end of file diff --git a/services/src/main/scala/io/greenbus/services/authz/ResourceSelector.scala b/services/src/main/scala/io/greenbus/services/authz/ResourceSelector.scala new file mode 100644 index 0000000..e0b7ddc --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/authz/ResourceSelector.scala @@ -0,0 +1,154 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.authz + +import java.util.UUID +import org.squeryl.dsl.ast.{ BinaryOperatorNodeLogicalBoolean, LogicalBoolean } +import io.greenbus.services.data.EntityRow +import org.squeryl.PrimitiveTypeMode._ +import io.greenbus.services.data.ServicesSchema._ +import org.squeryl.Query + +object EntityFilter { + def optional(filter: Option[EntityFilter], ent: EntityRow): LogicalBoolean = { + filter match { + case None => true === true + case Some(f) => f(ent.id) + } + } + def optional(filter: Option[EntityFilter], entId: UUID): LogicalBoolean = { + filter match { + case None => true === true + case Some(f) => f(entId) + } + } + def optional(filter: Option[EntityFilter], entId: Option[UUID]): LogicalBoolean = { + filter match { + case None => true === true + case Some(f) => f(entId) + } + } +} + +trait EntityFilter { + + protected def filterQuery: Query[EntityRow] + + def apply(id: UUID): LogicalBoolean = { + id in from(filterQuery)(ent => select(ent.id)) + } + def apply(optId: Option[UUID]): LogicalBoolean = { + optId in from(filterQuery)(ent => select(ent.id)) + } + + def filter(ids: Seq[UUID]): Seq[UUID] = { + from(entities)(ent => + where(ent.id in ids and not(apply(ent.id))) + select (ent.id)).toSeq + } + + def isOutsideSet(id: UUID): Boolean = { + from(entities)(ent => + where(ent.id === id and not(apply(ent.id))) + select (ent.id)).page(0, 1).nonEmpty + } + + def anyOutsideSet(ids: Seq[UUID]): Boolean = { + from(entities)(ent => + where(ent.id in ids and not(apply(ent.id))) + select (ent.id)).page(0, 1).nonEmpty + } + + def anyOutsideSet(ents: Query[EntityRow]): Boolean = { + from(ents)(ent => + where(not(apply(ent.id))) + select (ent.id)).page(0, 1).nonEmpty + } + +} + +sealed trait ResourceSelector { + def clause: EntityRow => LogicalBoolean +} + +object ResourceSelector { + + def buildFilter(denies: Seq[ResourceSelector], allows: Seq[ResourceSelector]): EntityFilter = { + val denyClauses = denies.map(_.clause) + val allowClauses = allows.map(_.clause) + + def allDenials(row: EntityRow): LogicalBoolean = { + denyClauses.map(f => f(row)).reduceLeft((a, b) => new BinaryOperatorNodeLogicalBoolean(a, b, "and")) + } + def allAllows(row: EntityRow): LogicalBoolean = { + allowClauses.map(f => f(row)).reduceLeft((a, b) => new BinaryOperatorNodeLogicalBoolean(a, b, "or")) + } + + new EntityFilter { + + protected def filterQuery: Query[EntityRow] = { + (allowClauses.nonEmpty, denyClauses.nonEmpty) match { + case (false, false) => entities.where(t => true === true) + + case (true, false) => + from(entities)(ent => + where(allAllows(ent)) + select (ent)) + + case (false, true) => + from(entities)(ent => + where(not(allDenials(ent))) + select (ent)) + + case (true, true) => + from(entities)(ent => + where(allAllows(ent) and not(allDenials(ent))) + select (ent)) + } + } + } + } + +} + +case class TypeSelector(types: Seq[String]) extends ResourceSelector { + + def clause: EntityRow => LogicalBoolean = { + row: EntityRow => + exists(from(entityTypes)(t => + where(t.entityId === row.id and + (t.entType in types)) + select (t.entityId))) + + } +} + +case class ParentSelector(parents: Seq[String]) extends ResourceSelector { + + def clause: EntityRow => LogicalBoolean = { + row: EntityRow => + exists(from(entities, edges)((parent, edge) => + where(parent.name in parents and + edge.parentId === parent.id and + edge.childId === row.id and + edge.relationship === "owns") + select (parent.id))) + } +} + diff --git a/services/src/main/scala/io/greenbus/services/core/AuthServices.scala b/services/src/main/scala/io/greenbus/services/core/AuthServices.scala new file mode 100644 index 0000000..fa0d37a --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/core/AuthServices.scala @@ -0,0 +1,312 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.core + +import java.util.UUID + +import io.greenbus.client.exception.{ ForbiddenException, BadRequestException } +import io.greenbus.client.proto.Envelope +import io.greenbus.client.service.AuthService.Descriptors +import io.greenbus.client.service.proto.Auth.Permission +import io.greenbus.client.service.proto.AuthRequests._ +import io.greenbus.services.framework.{ ServiceContext, Success, _ } +import io.greenbus.services.model.AuthModel +import io.greenbus.services.model.AuthModel.{ AgentInfo, CoreUntypedTemplate, PermissionSetInfo } +import io.greenbus.services.model.UUIDHelpers._ + +import scala.collection.JavaConversions._ + +object AuthServices { + + val defaultPageSize = 200 + + val agentResource = "agent" + val agentPasswordResource = "agent_password" + val permissionSetResource = "agent_permissions" + + def validatePermissionSet(template: PermissionSetTemplate) { + if (!template.hasName) { + throw new BadRequestException("Permission set must include name") + } + EntityServices.validateEntityName(template.getName) + + if (template.getPermissionsCount == 0) { + throw new BadRequestException("Permission set must include at least one permission") + } + + template.getPermissionsList.foreach(validatePermission) + } + + def validatePermission(perm: Permission) { + if (!perm.hasAllow) { + throw new BadRequestException("Permission must specify allow/deny") + } + if (perm.getResourcesCount == 0) { + throw new BadRequestException("Permission must include a resource; add '*' for all") + } + if (perm.getActionsCount == 0) { + throw new BadRequestException("Permission must include an action; add '*' for all") + } + + perm.getSelectorsList.foreach { selector => + if (!selector.hasStyle) { + throw new BadRequestException("Permission's entity selector must include style") + } + if ((selector.getStyle == "type" || selector.getStyle == "parent") && selector.getArgumentsCount == 0) { + throw new BadRequestException(s"Entity selectors of style ${selector.getStyle} must include arguments") + } + } + } +} + +class AuthServices(services: ServiceRegistry, authModel: AuthModel) { + import io.greenbus.services.core.AuthServices._ + + services.fullService(Descriptors.GetAgents, getAgents) + services.fullService(Descriptors.AgentQuery, agentQuery) + services.fullService(Descriptors.PutAgents, putAgents) + services.fullService(Descriptors.PutAgentPasswords, putAgentPasswords) + services.fullService(Descriptors.DeleteAgents, deleteAgents) + services.fullService(Descriptors.GetPermissionSets, getPermissionSets) + services.fullService(Descriptors.PermissionSetQuery, permissionSetQuery) + services.fullService(Descriptors.PutPermissionSets, putPermissionSets) + services.fullService(Descriptors.DeletePermissionSets, deletePermissionSets) + + def getAgents(request: GetAgentsRequest, headers: Map[String, String], context: ServiceContext): Response[GetAgentsResponse] = { + + val selfOnly = context.auth.authorizeAndCheckSelfOnly(agentResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val keySet = request.getRequest + + if (keySet.getUuidsCount == 0 && keySet.getNamesCount == 0) { + throw new BadRequestException("Must include at least one id or name") + } + + val uuids = keySet.getUuidsList.toSeq.map(protoUUIDToUuid) + val names = keySet.getNamesList.toSeq + + val results = if (selfOnly) { + if (uuids.contains(context.auth.agentId) || names.contains(context.auth.agentName)) { + authModel.agentKeyQuery(Seq(context.auth.agentId), Seq()) + } else { + Seq() + } + } else { + authModel.agentKeyQuery(uuids, names) + } + + val response = GetAgentsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def agentQuery(request: AgentQueryRequest, headers: Map[String, String], context: ServiceContext): Response[AgentQueryResponse] = { + + val selfOnly = context.auth.authorizeAndCheckSelfOnly(agentResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val query = request.getRequest + + val (pageByName, lastUuid, lastName, pageSize) = EntityServices.parsePageParams(query.hasPagingParams, query.getPagingParams) + + val permSetNames = query.getPermissionSetsList.toSeq + + val selfOpt = if (selfOnly) Some(context.auth.agentId) else None + + val results = authModel.agentQuery(selfOpt, permSetNames, lastUuid, lastName, pageSize, pageByName) + + val response = AgentQueryResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def putAgents(request: PutAgentsRequest, headers: Map[String, String], context: ServiceContext): Response[PutAgentsResponse] = { + + context.auth.authorizeUnconditionallyOnly(agentResource, "create") + context.auth.authorizeUnconditionallyOnly(agentResource, "update") + + if (request.getTemplatesCount == 0) { + throw new BadRequestException("Must include request content") + } + + val templates = request.getTemplatesList.toSeq + + val modelTemplates = templates.map { template => + if (!template.hasName) { + throw new BadRequestException("Must include agent name") + } + + EntityServices.validateEntityName(template.getName) + + val passwordOpt = if (template.hasPassword) Some(template.getPassword) else None + + val uuidOpt = if (template.hasUuid) Some(protoUUIDToUuid(template.getUuid)) else None + + CoreUntypedTemplate(uuidOpt, template.getName, AgentInfo(passwordOpt, template.getPermissionSetsList.toSeq)) + } + + val results = authModel.putAgents(context.notifier, modelTemplates, true) + + val response = PutAgentsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def putAgentPasswords(request: PutAgentPasswordsRequest, headers: Map[String, String], context: ServiceContext): Response[PutAgentPasswordsResponse] = { + + val selfOnly = context.auth.authorizeAndCheckSelfOnly(agentPasswordResource, "update") + + if (request.getUpdatesCount == 0) { + throw new BadRequestException("Must include request content") + } + + val updates = request.getUpdatesList.toSeq.map { update => + val uuid: UUID = if (update.hasUuid) update.getUuid else throw new BadRequestException("Must include UUID of Agent") + val password = if (update.hasPassword) update.getPassword else throw new BadRequestException("Must include new password") + + (uuid, password) + } + + if (selfOnly) { + updates match { + case Seq(update) => + if (update._1 != context.auth.agentId) { + throw new ForbiddenException("Insufficient permissions") + } + case _ => throw new ForbiddenException("Insufficient permissions") + } + } + + val results = authModel.modifyAgentPasswords(updates) + + val response = PutAgentPasswordsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def deleteAgents(request: DeleteAgentsRequest, headers: Map[String, String], context: ServiceContext): Response[DeleteAgentsResponse] = { + + context.auth.authorizeUnconditionallyOnly(agentResource, "delete") + + if (request.getAgentUuidsCount == 0) { + throw new BadRequestException("Must include ids to delete") + } + + val results = authModel.deleteAgents(context.notifier, request.getAgentUuidsList.map(protoUUIDToUuid)) + + val response = DeleteAgentsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def getPermissionSets(request: GetPermissionSetsRequest, headers: Map[String, String], context: ServiceContext): Response[GetPermissionSetsResponse] = { + + val selfOnly = context.auth.authorizeAndCheckSelfOnly(permissionSetResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val keySet = request.getRequest + + if (keySet.getIdsCount == 0 && keySet.getNamesCount == 0) { + throw new BadRequestException("Must include at least one id or name.") + } + + val uuids = keySet.getIdsList.toSeq.map(protoIdToLong) + val names = keySet.getNamesList.toSeq + + val results = if (selfOnly) { + authModel.permissionSetSelfKeyQuery(context.auth.agentId, uuids, names) + } else { + authModel.permissionSetKeyQuery(uuids, names) + } + + val response = GetPermissionSetsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def permissionSetQuery(request: PermissionSetQueryRequest, headers: Map[String, String], context: ServiceContext): Response[PermissionSetQueryResponse] = { + + val selfOnly = context.auth.authorizeAndCheckSelfOnly(permissionSetResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val query = request.getRequest + + val lastId = if (query.hasLastId) Some(protoIdToLong(query.getLastId)) else None + val pageSize = if (query.hasPageSize) query.getPageSize else defaultPageSize + + val results = if (selfOnly) { + authModel.permissionSetSelfQuery(context.auth.agentId, lastId, pageSize) + } else { + authModel.permissionSetQuery(lastId, pageSize) + } + + val response = PermissionSetQueryResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def putPermissionSets(request: PutPermissionSetsRequest, headers: Map[String, String], context: ServiceContext): Response[PutPermissionSetsResponse] = { + + context.auth.authorizeUnconditionallyOnly(permissionSetResource, "create") + context.auth.authorizeUnconditionallyOnly(permissionSetResource, "update") + + if (request.getTemplatesCount == 0) { + throw new BadRequestException("Must include request content") + } + + val templates = request.getTemplatesList.toSeq + + templates.foreach(validatePermissionSet) + + val modelTemplates = templates.map { template => + + val uuidOpt = if (template.hasId) Some(protoIdToLong(template.getId)) else None + + CoreUntypedTemplate(uuidOpt, template.getName, PermissionSetInfo(template.getPermissionsList.toSeq)) + } + + val results = authModel.putPermissionSets(context.notifier, modelTemplates, true) + + val response = PutPermissionSetsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def deletePermissionSets(request: DeletePermissionSetsRequest, headers: Map[String, String], context: ServiceContext): Response[DeletePermissionSetsResponse] = { + + context.auth.authorizeUnconditionallyOnly(permissionSetResource, "delete") + + if (request.getPermissionSetIdsCount == 0) { + throw new BadRequestException("Must include ids to delete") + } + + val uuids = request.getPermissionSetIdsList.map(protoIdToLong) + + val results = authModel.deletePermissionSets(context.notifier, uuids) + + val response = DeletePermissionSetsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + +} \ No newline at end of file diff --git a/services/src/main/scala/io/greenbus/services/core/CommandServices.scala b/services/src/main/scala/io/greenbus/services/core/CommandServices.scala new file mode 100644 index 0000000..71ecb8d --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/core/CommandServices.scala @@ -0,0 +1,251 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.core + +import java.util.concurrent.TimeoutException + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.Session +import io.greenbus.client.exception.{ BadRequestException, LockedException, ServiceException } +import io.greenbus.client.proto.Envelope +import io.greenbus.client.service.CommandService +import io.greenbus.client.service.CommandService.Descriptors +import io.greenbus.client.service.proto.CommandRequests._ +import io.greenbus.client.service.proto.Commands.{ CommandRequest, CommandResult, CommandStatus } +import io.greenbus.services.framework._ +import io.greenbus.services.model.EventAlarmModel.SysEventTemplate +import io.greenbus.services.model.UUIDHelpers._ +import io.greenbus.services.model._ + +import scala.collection.JavaConversions._ +import scala.concurrent.Future + +object CommandServices { + + val selectResource = "command_lock_select" + val blockResource = "command_lock_block" + val lockResource = "command_lock" + + val commandRequestResource = "user_command_request" + val commandRequestUneventedResource = "user_command_request_unevented" + + val defaultExpireTime = 30000 + + val defaultLockPageSize = 200 +} + +class CommandServices(services: ServiceRegistry, session: Session, trans: ServiceTransactionSource, commandModel: CommandModel, frontEndModel: FrontEndModel, eventModel: EventAlarmModel) extends Logging { + import io.greenbus.services.core.CommandServices._ + + services.simpleAsync(Descriptors.IssueCommandRequest, issueCommandRequest) + + services.fullService(Descriptors.GetCommandLocks, getCommandLocks) + services.fullService(Descriptors.CommandLockQuery, commandLockQuery) + services.fullService(Descriptors.SelectCommands, selectCommands) + services.fullService(Descriptors.BlockCommands, blockCommands) + services.fullService(Descriptors.DeleteCommandLocks, deleteCommandLocks) + + def issueCommandRequest(request: PostCommandRequestRequest, headers: Map[String, String], responseHandler: Response[PostCommandRequestResponse] => Unit) { + + trans.transaction(headers) { context => + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val cmdReq = request.getRequest + + val unevented = cmdReq.hasUnevented && cmdReq.getUnevented + + val filter = if (unevented) { + context.auth.authorize(commandRequestUneventedResource, "create") + } else { + context.auth.authorize(commandRequestResource, "create") + } + + if (!cmdReq.hasCommandUuid) { + throw new BadRequestException("Must include command") + } + + if (!commandModel.agentCanIssueCommand(cmdReq.getCommandUuid, context.auth.agentId, filter)) { + throw new LockedException("Command locked") + } + + val client = CommandService.client(session) + val resultOpt: Option[(Option[String], String)] = frontEndModel.addressForCommand(cmdReq.getCommandUuid) + + resultOpt match { + case None => throw new BadRequestException("Frontend serving command not found") + case Some((None, cmdName)) => Future.successful(CommandResult.newBuilder().setStatus(CommandStatus.TIMEOUT).build()) + case Some((Some(address), cmdName)) => + + val issued = client.issueCommandRequest(request.getRequest, address) + import scala.concurrent.ExecutionContext.Implicits.global + + issued.onSuccess { + case cmdResult => + val response = PostCommandRequestResponse.newBuilder().setResult(cmdResult).build + responseHandler(Success(Envelope.Status.OK, response)) + } + + issued.onFailure { + case ex: ServiceException => responseHandler(Failure(ex.getStatus, ex.getMessage)) + case ex: TimeoutException => + logger.warn("Response timeout from front end connection") + responseHandler(Failure(Envelope.Status.RESPONSE_TIMEOUT, "Response timeout from front end connection")) + case ex: Throwable => + logger.warn("Command service returned unexpected error: " + ex) + responseHandler(Failure(Envelope.Status.INTERNAL_ERROR, "Error issuing command request to front end connection")) + } + + if (!unevented) { + val event = (cmdReq.hasType && cmdReq.getType != CommandRequest.ValType.NONE) match { + case false => { + val attrs = EventAlarmModel.mapToAttributesList(Seq(("command", cmdName))) + SysEventTemplate(context.auth.agentName, EventSeeding.System.controlExe.eventType, Some("services"), None, None, None, attrs) + } + case true => { + val cmdValue = cmdReq.getType match { + case CommandRequest.ValType.DOUBLE => cmdReq.getDoubleVal.toString + case CommandRequest.ValType.INT => cmdReq.getIntVal.toString + case CommandRequest.ValType.STRING => cmdReq.getStringVal + case CommandRequest.ValType.NONE => "-" // should not happen + } + val attrs = EventAlarmModel.mapToAttributesList(Seq(("command", cmdName), ("value", cmdValue))) + SysEventTemplate(context.auth.agentName, EventSeeding.System.updatedSetpoint.eventType, Some("services"), None, None, None, attrs) + + } + } + + eventModel.postEvents(context.notifier, Seq(event)) + } + } + } + + } + + def getCommandLocks(request: GetCommandLockRequest, headers: Map[String, String], context: ServiceContext): Response[GetCommandLockResponse] = { + + val filter = context.auth.authorize(lockResource, "read") + + if (request.getLockIdsCount == 0) { + throw new BadRequestException("Must include one or more ids") + } + + val lockIds = request.getLockIdsList.toSeq.map(protoIdToLong) + + val results = commandModel.lockKeyQuery(lockIds, filter) + + val response = GetCommandLockResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def commandLockQuery(request: CommandLockQueryRequest, headers: Map[String, String], context: ServiceContext): Response[CommandLockQueryResponse] = { + + val filter = context.auth.authorize(lockResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val query = request.getRequest + + val cmds = query.getCommandUuidsList.toSeq map protoUUIDToUuid + + val agents = query.getAgentUuidsList.toSeq map protoUUIDToUuid + + val access = if (query.hasAccess) Some(query.getAccess) else None + + val lastId = if (query.hasLastId) Some(protoIdToLong(query.getLastId)) else None + + val results = commandModel.lockQuery(cmds, agents, access, lastId, defaultLockPageSize, filter) + + val response = CommandLockQueryResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def selectCommands(request: PostCommandSelectRequest, headers: Map[String, String], context: ServiceContext): Response[PostCommandSelectResponse] = { + + val filter = context.auth.authorize(selectResource, "create") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + if (request.getRequest.getCommandUuidsCount == 0) { + throw new BadRequestException("Must include one or more commands") + } + + val commands = request.getRequest.getCommandUuidsList.toSeq.map(protoUUIDToUuid) + val expiryDuration = if (request.getRequest.hasExpireDuration) request.getRequest.getExpireDuration else defaultExpireTime + val expireTime = System.currentTimeMillis() + expiryDuration + val agentId = context.auth.agentId + + val result = try { + commandModel.selectCommands(context.notifier, commands, agentId, expireTime, filter) + } catch { + case ex: CommandLockException => throw new LockedException(ex.getMessage) + } + + val response = PostCommandSelectResponse.newBuilder().setResult(result).build + Success(Envelope.Status.OK, response) + } + + def blockCommands(request: PostCommandBlockRequest, headers: Map[String, String], context: ServiceContext): Response[PostCommandBlockResponse] = { + + val filter = context.auth.authorize(blockResource, "create") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + if (request.getRequest.getCommandUuidsCount == 0) { + throw new BadRequestException("Must include one or more commands") + } + + val commands = request.getRequest.getCommandUuidsList.toSeq.map(protoUUIDToUuid) + val agentId = context.auth.agentId + + val result = try { + commandModel.blockCommands(context.notifier, commands, agentId, filter) + } catch { + case ex: CommandLockException => throw new LockedException(ex.getMessage) + } + + val response = PostCommandBlockResponse.newBuilder().setResult(result).build + Success(Envelope.Status.OK, response) + } + + def deleteCommandLocks(request: DeleteCommandLockRequest, headers: Map[String, String], context: ServiceContext): Response[DeleteCommandLockResponse] = { + + val selfOnly = context.auth.authorizeAndCheckSelfOnly(lockResource, "delete") + + if (request.getLockIdsCount == 0) { + throw new BadRequestException("Must include one or more ids") + } + + val lockIds = request.getLockIdsList.toSeq.map(protoIdToLong) + + val filter = if (selfOnly) Some(context.auth.agentId) else None + + val results = commandModel.deleteLocks(context.notifier, lockIds, filter) + + val response = DeleteCommandLockResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } +} diff --git a/services/src/main/scala/io/greenbus/services/core/EventAlarmServices.scala b/services/src/main/scala/io/greenbus/services/core/EventAlarmServices.scala new file mode 100644 index 0000000..8192e52 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/core/EventAlarmServices.scala @@ -0,0 +1,498 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.core + +import io.greenbus.client.exception.{ BadRequestException, ForbiddenException } +import io.greenbus.client.proto.Envelope +import io.greenbus.client.proto.Envelope.SubscriptionEventType +import io.greenbus.client.service.EventService.Descriptors +import io.greenbus.client.service.proto.EventRequests +import io.greenbus.client.service.proto.EventRequests._ +import io.greenbus.client.service.proto.Events.{ Alarm, AlarmNotification, Event, EventNotification } +import io.greenbus.services.authz.EntityFilter +import io.greenbus.services.framework.{ ServiceContext, Success, _ } +import io.greenbus.services.model.EventAlarmModel.{ EventQueryParams, SysEventTemplate } +import io.greenbus.services.model.UUIDHelpers._ +import io.greenbus.services.model.{ EntityModel, EventAlarmModel } + +import scala.collection.JavaConversions._ + +object EventAlarmServices { + + val eventConfigResource = "event_config" + val eventResource = "event" + val alarmResource = "alarm" + + val defaultEventConfigPageSize = 200 + val defaultEventPageSize = 200 +} + +class EventAlarmServices(services: ServiceRegistry, eventAlarmModel: EventAlarmModel, entityModel: EntityModel, eventBinding: SubscriptionChannelBinder, alarmBinding: SubscriptionChannelBinder) { + import io.greenbus.services.core.EventAlarmServices._ + + services.fullService(Descriptors.EventConfigQuery, eventConfigQuery) + services.fullService(Descriptors.GetEventConfigs, getEventConfigs) + services.fullService(Descriptors.PutEventConfigs, putEventConfigs) + services.fullService(Descriptors.DeleteEventConfigs, deleteEventConfigs) + + services.fullService(Descriptors.EventQuery, eventQuery) + services.fullService(Descriptors.GetEvents, getEvents) + services.fullService(Descriptors.PostEvents, postEvents) + + services.fullService(Descriptors.AlarmQuery, alarmQuery) + services.fullService(Descriptors.PutAlarmState, putAlarmStates) + + services.fullService(Descriptors.SubscribeToEvents, subscribeToEvents) + services.fullService(Descriptors.SubscribeToAlarms, subscribeToAlarms) + + def eventConfigQuery(request: EventConfigQueryRequest, headers: Map[String, String], context: ServiceContext): Response[EventConfigQueryResponse] = { + + val filter = context.auth.authorize(eventConfigResource, "read") + if (filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions on " + eventConfigResource) + } + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val query = request.getRequest + + val last = if (query.hasLastEventType) Some(query.getLastEventType) else None + val pageSize = if (query.hasPageSize) query.getPageSize else defaultEventConfigPageSize + + val alarmStates = query.getAlarmStateList.toSeq + val designations = query.getDesignationList.toSeq + val severities = query.getSeverityList.map(_.toInt).toSeq + val severityOrHigher = if (query.hasSeverityOrHigher) Some(query.getSeverityOrHigher) else None + + val results = eventAlarmModel.eventConfigQuery(severities, severityOrHigher, designations, alarmStates, last, pageSize) + + val response = EventConfigQueryResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def getEventConfigs(request: GetEventConfigRequest, headers: Map[String, String], context: ServiceContext): Response[GetEventConfigResponse] = { + + val filter = context.auth.authorize(eventConfigResource, "read") + if (filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions on " + eventConfigResource) + } + + if (request.getEventTypeCount == 0) { + throw new BadRequestException("Must include at least one event type") + } + + val eventTypeList = request.getEventTypeList.toSeq + + val results = eventAlarmModel.getEventConfigs(eventTypeList) + + val response = GetEventConfigResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def putEventConfigs(request: PutEventConfigRequest, headers: Map[String, String], context: ServiceContext): Response[PutEventConfigResponse] = { + + val createFilter = context.auth.authorize(eventConfigResource, "create") + if (createFilter.nonEmpty) { + throw new ForbiddenException("Must have blanket create permissions on " + eventConfigResource) + } + val updateFilter = context.auth.authorize(eventConfigResource, "update") + if (updateFilter.nonEmpty) { + throw new ForbiddenException("Must have blanket update permissions on " + eventConfigResource) + } + + if (request.getRequestCount == 0) { + throw new BadRequestException("Must include request content") + } + + val configs = request.getRequestList.toSeq.map { proto => + if (!proto.hasEventType) { + throw new BadRequestException("All event configs must include event type") + } + if (!proto.hasSeverity) { + throw new BadRequestException("All event configs must include severity") + } + if (!proto.hasDesignation) { + throw new BadRequestException("All event configs must include designation") + } + if (!proto.hasResource) { + throw new BadRequestException("All event configs must include resource") + } + + val alarmState = if (proto.hasAlarmState) proto.getAlarmState else Alarm.State.UNACK_SILENT + + EventAlarmModel.EventConfigTemp(proto.getEventType, proto.getSeverity, proto.getDesignation, alarmState, proto.getResource) + } + + val results = eventAlarmModel.putEventConfigs(context.notifier, configs) + + val response = PutEventConfigResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def deleteEventConfigs(request: DeleteEventConfigRequest, headers: Map[String, String], context: ServiceContext): Response[DeleteEventConfigResponse] = { + + val filter = context.auth.authorize(eventConfigResource, "delete") + if (filter.nonEmpty) { + throw new ForbiddenException("Must have blanket delete permissions on " + eventConfigResource) + } + + if (request.getEventTypeCount == 0) { + throw new BadRequestException("Must include at least one event type") + } + + val eventTypeList = request.getEventTypeList.toSeq + + val results = eventAlarmModel.deleteEventConfigs(context.notifier, eventTypeList) + + val response = DeleteEventConfigResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def getEvents(request: GetEventsRequest, headers: Map[String, String], context: ServiceContext): Response[GetEventsResponse] = { + + val filter = context.auth.authorize(eventResource, "read") + + if (request.getEventIdCount == 0) { + throw new BadRequestException("Must include at least one event id") + } + + val eventIds = request.getEventIdList.toSeq.map(protoIdToLong) + + val results = eventAlarmModel.getEvents(eventIds, filter) + + val response = GetEventsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def postEvents(request: PostEventsRequest, headers: Map[String, String], context: ServiceContext): Response[PostEventsResponse] = { + + val createFilter = context.auth.authorize(eventResource, "create") + if (createFilter.nonEmpty) { + throw new ForbiddenException("Must have blanket create permissions on " + eventResource) + } + + if (request.getRequestCount == 0) { + throw new BadRequestException("Must include request content") + } + + val protoTemplates = request.getRequestList.toSeq + + val templates = protoTemplates.map { et => + val eventType = if (et.hasEventType) { + et.getEventType + } else { + throw new BadRequestException("Must include event type") + } + + val subsystem = if (et.hasSubsystem) Some(et.getSubsystem) else None + val deviceTime = if (et.hasDeviceTime) Some(et.getDeviceTime) else None + + val entityUuid = if (et.hasEntityUuid) Some(et.getEntityUuid) else None + + val modelGroup = if (et.hasModelGroup) Some(et.getModelGroup) else None + + val args = et.getArgsList.toSeq + + SysEventTemplate(context.auth.agentName, eventType, subsystem, deviceTime, entityUuid, modelGroup, args) + } + + val results = eventAlarmModel.postEvents(context.notifier, templates) + + val response = PostEventsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + private def parseEventQuery(query: EventRequests.EventQueryParams, lastId: Option[Long], pageSize: Int): EventQueryParams = { + val eventTypes = query.getEventTypeList.toSeq + val timeFrom = if (query.hasTimeFrom) Some(query.getTimeFrom) else None + val timeTo = if (query.hasTimeTo) Some(query.getTimeTo) else None + val severities = query.getSeverityList.toSeq.map(_.toInt) + val severityOrHigher = if (query.hasSeverityOrHigher) Some(query.getSeverityOrHigher) else None + val subsystems = query.getSubsystemList.toSeq + val agents = query.getAgentList.toSeq + + val modelGroups = query.getModelGroupList.toSeq + + val isAlarm = if (query.hasIsAlarm) Some(query.getIsAlarm) else None + + val (entityUuids, entityNames) = if (query.hasEntities) { + (query.getEntities.getUuidsList.toSeq.map(protoUUIDToUuid), query.getEntities.getNamesList.toSeq) + } else { + (Nil, Nil) + } + + val latest = if (query.hasLatest) query.getLatest else true + + EventQueryParams(eventTypes, timeFrom, timeTo, severities, severityOrHigher, subsystems, agents, entityUuids, entityNames, modelGroups, isAlarm, latest, lastId, pageSize) + } + + def eventQuery(request: EventQueryRequest, headers: Map[String, String], context: ServiceContext): Response[EventQueryResponse] = { + + val filter = context.auth.authorize(eventResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val query = request.getRequest + + val lastId = if (query.hasLastId) Some(query.getLastId.getValue.toLong) else None + + val limit = if (query.hasPageSize) query.getPageSize else defaultEventPageSize + + val params = if (query.hasQueryParams) parseEventQuery(query.getQueryParams, lastId, limit) else EventQueryParams(last = lastId, pageSize = limit) + + val results = eventAlarmModel.eventQuery(params, filter) + + val response = EventQueryResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + private def parseEventSubscriptionQuery(query: EventSubscriptionQuery, filter: Option[EntityFilter]): EventQueryParams = { + + val timeFrom = if (query.hasTimeFrom) Some(query.getTimeFrom) else None + val limit = if (query.hasLimit) query.getLimit else defaultEventPageSize + + val eventTypes = query.getEventTypeList.toSeq + val severities = query.getSeverityList.toSeq.map(_.toInt) + val subsystem = query.getSubsystemList.toSeq + val agents = query.getAgentList.toSeq + + val (entityUuids, entityNames) = if (query.hasEntities) { + (query.getEntities.getUuidsList.toSeq.map(protoUUIDToUuid), query.getEntities.getNamesList.toSeq) + } else { + (Nil, Nil) + } + + val lookingForEntities = entityUuids.nonEmpty || entityNames.nonEmpty + + if (!lookingForEntities && filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions to subscribe without specifying entities") + } + + val entUuids = if (lookingForEntities) { + entityModel.keyQueryFilter(entityUuids, entityNames, filter) + } else { + Nil + } + + if (lookingForEntities && entUuids.isEmpty) { + throw new BadRequestException("Entities to subscribe to not found") + } + + EventQueryParams(eventTypes = eventTypes, timeFrom = timeFrom, severities = severities, subsystems = subsystem, agents = agents, entityUuids = entUuids, pageSize = limit, latest = true) + } + + def subscribeToEvents(request: SubscribeEventsRequest, headers: Map[String, String], context: ServiceContext): Response[SubscribeEventsResponse] = { + + val filter = context.auth.authorize(eventResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val queue = headers.get("__subscription").getOrElse { + throw new BadRequestException("Must include subscription queue in headers") + } + + val query = request.getRequest + + val timeFrom = if (query.hasTimeFrom) Some(query.getTimeFrom) else None + val limit = if (query.hasLimit) query.getLimit else defaultEventPageSize + + val hasParams = query.getEventTypeCount != 0 || + query.getSeverityCount != 0 || + query.getSubsystemCount != 0 || + query.getAgentCount != 0 || + (query.hasEntities && (query.getEntities.getUuidsCount != 0 || query.getEntities.getNamesCount != 0)) + + val results = if (!hasParams) { + + if (filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions to subscribe without specifying entities") + } + + eventBinding.bindAll(queue) + + eventAlarmModel.eventQuery(EventQueryParams(timeFrom = timeFrom, pageSize = limit, latest = true)) + + } else { + + val params = parseEventSubscriptionQuery(query, filter) + + eventBinding.bindEach(queue, Seq(params.eventTypes, params.severities.map(_.toString), params.subsystems, params.agents, params.entityUuids.map(_.toString))) + + eventAlarmModel.eventQuery(params) + } + + val response = SubscribeEventsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def alarmQuery(request: AlarmQueryRequest, headers: Map[String, String], context: ServiceContext): Response[AlarmQueryResponse] = { + + val filter = context.auth.authorize(alarmResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val query = request.getRequest + + val lastId = if (query.hasLastId) Some(query.getLastId.getValue.toLong) else None + + val limit = if (query.hasPageSize) query.getPageSize else defaultEventPageSize + + val params = if (query.hasEventQueryParams) parseEventQuery(query.getEventQueryParams, lastId, limit) else EventQueryParams(last = lastId, pageSize = limit) + + val alarmStates = query.getAlarmStatesList.toSeq + + val results = eventAlarmModel.alarmQuery(alarmStates, params, filter) + + val response = AlarmQueryResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def subscribeToAlarms(request: SubscribeAlarmsRequest, headers: Map[String, String], context: ServiceContext): Response[SubscribeAlarmsResponse] = { + + val filter = context.auth.authorize(alarmResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val queue = headers.get("__subscription").getOrElse { + throw new BadRequestException("Must include subscription queue in headers") + } + + val query = request.getRequest + + val states = query.getAlarmStatesList.toSeq + + val results = if (query.hasEventQuery) { + val eventQuery = query.getEventQuery + + val hasEventParams = + eventQuery.getEventTypeCount != 0 || + eventQuery.getSeverityCount != 0 || + eventQuery.getSubsystemCount != 0 || + eventQuery.getAgentCount != 0 || + (eventQuery.hasEntities && (eventQuery.getEntities.getUuidsCount != 0 || eventQuery.getEntities.getNamesCount != 0)) + + val hasParams = states.nonEmpty || hasEventParams + + if (!hasParams) { + + if (filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions to subscribe without specifying entities") + } + + alarmBinding.bindAll(queue) + + eventAlarmModel.alarmQuery(states, parseEventSubscriptionQuery(eventQuery, None)) + + } else { + + if (!hasEventParams) { + throw new BadRequestException("Must include request parameters if not subscribing to all") + } + + val params = parseEventSubscriptionQuery(eventQuery, filter) + + alarmBinding.bindEach(queue, Seq(states.map(_.toString), params.eventTypes, params.severities.map(_.toString), params.subsystems, params.agents, params.entityUuids.map(_.toString))) + + eventAlarmModel.alarmQuery(states, params) + } + + } else { + + if (states.isEmpty) { + throw new BadRequestException("Must include request parameters when not subscribing to all") + } + + if (filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions to subscribe without specifying entities") + } + + alarmBinding.bindEach(queue, Seq(states.map(_.toString), Nil, Nil, Nil, Nil, Nil)) + + eventAlarmModel.alarmQuery(states, EventQueryParams(pageSize = defaultEventPageSize)) + } + + val response = SubscribeAlarmsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def putAlarmStates(request: PutAlarmStateRequest, headers: Map[String, String], context: ServiceContext): Response[PutAlarmStateResponse] = { + + val updateFilter = context.auth.authorize(alarmResource, "update") + if (updateFilter.nonEmpty) { + throw new ForbiddenException("Must have blanket create permissions on " + eventResource) + } + + if (request.getRequestCount == 0) { + throw new BadRequestException("Must include request content") + } + + val updates = request.getRequestList.toSeq.map { update => + if (!update.hasAlarmId) { + throw new BadRequestException("Must include alarm ID") + } + if (!update.hasAlarmState) { + throw new BadRequestException("Must include alarm state") + } + (update.getAlarmId.getValue.toLong, update.getAlarmState) + } + + val results = eventAlarmModel.putAlarmStates(context.notifier, updates) + + val response = PutAlarmStateResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + +} + +object EventSubscriptionDescriptor extends SubscriptionDescriptor[Event] { + def keyParts(payload: Event): Seq[String] = { + Seq(payload.getEventType, payload.getSeverity.toString, payload.getSubsystem, payload.getAgentName, payload.getEntityUuid.getValue) + } + + def notification(eventType: SubscriptionEventType, payload: Event): Array[Byte] = { + EventNotification.newBuilder + .setValue(payload) + .build() + .toByteArray + } +} + +object AlarmSubscriptionDescriptor extends SubscriptionDescriptor[Alarm] { + def keyParts(payload: Alarm): Seq[String] = { + Seq(payload.getState.toString, payload.getEvent.getEventType, payload.getEvent.getSeverity.toString, payload.getEvent.getSubsystem, payload.getEvent.getAgentName, payload.getEvent.getEntityUuid.getValue) + } + + def notification(eventType: SubscriptionEventType, payload: Alarm): Array[Byte] = { + AlarmNotification.newBuilder + .setValue(payload) + .setEventType(eventType) + .build() + .toByteArray + } +} diff --git a/services/src/main/scala/io/greenbus/services/core/FrontEndServices.scala b/services/src/main/scala/io/greenbus/services/core/FrontEndServices.scala new file mode 100644 index 0000000..937baa0 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/core/FrontEndServices.scala @@ -0,0 +1,176 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.core + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.amqp.AmqpServiceOperations +import io.greenbus.client.exception.{ BadRequestException, ForbiddenException } +import io.greenbus.client.proto.Envelope +import io.greenbus.client.proto.Envelope.SubscriptionEventType +import io.greenbus.client.service.FrontEndService +import io.greenbus.client.service.proto.FrontEnd._ +import io.greenbus.client.service.proto.FrontEndRequests._ +import io.greenbus.client.service.proto.Model._ +import io.greenbus.services.framework.{ ServiceContext, Success, _ } +import io.greenbus.services.model.FrontEndModel +import io.greenbus.services.model.UUIDHelpers._ + +import scala.collection.JavaConversions._ + +object FrontEndServices { + + val frontEndRegistrationResource = "frontEndRegistration" + val frontEndConnectionStatusResource = "frontEndConnectionStatus" + + val defaultPageSize = 200 +} + +class FrontEndServices( + services: ServiceRegistry, + ops: AmqpServiceOperations, + frontEndModel: FrontEndModel, + frontEndConnectionStatusBinding: SubscriptionChannelBinder) extends Logging { + import io.greenbus.services.core.FrontEndServices._ + + services.fullService(FrontEndService.Descriptors.GetFrontEndConnectionStatuses, getFrontEndConnectionStatuses) + services.fullService(FrontEndService.Descriptors.SubscribeToFrontEndConnectionStatuses, subscribeFrontEndConnectionStatuses) + + def getFrontEndConnectionStatuses(request: GetFrontEndConnectionStatusRequest, headers: Map[String, String], context: ServiceContext): Response[GetFrontEndConnectionStatusResponse] = { + + val filter = context.auth.authorize(frontEndConnectionStatusResource, "read") + + if (!request.hasEndpoints) { + throw new BadRequestException("Must include request content") + } + + val keySet = request.getEndpoints + + if (keySet.getUuidsCount == 0 && keySet.getNamesCount == 0) { + throw new BadRequestException("Must include at least one id or name") + } + + val uuids = keySet.getUuidsList.toSeq.map(protoUUIDToUuid) + val names = keySet.getNamesList.toSeq + + val results = frontEndModel.getFrontEndConnectionStatuses(uuids, names, filter) + + val response = GetFrontEndConnectionStatusResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def subscribeFrontEndConnectionStatuses(request: SubscribeFrontEndConnectionStatusRequest, headers: Map[String, String], context: ServiceContext): Response[SubscribeFrontEndConnectionStatusResponse] = { + + val filter = context.auth.authorize(frontEndConnectionStatusResource, "read") + + if (!request.hasEndpoints) { + throw new BadRequestException("Must include request content") + } + + val queue = headers.get("__subscription").getOrElse { + throw new BadRequestException("Must include subscription queue in headers") + } + + val keySet = request.getEndpoints + + val results = if (keySet.getUuidsCount != 0 || keySet.getNamesCount != 0) { + + val uuids = keySet.getUuidsList.toSeq.map(protoUUIDToUuid) + val names = keySet.getNamesList.toSeq + + val endpoints = frontEndModel.endpointKeyQuery(uuids, names, filter) + + val getResults = frontEndModel.getFrontEndConnectionStatuses(uuids, names, filter) + + val filteredUuids = endpoints.map(_.getUuid) + + frontEndConnectionStatusBinding.bindEach(queue, Seq(filteredUuids.map(_.getValue))) + + getResults + + } else { + + if (filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions on entities when subscribing to all") + } + + frontEndConnectionStatusBinding.bindAll(queue) + + Nil + } + + val response = SubscribeFrontEndConnectionStatusResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } +} + +object EndpointSubscriptionDescriptor extends SubscriptionDescriptor[Endpoint] { + def keyParts(payload: Endpoint): Seq[String] = { + Seq(payload.getUuid.getValue, payload.getName, payload.getProtocol) + } + + def notification(eventType: SubscriptionEventType, payload: Endpoint): Array[Byte] = { + EndpointNotification.newBuilder + .setValue(payload) + .setEventType(eventType) + .build() + .toByteArray + } +} +object PointSubscriptionDescriptor extends SubscriptionDescriptor[Point] { + def keyParts(payload: Point): Seq[String] = { + val endpointUuid = if (payload.hasEndpointUuid) payload.getEndpointUuid.getValue else "" + Seq(endpointUuid, payload.getUuid.getValue, payload.getName) + } + + def notification(eventType: SubscriptionEventType, payload: Point): Array[Byte] = { + PointNotification.newBuilder + .setValue(payload) + .setEventType(eventType) + .build() + .toByteArray + } +} +object CommandSubscriptionDescriptor extends SubscriptionDescriptor[Command] { + def keyParts(payload: Command): Seq[String] = { + val endpointUuid = if (payload.hasEndpointUuid) payload.getEndpointUuid.getValue else "" + Seq(endpointUuid, payload.getUuid.getValue, payload.getName) + } + + def notification(eventType: SubscriptionEventType, payload: Command): Array[Byte] = { + CommandNotification.newBuilder + .setValue(payload) + .setEventType(eventType) + .build() + .toByteArray + } +} + +object FrontEndConnectionStatusSubscriptionDescriptor extends SubscriptionDescriptor[FrontEndConnectionStatus] { + def keyParts(payload: FrontEndConnectionStatus): Seq[String] = { + Seq(payload.getEndpointUuid.getValue) + } + + def notification(eventType: SubscriptionEventType, payload: FrontEndConnectionStatus): Array[Byte] = { + FrontEndConnectionStatusNotification.newBuilder + .setValue(payload) + .setEventType(eventType) + .build() + .toByteArray + } +} diff --git a/services/src/main/scala/io/greenbus/services/core/LoginServices.scala b/services/src/main/scala/io/greenbus/services/core/LoginServices.scala new file mode 100644 index 0000000..4ebabed --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/core/LoginServices.scala @@ -0,0 +1,253 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.core + +import java.util.UUID +import java.util.concurrent.TimeoutException + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.services.{ AsyncAuthenticationModule, SqlAuthenticationModule, AuthenticationModule } +import io.greenbus.services.framework._ +import io.greenbus.client.service.proto.LoginRequests._ +import io.greenbus.client.exception.{ UnauthorizedException, BadRequestException } +import io.greenbus.client.proto.Envelope +import io.greenbus.services.model.EventAlarmModel.SysEventTemplate +import io.greenbus.sql.DbConnection +import io.greenbus.services.model.{ UUIDHelpers, EventSeeding, EventAlarmModel, AuthModel } +import io.greenbus.client.version.Version + +import scala.concurrent.ExecutionContext.Implicits.global + +object LoginServices { + val defaultExpirationLength = 18144000000L // one month + +} + +class LoginServices(services: ServiceRegistry, sql: DbConnection, authenticator: AuthenticationModule, authModel: AuthModel, eventModel: EventAlarmModel, modelNotifier: ModelNotifier) extends Logging { + import LoginServices._ + import io.greenbus.client.service.LoginService.Descriptors + + //services.simpleSync(Descriptors.Login, login) + services.simpleAsync(Descriptors.Login, loginAsync) + services.simpleSync(Descriptors.Logout, logout) + services.simpleSync(Descriptors.Validate, validateAuthToken) + + def loginAsync(request: PostLoginRequest, headers: Map[String, String], responseHandler: Response[PostLoginResponse] => Unit): Unit = { + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val login = request.getRequest + + if (!login.hasName) { + throw new BadRequestException("Must include user name.") + } + if (!login.hasPassword) { + throw new BadRequestException("Must include password.") + } + + val currentTime = System.currentTimeMillis() + val expiration = if (login.hasExpirationTime) { + if (login.getExpirationTime < currentTime) { + throw new BadRequestException("Expiration time cannot be in the past") + } + login.getExpirationTime + } else { + currentTime + defaultExpirationLength + } + + val clientVersion = if (login.hasClientVersion) login.getClientVersion else "unknown" + val location = if (login.hasLoginLocation) login.getLoginLocation else "unknown" + + val error = "Invalid username or password" + + def enterAuthorization(agentId: UUID): Response[PostLoginResponse] = { + val token = authModel.createTokenAndStore(agentId, expiration, clientVersion, location) + + val attrs = EventAlarmModel.mapToAttributesList(Seq(("user", login.getName))) + val event = SysEventTemplate(login.getName, EventSeeding.System.userLogin.eventType, Some("auth"), None, Some(UUIDHelpers.uuidToProtoUUID(agentId)), None, attrs) + eventModel.postEvents(modelNotifier, Seq(event)) + + val resp = LoginResponse.newBuilder + .setToken(token) + .setExpirationTime(expiration) + .setServerVersion(Version.clientVersion) + .build() + + Success(Envelope.Status.OK, PostLoginResponse.newBuilder().setResponse(resp).build()) + } + + def postFailureEvent(): Unit = { + val attrs = EventAlarmModel.mapToAttributesList(Seq(("reason", error))) + val event = SysEventTemplate(login.getName, EventSeeding.System.userLoginFailure.eventType, Some("auth"), None, None, None, attrs) + eventModel.postEvents(modelNotifier, Seq(event)) + } + + authenticator match { + + case sqlModule: SqlAuthenticationModule => { + + val response = sql.transaction { + val (authenticated, uuidOpt) = sqlModule.authenticate(login.getName, login.getPassword) + (authenticated, uuidOpt) match { + case (true, Some(uuid)) => enterAuthorization(uuid) + case _ => + postFailureEvent() + Failure(Envelope.Status.UNAUTHORIZED, error) + } + } + responseHandler(response) + } + + case asyncModule: AsyncAuthenticationModule => { + + val authFut = asyncModule.authenticate(login.getName, login.getPassword) + + authFut.onFailure { + case ex: TimeoutException => + logger.warn("Response timeout from authentication service") + sql.transaction { + val attrs = EventAlarmModel.mapToAttributesList(Seq(("reason", "Authentication service timeout"))) + val event = SysEventTemplate(login.getName, EventSeeding.System.userLoginFailure.eventType, Some("auth"), None, None, None, attrs) + eventModel.postEvents(modelNotifier, Seq(event)) + } + responseHandler(Failure(Envelope.Status.RESPONSE_TIMEOUT, "Response timeout from front end connection")) + case ex: Throwable => + logger.warn("Authentication service returned unexpected error: " + ex) + sql.transaction { + postFailureEvent() + } + responseHandler(Failure(Envelope.Status.UNAUTHORIZED, error)) + } + + authFut.onSuccess { + case true => + val response = sql.transaction { + authModel.agentIdForName(login.getName) match { + case None => + postFailureEvent() + Failure(Envelope.Status.UNAUTHORIZED, error) + case Some(agentId) => + enterAuthorization(agentId) + } + } + responseHandler(response) + + case false => + sql.transaction { + postFailureEvent() + } + responseHandler(Failure(Envelope.Status.UNAUTHORIZED, error)) + } + } + } + + } + + def login(request: PostLoginRequest, headers: Map[String, String]): Response[PostLoginResponse] = { + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val login = request.getRequest + + if (!login.hasName) { + throw new BadRequestException("Must include user name.") + } + if (!login.hasPassword) { + throw new BadRequestException("Must include password.") + } + + val currentTime = System.currentTimeMillis() + val expiration = if (login.hasExpirationTime) { + if (login.getExpirationTime < currentTime) { + throw new BadRequestException("Expiration time cannot be in the past") + } + login.getExpirationTime + } else { + currentTime + defaultExpirationLength + } + + val clientVersion = if (login.hasClientVersion) login.getClientVersion else "unknown" + val location = if (login.hasLoginLocation) login.getLoginLocation else "unknown" + + sql.transaction { + val result = authModel.simpleLogin(login.getName, login.getPassword, expiration, clientVersion, location) + result match { + case Left(error) => + + val attrs = EventAlarmModel.mapToAttributesList(Seq(("reason", error))) + val event = SysEventTemplate(login.getName, EventSeeding.System.userLoginFailure.eventType, Some("auth"), None, None, None, attrs) + eventModel.postEvents(modelNotifier, Seq(event)) + + throw new UnauthorizedException(error) + case Right((token, uuid)) => { + + val attrs = EventAlarmModel.mapToAttributesList(Seq(("user", login.getName))) + val event = SysEventTemplate(login.getName, EventSeeding.System.userLogin.eventType, Some("auth"), None, Some(uuid), None, attrs) + eventModel.postEvents(modelNotifier, Seq(event)) + + val resp = LoginResponse.newBuilder + .setToken(token) + .setExpirationTime(expiration) + .setServerVersion(Version.clientVersion) + .build() + + Success(Envelope.Status.OK, PostLoginResponse.newBuilder().setResponse(resp).build()) + } + } + } + } + + def logout(request: PostLogoutRequest, headers: Map[String, String]): Response[PostLogoutResponse] = { + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val tokenRequest = request.getRequest + if (!tokenRequest.hasToken) { + throw new BadRequestException("Must include auth token to be logged out") + } + + sql.transaction { + authModel.simpleLogout(tokenRequest.getToken) + val event = SysEventTemplate("-", EventSeeding.System.userLogout.eventType, Some("auth"), None, None, None, Seq()) + eventModel.postEvents(modelNotifier, Seq(event)) + } + + Success(Envelope.Status.OK, PostLogoutResponse.newBuilder().build()) + } + + def validateAuthToken(request: ValidateAuthTokenRequest, headers: Map[String, String]): Response[ValidateAuthTokenResponse] = { + if (!request.hasToken) { + throw new BadRequestException("Must include request content") + } + + val result = sql.transaction { + authModel.authValidate(request.getToken) + } + + result match { + case false => Failure(Envelope.Status.UNAUTHORIZED, "Invalid auth token") + case true => Success(Envelope.Status.OK, ValidateAuthTokenResponse.newBuilder().build()) + } + } +} diff --git a/services/src/main/scala/io/greenbus/services/core/MeasurementServices.scala b/services/src/main/scala/io/greenbus/services/core/MeasurementServices.scala new file mode 100644 index 0000000..885f81a --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/core/MeasurementServices.scala @@ -0,0 +1,191 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.core + +import io.greenbus.services.framework._ +import io.greenbus.client.service.proto.MeasurementRequests._ +import io.greenbus.client.service.MeasurementService.Descriptors +import io.greenbus.client.exception.{ ForbiddenException, BadRequestException } +import io.greenbus.services.model.UUIDHelpers._ +import scala.collection.JavaConversions._ +import io.greenbus.client.proto.Envelope +import io.greenbus.mstore.{ MeasurementHistorySource, MeasurementValueSource } +import io.greenbus.client.service.proto.Measurements.{ PointMeasurementValues, PointMeasurementValue } +import io.greenbus.services.framework.ServiceContext +import io.greenbus.services.framework.Success +import java.util.UUID +import io.greenbus.services.model.FrontEndModel + +object MeasurementServices { + + val measurementResource = "measurement" + + val defaultHistoryLimit = 200 +} + +class MeasurementServices(services: ServiceRegistry, store: MeasurementValueSource, history: MeasurementHistorySource, measChannel: SubscriptionChannelBinder, measBatchChannel: SubscriptionChannelBinder, frontEndModel: FrontEndModel) { + import MeasurementServices._ + + services.fullService(Descriptors.GetCurrentValues, getCurrentValue) + services.fullService(Descriptors.GetCurrentValuesAndSubscribe, subscribeWithCurrentValue) + services.fullService(Descriptors.GetHistory, getHistory) + services.fullService(Descriptors.SubscribeToBatches, subscribeToBatches) + + def getCurrentValue(request: GetCurrentValuesRequest, headers: Map[String, String], context: ServiceContext): Response[GetCurrentValuesResponse] = { + + val optFilter = context.auth.authorize(measurementResource, "read") + + if (request.getPointUuidsCount == 0) { + throw new BadRequestException("Must include at least one point uuid") + } + + val uuids = request.getPointUuidsList.toSeq.map(protoUUIDToUuid) + + val filteredUuids = optFilter match { + case None => uuids + case Some(filter) => filter.filter(uuids) + } + + val results = store.get(filteredUuids) + + val protoResults = results.map { + case (id, meas) => + PointMeasurementValue.newBuilder + .setPointUuid(uuidToProtoUUID(id)) + .setValue(meas) + .build + } + + val response = GetCurrentValuesResponse.newBuilder().addAllResults(protoResults).build + Success(Envelope.Status.OK, response) + } + + def subscribeWithCurrentValue(request: GetCurrentValuesAndSubscribeRequest, headers: Map[String, String], context: ServiceContext): Response[GetCurrentValuesAndSubscribeResponse] = { + + val optFilter = context.auth.authorize(measurementResource, "read") + + val queue = headers.get("__subscription").getOrElse { + throw new BadRequestException("Must include subscription queue in headers") + } + + val protoResults = if (request.getPointUuidsCount == 0) { + + if (optFilter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions when subscribing to all") + } + + measChannel.bindAll(queue) + + Seq() + + } else { + + val uuids = request.getPointUuidsList.toSeq.map(protoUUIDToUuid) + + val filteredUuids = optFilter match { + case None => uuids + case Some(filter) => filter.filter(uuids) + } + + val results = store.get(filteredUuids) + + val protoResults = results.map { + case (id, meas) => + PointMeasurementValue.newBuilder + .setPointUuid(uuidToProtoUUID(id)) + .setValue(meas) + .build + } + + if (filteredUuids.nonEmpty) { + measChannel.bindEach(queue, Seq(filteredUuids.map(_.toString), Nil)) + } + + protoResults + } + + val response = GetCurrentValuesAndSubscribeResponse.newBuilder().addAllResults(protoResults).build + Success(Envelope.Status.OK, response) + } + + def getHistory(request: GetMeasurementHistoryRequest, headers: Map[String, String], context: ServiceContext): Response[GetMeasurementHistoryResponse] = { + + val optFilter = context.auth.authorize(measurementResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request query") + } + + val query = request.getRequest + + if (!query.hasPointUuid) { + throw new BadRequestException("Must include point uuid") + } + + val pointId: UUID = query.getPointUuid + + if (!frontEndModel.pointExists(pointId, optFilter)) { + throw new BadRequestException("No point exists for uuid") + } + + val windowStart = if (query.hasTimeFrom) Some(query.getTimeFrom) else None + val windowEnd = if (query.hasTimeTo) Some(query.getTimeTo) else None + val limit = if (query.hasLimit) query.getLimit else defaultHistoryLimit + val latest = if (query.hasLatest) query.getLatest else false + + val results = history.getHistory(pointId, windowStart, windowEnd, limit, latest) + + val result = PointMeasurementValues.newBuilder() + .setPointUuid(query.getPointUuid) + .addAllValue(results) + .build() + + val response = GetMeasurementHistoryResponse.newBuilder().setResult(result).build + Success(Envelope.Status.OK, response) + } + + def subscribeToBatches(request: SubscribeToBatchesRequest, headers: Map[String, String], context: ServiceContext): Response[SubscribeToBatchesResponse] = { + + val optFilter = context.auth.authorize(measurementResource, "read") + + if (optFilter.nonEmpty) { + throw new ForbiddenException(s"Must have blanked read permissions for $measurementResource to subscribe to batches") + } + + val queue = headers.get("__subscription").getOrElse { + throw new BadRequestException("Must include subscription queue in headers") + } + + if (!request.hasQuery) { + throw new BadRequestException("Must include request query") + } + + val endpointUuids = request.getQuery.getEndpointUuidsList.toSeq + + if (endpointUuids.nonEmpty) { + measBatchChannel.bindEach(queue, Seq(endpointUuids.map(_.getValue))) + } else { + measBatchChannel.bindAll(queue) + } + + val response = SubscribeToBatchesResponse.newBuilder().build + Success(Envelope.Status.OK, response) + } + +} diff --git a/services/src/main/scala/io/greenbus/services/core/ModelServices.scala b/services/src/main/scala/io/greenbus/services/core/ModelServices.scala new file mode 100644 index 0000000..d6b3eec --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/core/ModelServices.scala @@ -0,0 +1,1286 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.core + +import java.util.UUID + +import io.greenbus.client.exception.{ BadRequestException, ForbiddenException } +import io.greenbus.client.proto.Envelope +import io.greenbus.client.proto.Envelope.SubscriptionEventType +import io.greenbus.client.service.proto.Model._ +import io.greenbus.client.service.proto.ModelRequests._ +import io.greenbus.services.framework._ +import io.greenbus.services.model.EntityModel.TypeParams +import io.greenbus.services.model.EventAlarmModel.SysEventTemplate +import io.greenbus.services.model.FrontEndModel.{ CommandInfo, EndpointInfo, PointInfo } +import io.greenbus.services.model.UUIDHelpers._ +import io.greenbus.services.model._ + +import scala.collection.JavaConversions._ + +object EntityServices { + + val entityResource = "entity" + val edgeResource = "entity_edge" + + val keyValueResource = "entity_key_value" + + val pointResource = "point" + val commandResource = "command" + val endpointResource = "endpoint" + + val defaultPageSize = 200 + + def validateEntityName(name: String) { + name.foreach { + case ' ' => throw new BadRequestException("Entity names must not include spaces.") + case '*' => throw new BadRequestException("Entity names must not include banned character '*'.") + case _ => + } + } + + def parsePageParams(hasPagingParams: => Boolean, getPagingParams: => EntityPagingParams): (Boolean, Option[UUID], Option[String], Int) = { + if (hasPagingParams) { + val pageByName = !getPagingParams.hasPageByName || getPagingParams.getPageByName + val lastUuid = if (getPagingParams.hasLastUuid) Some(protoUUIDToUuid(getPagingParams.getLastUuid)) else None + val lastName = if (getPagingParams.hasLastName) Some(getPagingParams.getLastName) else None + val pageSize = if (getPagingParams.hasPageSize) getPagingParams.getPageSize else defaultPageSize + (pageByName, lastUuid, lastName, pageSize) + } else { + (true, None, None, defaultPageSize) + } + } +} + +class EntityServices(services: ServiceRegistry, + entityModel: EntityModel, + frontEndModel: FrontEndModel, + eventModel: EventAlarmModel, + entityBinding: SubscriptionChannelBinder, + keyValueBinding: SubscriptionChannelBinder, + edgeBinding: SubscriptionChannelBinder, + endpointBinding: SubscriptionChannelBinder, + pointBinding: SubscriptionChannelBinder, + commandBinding: SubscriptionChannelBinder) { + + import io.greenbus.client.service.ModelService.Descriptors + import io.greenbus.services.core.EntityServices._ + + services.fullService(Descriptors.Get, getEntities) + services.fullService(Descriptors.EntityQuery, entityQuery) + services.fullService(Descriptors.Subscribe, subscribeToEntities) + services.fullService(Descriptors.RelationshipFlatQuery, relationshipFlatQuery) + services.fullService(Descriptors.Put, putEntities) + services.fullService(Descriptors.Delete, deleteEntities) + + services.fullService(Descriptors.EdgeQuery, edgeQuery) + services.fullService(Descriptors.PutEdges, putEntityEdges) + services.fullService(Descriptors.DeleteEdges, deleteEntityEdges) + services.fullService(Descriptors.SubscribeToEdges, subscribeToEntityEdges) + + services.fullService(Descriptors.GetEntityKeyValues, getEntityKeyValues) + services.fullService(Descriptors.GetEntityKeys, getEntityKeys) + services.fullService(Descriptors.PutEntityKeyValues, putEntityKeyValues) + services.fullService(Descriptors.DeleteEntityKeyValues, deleteEntityKeyValues) + services.fullService(Descriptors.SubscribeToEntityKeyValues, subscribeToEntityKeyValues) + + services.fullService(Descriptors.GetPoints, getPoints) + services.fullService(Descriptors.PointQuery, pointQuery) + services.fullService(Descriptors.PutPoints, putPoints) + services.fullService(Descriptors.DeletePoints, deletePoints) + services.fullService(Descriptors.SubscribeToPoints, subscribeToPoints) + services.fullService(Descriptors.GetCommands, getCommands) + services.fullService(Descriptors.CommandQuery, commandQuery) + services.fullService(Descriptors.PutCommands, putCommands) + services.fullService(Descriptors.DeleteCommands, deleteCommands) + services.fullService(Descriptors.SubscribeToCommands, subscribeToCommands) + services.fullService(Descriptors.GetEndpoints, getEndpoints) + services.fullService(Descriptors.EndpointQuery, endpointQuery) + services.fullService(Descriptors.PutEndpoints, putEndpoints) + services.fullService(Descriptors.PutEndpointDisabled, putEndpointsDisabled) + services.fullService(Descriptors.DeleteEndpoints, deleteEndpoints) + services.fullService(Descriptors.SubscribeToEndpoints, subscribeToEndpoints) + + def getEntities(request: GetEntitiesRequest, headers: Map[String, String], context: ServiceContext): Response[GetEntitiesResponse] = { + val filter = context.auth.authorize(entityResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content.") + } + + val keySet = request.getRequest + + if (keySet.getUuidsCount == 0 && keySet.getNamesCount == 0) { + throw new BadRequestException("Must include at least one id or name.") + } + + val uuids = keySet.getUuidsList.toSeq.map(protoUUIDToUuid) + val names = keySet.getNamesList.toSeq + + val results = entityModel.keyQuery(uuids, names, filter) + + val response = GetEntitiesResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def entityQuery(request: EntityQueryRequest, headers: Map[String, String], context: ServiceContext): Response[EntityQueryResponse] = { + val filter = context.auth.authorize(entityResource, "read") + + if (!request.hasQuery) { + throw new BadRequestException("Must include request content.") + } + + val query = request.getQuery + + val typeParams = if (query.hasTypeParams) { + TypeParams( + query.getTypeParams.getIncludeTypesList.toSeq, + query.getTypeParams.getMatchTypesList.toSeq, + query.getTypeParams.getFilterOutTypesList.toSeq) + } else { + TypeParams(Nil, Nil, Nil) + } + + val (pageByName, lastUuid, lastName, pageSize) = parsePageParams(query.hasPagingParams, query.getPagingParams) + + val results = entityModel.fullQuery(typeParams, lastUuid, lastName, pageSize, pageByName, filter) + + val response = EntityQueryResponse.newBuilder().addAllResults(results).build + + Success(Envelope.Status.OK, response) + } + + def subscribeToEntities(request: SubscribeEntitiesRequest, headers: Map[String, String], context: ServiceContext): Response[SubscribeEntitiesResponse] = { + val filter = context.auth.authorize(entityResource, "read") + + if (!request.hasQuery) { + throw new BadRequestException("Must include request content") + } + + val query = request.getQuery + + val queue = headers.get("__subscription").getOrElse { + throw new BadRequestException("Must include subscription queue in headers") + } + + val immediateResults = if (query.getUuidsCount == 0 && query.getNamesCount == 0) { + + if (filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions on entities when subscribing to all entities") + } + + entityBinding.bindAll(queue) + Nil + + } else { + + val names = query.getNamesList.toSeq + val uuids = query.getUuidsList.toSeq.map(protoUUIDToUuid) + + val results = entityModel.keyQuery(uuids, names, filter) + + val filteredUuids = results.map(_.getUuid.getValue) + + if (filteredUuids.nonEmpty) { + entityBinding.bindEach(queue, Seq(filteredUuids)) + } + results + } + + val resp = SubscribeEntitiesResponse.newBuilder().addAllResults(immediateResults).build() + + Success(Envelope.Status.OK, resp) + } + + def relationshipFlatQuery(request: EntityRelationshipFlatQueryRequest, headers: Map[String, String], context: ServiceContext): Response[EntityRelationshipFlatQueryResponse] = { + + val filter = context.auth.authorize(edgeResource, "read") + + val query = request.getQuery + val options = Seq( + query.getStartUuidsCount > 0, + query.getStartNamesCount > 0, + query.getStartTypesCount > 0).filter(b => b) + + if (options.size == 0) { + throw new BadRequestException("Must include one of ids, names, or types to search for.") + } + if (options.size > 1) { + throw new BadRequestException("Must include only one of ids, names, or types to search for.") + } + + if (!query.hasRelationship) { + throw new BadRequestException("Must include relationship.") + } + if (!query.hasDescendantOf) { + throw new BadRequestException("Must include direction.") + } + + val descendantTypes = query.getEndTypesList.toSeq + val depthLimit = if (query.hasDepthLimit) Some(query.getDepthLimit) else None + + val (pageByName, lastUuid, lastName, pageSize) = parsePageParams(query.hasPagingParams, query.getPagingParams) + + val entities = if (query.getStartUuidsCount > 0) { + entityModel.idsRelationFlatQuery( + query.getStartUuidsList.map(protoUUIDToUuid).toSeq, + query.getRelationship, + query.getDescendantOf, + descendantTypes, + depthLimit, + lastUuid, + lastName, + pageSize, + pageByName, + filter) + } else if (query.getStartNamesCount > 0) { + entityModel.namesRelationFlatQuery( + query.getStartNamesList.toSeq, + query.getRelationship, + query.getDescendantOf, + descendantTypes, + depthLimit, + lastUuid, + lastName, + pageSize, + pageByName, + filter) + } else { + entityModel.typesRelationFlatQuery( + query.getStartTypesList.toSeq, + query.getRelationship, + query.getDescendantOf, + descendantTypes, + depthLimit, + lastUuid, + lastName, + pageSize, + pageByName, + filter) + } + + val response = EntityRelationshipFlatQueryResponse.newBuilder().addAllResults(entities).build + + Success(Envelope.Status.OK, response) + + } + + def putEntities(request: PutEntitiesRequest, headers: Map[String, String], context: ServiceContext): Response[PutEntitiesResponse] = { + + val createFilter = context.auth.authorize(entityResource, "create") + val updateFilter = context.auth.authorize(entityResource, "update") + + if (request.getEntitiesCount == 0) { + throw new BadRequestException("Must include request content") + } + + val templates = request.getEntitiesList.toSeq + + if (!templates.forall(_.hasName)) { + throw new BadRequestException("Must include entity name.") + } + + templates.filter(_.hasName).map(_.getName).foreach(validateEntityName) + + val (withIds, withNames) = templates.partition(_.hasUuid) + + val withIdsSimple = withIds.map { ent => + (protoUUIDToUuid(ent.getUuid), ent.getName, ent.getTypesList.toSet) + } + + val withNamesSimple = withNames.map { ent => + (ent.getName, ent.getTypesList.toSet) + } + + val allowCreates = createFilter.isEmpty + + val entities = try { + entityModel.putEntities(context.notifier, withIdsSimple, withNamesSimple, allowCreates, updateFilter) + } catch { + case ex: ModelPermissionException => + throw new ForbiddenException(ex.getMessage) + case inEx: ModelInputException => + throw new BadRequestException(inEx.getMessage) + } + + Success(Envelope.Status.OK, PutEntitiesResponse.newBuilder().addAllResults(entities).build()) + } + + def deleteEntities(request: DeleteEntitiesRequest, headers: Map[String, String], context: ServiceContext): Response[DeleteEntitiesResponse] = { + + val filter = context.auth.authorize(entityResource, "delete") + + if (request.getEntityUuidsCount == 0) { + throw new BadRequestException("Must include at least one id to delete.") + } + + val ids = request.getEntityUuidsList map protoUUIDToUuid + + val results = try { + entityModel.deleteEntities(context.notifier, ids, filter) + } catch { + case ex: ModelPermissionException => { + throw new ForbiddenException(ex.getMessage) + } + } + + Success(Envelope.Status.OK, DeleteEntitiesResponse.newBuilder().addAllResults(results).build()) + } + + def getEntityKeyValues(request: GetEntityKeyValuesRequest, headers: Map[String, String], context: ServiceContext): Response[GetEntityKeyValuesResponse] = { + val filter = context.auth.authorize(keyValueResource, "read") + + if (request.getRequestCount == 0) { + throw new BadRequestException("Must include request content") + } + + request.getRequestList.foreach { kvp => + if (!kvp.hasUuid) { + throw new BadRequestException("Must include UUID") + } + if (!kvp.hasKey) { + throw new BadRequestException("Must include key") + } + } + + val idAndKeys = request.getRequestList.map(kvp => (protoUUIDToUuid(kvp.getUuid), kvp.getKey)) + + val results = entityModel.getKeyValues2(idAndKeys, filter) + + Success(Envelope.Status.OK, GetEntityKeyValuesResponse.newBuilder().addAllResults(results).build()) + } + + def getEntityKeys(request: GetKeysForEntitiesRequest, headers: Map[String, String], context: ServiceContext): Response[GetKeysForEntitiesResponse] = { + val filter = context.auth.authorize(keyValueResource, "read") + + if (request.getRequestCount == 0) { + throw new BadRequestException("Must include request content") + } + + val uuids = request.getRequestList.map(protoUUIDToUuid) + + val results = entityModel.getKeys(uuids, filter) + + Success(Envelope.Status.OK, GetKeysForEntitiesResponse.newBuilder().addAllResults(results).build()) + } + + def subscribeToEntityKeyValues(request: SubscribeEntityKeyValuesRequest, headers: Map[String, String], context: ServiceContext): Response[SubscribeEntityKeyValuesResponse] = { + + val filter = context.auth.authorize(keyValueResource, "read") + + if (!request.hasQuery) { + throw new BadRequestException("Must include request content") + } + + val queue = headers.getOrElse("__subscription", throw new BadRequestException("Must include subscription queue in headers")) + + val query = request.getQuery + + val hasKeys = query.getUuidsCount != 0 || query.getKeyPairsCount != 0 + val hasParams = hasKeys || query.getEndpointUuidsCount != 0 + + val results = if (!hasParams) { + + if (filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions on entities when subscribing to all") + } + + keyValueBinding.bindAll(queue) + + Seq() + + } else if (query.getEndpointUuidsCount != 0) { + + if (hasKeys) { + throw new BadRequestException("Must not include key parameters while subscribing by endpoint") + } + if (filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions when subscribing to abstract sets") + } + + val endpointUuids = query.getEndpointUuidsList.toSeq + + keyValueBinding.bindEach(queue, Seq(Nil, Nil, endpointUuids.map(_.getValue))) + + Seq() + + } else { + + val uuidSubKeys = query.getUuidsList.toSeq + + val uuidAndKeys = query.getKeyPairsList.toSeq.map { kv => + if (!kv.hasUuid) { + throw new BadRequestException("Key pairs must include UUID") + } + if (!kv.hasKey) { + throw new BadRequestException("Key pairs must include key") + } + + (protoUUIDToUuid(kv.getUuid), kv.getKey) + } + + val uuidAndKeyResults = if (uuidAndKeys.nonEmpty) entityModel.getKeyValues2(uuidAndKeys) else Seq() + + val uuidResults = if (uuidSubKeys.nonEmpty) entityModel.getKeyValuesForUuids(uuidSubKeys.map(protoUUIDToUuid)) else Seq() + + uuidAndKeyResults.foreach { kv => + keyValueBinding.bindTogether(queue, Seq(Seq(Some(kv.getUuid.getValue), Some(kv.getKey), None))) + } + + if (uuidResults.nonEmpty) { + keyValueBinding.bindEach(queue, Seq(uuidResults.map(uuid => uuid.getUuid.getValue), Seq(), Seq())) + } + + uuidResults ++ uuidAndKeyResults + } + + val response = SubscribeEntityKeyValuesResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def putEntityKeyValues(request: PutEntityKeyValuesRequest, headers: Map[String, String], context: ServiceContext): Response[PutEntityKeyValuesResponse] = { + + val createFilter = context.auth.authorize(keyValueResource, "create") + val updateFilter = context.auth.authorize(keyValueResource, "update") + + if (request.getKeyValuesCount == 0) { + throw new BadRequestException("Must include request content") + } + + val templates = request.getKeyValuesList.toSeq + + templates.foreach { temp => + if (!temp.hasUuid) { + throw new BadRequestException("Must include UUID") + } + if (!temp.hasKey) { + throw new BadRequestException("Must include key") + } + if (!temp.hasValue) { + throw new BadRequestException("Must include value") + } + } + + val set = templates.map(kv => (protoUUIDToUuid(kv.getUuid), kv.getKey, kv.getValue)) + + val allowCreates = createFilter.isEmpty + + val results = entityModel.putKeyValues2(context.notifier, set, allowCreates, updateFilter) + + Success(Envelope.Status.OK, PutEntityKeyValuesResponse.newBuilder().addAllResults(results).build()) + } + + def deleteEntityKeyValues(request: DeleteEntityKeyValuesRequest, headers: Map[String, String], context: ServiceContext): Response[DeleteEntityKeyValuesResponse] = { + + val filter = context.auth.authorize(keyValueResource, "delete") + + if (request.getRequestCount == 0) { + throw new BadRequestException("Must include at least one id/key to delete.") + } + + request.getRequestList.foreach { kvp => + if (!kvp.hasUuid) { + throw new BadRequestException("Must include UUID") + } + if (!kvp.hasKey) { + throw new BadRequestException("Must include key") + } + } + + val idAndKeys = request.getRequestList.map(kvp => (protoUUIDToUuid(kvp.getUuid), kvp.getKey)) + + val results = entityModel.deleteKeyValues2(context.notifier, idAndKeys, filter) + + Success(Envelope.Status.OK, DeleteEntityKeyValuesResponse.newBuilder().addAllResults(results).build()) + } + + def edgeQuery(request: EntityEdgeQueryRequest, headers: Map[String, String], context: ServiceContext): Response[EntityEdgeQueryResponse] = { + + val filter = context.auth.authorize(edgeResource, "read") + + if (!request.hasQuery) { + throw new BadRequestException("Must include request content") + } + + val query = request.getQuery + + val parents = query.getParentUuidsList.map(protoUUIDToUuid) + val children = query.getChildUuidsList.map(protoUUIDToUuid) + val relations = query.getRelationshipsList.toSeq + val depthLimit = if (query.hasDepthLimit) Some(query.getDepthLimit) else None + + val lastId = if (query.hasLastId) Some(idToLong(query.getLastId)) else None + val pageSize = if (query.hasPageSize) query.getPageSize else defaultPageSize + + val results = entityModel.edgeQuery(parents, relations, children, depthLimit, lastId, pageSize, filter) + + Success(Envelope.Status.OK, EntityEdgeQueryResponse.newBuilder().addAllResults(results).build()) + } + + def subscribeToEntityEdges(request: SubscribeEntityEdgesRequest, headers: Map[String, String], context: ServiceContext): Response[SubscribeEntityEdgesResponse] = { + val filter = context.auth.authorize(edgeResource, "read") + + if (!request.hasQuery) { + throw new BadRequestException("Must include request content") + } + + val query = request.getQuery + + val queue = headers.get("__subscription").getOrElse { + throw new BadRequestException("Must include subscription queue in headers") + } + + if (query.getFiltersCount == 0) { + + if (filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions when subscribing to all") + } + + edgeBinding.bindAll(queue) + + } else { + + val params = query.getFiltersList.toSeq.map { filt => + + val optParent = if (filt.hasParentUuid) Some(protoUUIDToUuid(filt.getParentUuid)) else None + val optChild = if (filt.hasChildUuid) Some(protoUUIDToUuid(filt.getChildUuid)) else None + + if (optParent.isEmpty && optChild.isEmpty && filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions when subscribing without specifying parent or child") + } + + val optRelation = if (filt.hasRelationship) Some(filt.getRelationship) else None + val optDistance = if (filt.hasDistance) Some(filt.getDistance) else None + + (optParent, optChild, optRelation, optDistance) + } + + val filtered = filter match { + case Some(filt) => + val allParents = params.flatMap(_._1) + val allChildren = params.flatMap(_._2) + + val allEnts = allParents ++ allChildren + + val allowedSet = filt.filter(allEnts).toSet + + val allEntsSet = allEnts.toSet + + val entsFilteredOut = allEntsSet &~ allowedSet + + if (entsFilteredOut.nonEmpty) { + throw new ForbiddenException("Missing permissions for entities in edge request") + } + + params + + case None => + params + } + + val keyParams = filtered.map { + case (optParent, optChild, optRelation, optDistance) => List( + optParent.map(_.getValue), + optChild.map(_.getValue), + optRelation, + optDistance.map(_.toString)) + } + + edgeBinding.bindTogether(queue, keyParams) + } + + val resp = SubscribeEntityEdgesResponse.newBuilder().build() + + Success(Envelope.Status.OK, resp) + } + + private def idToLong(id: ModelID): Long = { + if (!id.hasValue) { + throw new BadRequestException("ModelID was missing a value") + } + try { + id.getValue.toLong + } catch { + case ex: NumberFormatException => + throw new BadRequestException("ModelID was an invalid format") + } + } + + def putEntityEdges(request: PutEntityEdgesRequest, headers: Map[String, String], context: ServiceContext): Response[PutEntityEdgesResponse] = { + + // TODO: Find all affected entities in one query and check them + val edgeFilter = context.auth.authorize(edgeResource, "create") + if (edgeFilter.nonEmpty) { + throw new ForbiddenException(s"Must have blanket create permissions for '$edgeResource'") + } + + if (request.getDescriptorsCount == 0) { + throw new BadRequestException("Must include at least one descriptor.") + } + + val templates = request.getDescriptorsList.toSeq + + templates.foreach { temp => + if (!temp.hasParentUuid) { + throw new BadRequestException("Entity edge must have a parent.") + } + if (!temp.hasChildUuid) { + throw new BadRequestException("Entity edge must include a child.") + } + if (!temp.hasRelationship) { + throw new BadRequestException("Entity edge must include relationship.") + } + } + + val grouped: Seq[(String, Seq[(UUID, UUID)])] = templates.groupBy(temp => temp.getRelationship).mapValues(_.map(temp => (protoUUIDToUuid(temp.getParentUuid), protoUUIDToUuid(temp.getChildUuid)))).toSeq + + val results = grouped.flatMap { + case (relation, pairs) => entityModel.putEdges(context.notifier, pairs, relation) + } + + Success(Envelope.Status.OK, PutEntityEdgesResponse.newBuilder().addAllResults(results).build()) + } + + def deleteEntityEdges(request: DeleteEntityEdgesRequest, headers: Map[String, String], context: ServiceContext): Response[DeleteEntityEdgesResponse] = { + + // TODO: Find all affected entities in one query and check them + val edgeFilter = context.auth.authorize(edgeResource, "delete") + if (edgeFilter.nonEmpty) { + throw new ForbiddenException(s"Must have blanket delete permissions for '$edgeResource'") + } + + if (request.getDescriptorsCount == 0) { + throw new BadRequestException("Must include at least one descriptor.") + } + + val templates = request.getDescriptorsList.toSeq + + templates.foreach { temp => + if (!temp.hasParentUuid) { + throw new BadRequestException("Entity edge must have a parent.") + } + if (!temp.hasChildUuid) { + throw new BadRequestException("Entity edge must include at least one child.") + } + if (!temp.hasRelationship) { + throw new BadRequestException("Entity edge must include relationship.") + } + } + + val grouped: Seq[(String, Seq[(UUID, UUID)])] = templates.groupBy(temp => temp.getRelationship).mapValues(_.map(temp => (protoUUIDToUuid(temp.getParentUuid), protoUUIDToUuid(temp.getChildUuid)))).toSeq + + val results = grouped.flatMap { + case (relation, pairs) => entityModel.deleteEdges(context.notifier, pairs, relation) + } + + Success(Envelope.Status.OK, DeleteEntityEdgesResponse.newBuilder().addAllResults(results).build()) + } + + def getPoints(request: GetPointsRequest, headers: Map[String, String], context: ServiceContext): Response[GetPointsResponse] = { + + val filter = context.auth.authorize(pointResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val keySet = request.getRequest + + if (keySet.getUuidsCount == 0 && keySet.getNamesCount == 0) { + throw new BadRequestException("Must include at least one id or name") + } + + val uuids = keySet.getUuidsList.toSeq.map(protoUUIDToUuid) + val names = keySet.getNamesList.toSeq + + val results = frontEndModel.pointKeyQuery(uuids, names, filter) + + val response = GetPointsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def pointQuery(request: PointQueryRequest, headers: Map[String, String], context: ServiceContext): Response[PointQueryResponse] = { + + val filter = context.auth.authorize(pointResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val query = request.getRequest + + val (pageByName, lastUuid, lastName, pageSize) = parsePageParams(query.hasPagingParams, query.getPagingParams) + + val typeParams = if (query.hasTypeParams) { + TypeParams( + query.getTypeParams.getIncludeTypesList.toSeq, + query.getTypeParams.getMatchTypesList.toSeq, + query.getTypeParams.getFilterOutTypesList.toSeq) + } else { + TypeParams(Nil, Nil, Nil) + } + + val pointCategories = query.getPointCategoriesList.toSeq + val units = query.getUnitsList.toSeq + + val results = frontEndModel.pointQuery(pointCategories, units, typeParams, lastUuid, lastName, pageSize, pageByName, filter) + + val response = PointQueryResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def putPoints(request: PutPointsRequest, headers: Map[String, String], context: ServiceContext): Response[PutPointsResponse] = { + + val createFilter = context.auth.authorize(pointResource, "create") + val allowCreate = createFilter.isEmpty + val updateFilter = context.auth.authorize(pointResource, "update") + + if (request.getTemplatesCount == 0) { + throw new BadRequestException("Must include request content") + } + + val templates = request.getTemplatesList.toSeq + + val modelTemplates = templates.map { template => + + if (!template.hasEntityTemplate) { + throw new BadRequestException("Must include entity template") + } + + if (!template.getEntityTemplate.hasName) { + throw new BadRequestException("Must include point name") + } + EntityServices.validateEntityName(template.getEntityTemplate.getName) + + if (!template.hasPointCategory) { + throw new BadRequestException("Must include point type") + } + if (!template.hasUnit) { + throw new BadRequestException("Must include point unit") + } + + val uuidOpt = if (template.getEntityTemplate.hasUuid) Some(protoUUIDToUuid(template.getEntityTemplate.getUuid)) else None + + FrontEndModel.CoreTypeTemplate(uuidOpt, template.getEntityTemplate.getName, template.getEntityTemplate.getTypesList.toSet, PointInfo(template.getPointCategory, template.getUnit)) + } + + val results = frontEndModel.putPoints(context.notifier, modelTemplates, allowCreate, updateFilter) + + val response = PutPointsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def deletePoints(request: DeletePointsRequest, headers: Map[String, String], context: ServiceContext): Response[DeletePointsResponse] = { + + val filter = context.auth.authorize(pointResource, "delete") + + if (request.getPointUuidsCount == 0) { + throw new BadRequestException("Must include point ids to delete") + } + + val uuids = request.getPointUuidsList.map(protoUUIDToUuid) + + val results = frontEndModel.deletePoints(context.notifier, uuids, filter) + + val response = DeletePointsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def subscribeToPoints(request: SubscribePointsRequest, headers: Map[String, String], context: ServiceContext): Response[SubscribePointsResponse] = { + + val filter = context.auth.authorize(pointResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val queue = headers.get("__subscription").getOrElse { + throw new BadRequestException("Must include subscription queue in headers") + } + + val query = request.getRequest + + val hasKeys = query.getUuidsCount != 0 || query.getNamesCount != 0 + val hasParams = hasKeys || query.getEndpointUuidsCount != 0 + + val results = if (!hasParams) { + + if (filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions when subscribing to all") + } + + pointBinding.bindAll(queue) + + Nil + + } else if (query.getEndpointUuidsCount != 0 && !hasKeys) { + + if (filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions when subscribing to all") + } + + val endpointUuids = query.getEndpointUuidsList.toSeq + + pointBinding.bindEach(queue, Seq(endpointUuids.map(_.getValue), Nil, Nil)) + + Nil + + } else if (hasKeys && query.getEndpointUuidsCount == 0) { + + val uuids = query.getUuidsList.toSeq + val names = query.getNamesList.toSeq + + val results = frontEndModel.pointKeyQuery(uuids map protoUUIDToUuid, names, filter) + + val filteredUuids = results.map(_.getUuid.getValue) + + if (filteredUuids.nonEmpty) { + pointBinding.bindEach(queue, Seq(Nil, filteredUuids, Nil)) + } + + results + + } else { + + throw new BadRequestException("Must not include key parameters while subscribing by endpoint") + } + + val response = SubscribePointsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def getCommands(request: GetCommandsRequest, headers: Map[String, String], context: ServiceContext): Response[GetCommandsResponse] = { + + val filter = context.auth.authorize(commandResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val keySet = request.getRequest + + if (keySet.getUuidsCount == 0 && keySet.getNamesCount == 0) { + throw new BadRequestException("Must include at least one id or name") + } + + val uuids = keySet.getUuidsList.toSeq.map(protoUUIDToUuid) + val names = keySet.getNamesList.toSeq + + val results = frontEndModel.commandKeyQuery(uuids, names, filter) + + val response = GetCommandsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def commandQuery(request: CommandQueryRequest, headers: Map[String, String], context: ServiceContext): Response[CommandQueryResponse] = { + + val filter = context.auth.authorize(commandResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val query = request.getRequest + + val (pageByName, lastUuid, lastName, pageSize) = parsePageParams(query.hasPagingParams, query.getPagingParams) + + val typeParams = if (query.hasTypeParams) { + TypeParams( + query.getTypeParams.getIncludeTypesList.toSeq, + query.getTypeParams.getMatchTypesList.toSeq, + query.getTypeParams.getFilterOutTypesList.toSeq) + } else { + TypeParams(Nil, Nil, Nil) + } + + val commandCategories = query.getCommandCategoriesList.toSeq + + val results = frontEndModel.commandQuery(commandCategories, typeParams, lastUuid, lastName, pageSize, pageByName, filter) + + val response = CommandQueryResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def putCommands(request: PutCommandsRequest, headers: Map[String, String], context: ServiceContext): Response[PutCommandsResponse] = { + + val createFilter = context.auth.authorize(commandResource, "create") + val allowCreate = createFilter.isEmpty + val updateFilter = context.auth.authorize(commandResource, "update") + + if (request.getTemplatesCount == 0) { + throw new BadRequestException("Must include request content") + } + + val templates = request.getTemplatesList.toSeq + + val modelTemplates = templates.map { template => + + if (!template.hasEntityTemplate || !template.getEntityTemplate.hasName) { + throw new BadRequestException("Must include point name") + } + EntityServices.validateEntityName(template.getEntityTemplate.getName) + + if (!template.hasDisplayName) { + throw new BadRequestException("Must include display name") + } + + if (!template.hasCategory) { + throw new BadRequestException("Must include command type") + } + + val uuidOpt = if (template.getEntityTemplate.hasUuid) Some(protoUUIDToUuid(template.getEntityTemplate.getUuid)) else None + + FrontEndModel.CoreTypeTemplate(uuidOpt, template.getEntityTemplate.getName, template.getEntityTemplate.getTypesList.toSet, CommandInfo(template.getDisplayName, template.getCategory)) + } + + val results = frontEndModel.putCommands(context.notifier, modelTemplates, allowCreate, updateFilter) + + val response = PutCommandsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def deleteCommands(request: DeleteCommandsRequest, headers: Map[String, String], context: ServiceContext): Response[DeleteCommandsResponse] = { + + val filter = context.auth.authorize(commandResource, "delete") + + if (request.getCommandUuidsCount == 0) { + throw new BadRequestException("Must include point ids to delete") + } + + val uuids = request.getCommandUuidsList.map(protoUUIDToUuid) + + val results = frontEndModel.deleteCommands(context.notifier, uuids, filter) + + val response = DeleteCommandsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def subscribeToCommands(request: SubscribeCommandsRequest, headers: Map[String, String], context: ServiceContext): Response[SubscribeCommandsResponse] = { + + val filter = context.auth.authorize(commandResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val queue = headers.get("__subscription").getOrElse { + throw new BadRequestException("Must include subscription queue in headers") + } + + val query = request.getRequest + + val hasKeys = query.getUuidsCount != 0 || query.getNamesCount != 0 + val hasParams = hasKeys || query.getEndpointUuidsCount != 0 + + val results = if (!hasParams) { + + if (filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions on entities when subscribing to all") + } + + commandBinding.bindAll(queue) + + Nil + + } else if (query.getEndpointUuidsCount != 0 && !hasKeys) { + + if (filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions when subscribing to all") + } + + val endpointUuids = query.getEndpointUuidsList.toSeq + + commandBinding.bindEach(queue, Seq(endpointUuids.map(_.getValue), Nil, Nil)) + + Nil + + } else if (hasKeys && query.getEndpointUuidsCount == 0) { + + val uuids = query.getUuidsList.toSeq + val names = query.getNamesList.toSeq + + val results = frontEndModel.commandKeyQuery(uuids map protoUUIDToUuid, names, filter) + + val filteredUuids = results.map(_.getUuid.getValue) + + if (filteredUuids.nonEmpty) { + commandBinding.bindEach(queue, Seq(Nil, filteredUuids, Nil)) + } + + results + + } else { + + throw new BadRequestException("Must not include key parameters while subscribing by endpoint") + } + + val response = SubscribeCommandsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def getEndpoints(request: GetEndpointsRequest, headers: Map[String, String], context: ServiceContext): Response[GetEndpointsResponse] = { + + val filter = context.auth.authorize(endpointResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val keySet = request.getRequest + + if (keySet.getUuidsCount == 0 && keySet.getNamesCount == 0) { + throw new BadRequestException("Must include at least one id or name") + } + + val uuids = keySet.getUuidsList.toSeq.map(protoUUIDToUuid) + val names = keySet.getNamesList.toSeq + + val results = frontEndModel.endpointKeyQuery(uuids, names, filter) + + val response = GetEndpointsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def endpointQuery(request: EndpointQueryRequest, headers: Map[String, String], context: ServiceContext): Response[EndpointQueryResponse] = { + + val filter = context.auth.authorize(endpointResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val query = request.getRequest + + val typeParams = if (query.hasTypeParams) { + TypeParams( + query.getTypeParams.getIncludeTypesList.toSeq, + query.getTypeParams.getMatchTypesList.toSeq, + query.getTypeParams.getFilterOutTypesList.toSeq) + } else { + TypeParams(Nil, Nil, Nil) + } + + val (pageByName, lastUuid, lastName, pageSize) = parsePageParams(query.hasPagingParams, query.getPagingParams) + + val protocols = query.getProtocolsList.toSeq + val disabledOpt = if (query.hasDisabled) Some(query.getDisabled) else None + + val results = frontEndModel.endpointQuery(protocols, disabledOpt, typeParams, lastUuid, lastName, pageSize, pageByName, filter) + + val response = EndpointQueryResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def putEndpoints(request: PutEndpointsRequest, headers: Map[String, String], context: ServiceContext): Response[PutEndpointsResponse] = { + + val createFilter = context.auth.authorize(endpointResource, "create") + val allowCreate = createFilter.isEmpty + val updateFilter = context.auth.authorize(endpointResource, "update") + + if (request.getTemplatesCount == 0) { + throw new BadRequestException("Must include request content") + } + + val templates = request.getTemplatesList.toSeq + + val modelTemplates = templates.map { template => + if (!template.hasEntityTemplate || !template.getEntityTemplate.hasName) { + throw new BadRequestException("Must include endpoint name") + } + EntityServices.validateEntityName(template.getEntityTemplate.getName) + + if (!template.hasProtocol) { + throw new BadRequestException("Must include protocol name") + } + + val uuidOpt = if (template.getEntityTemplate.hasUuid) Some(protoUUIDToUuid(template.getEntityTemplate.getUuid)) else None + + val disabledOpt = if (template.hasDisabled) Some(template.getDisabled) else None + + FrontEndModel.CoreTypeTemplate(uuidOpt, template.getEntityTemplate.getName, template.getEntityTemplate.getTypesList.toSet, EndpointInfo(template.getProtocol, disabledOpt)) + } + + val results = frontEndModel.putEndpoints(context.notifier, modelTemplates, allowCreate, updateFilter) + + val response = PutEndpointsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def putEndpointsDisabled(request: PutEndpointDisabledRequest, headers: Map[String, String], context: ServiceContext): Response[PutEndpointDisabledResponse] = { + + val filter = context.auth.authorize(endpointResource, "update") + + if (request.getUpdatesCount == 0) { + throw new BadRequestException("Must include request content") + } + + val updates = request.getUpdatesList.toSeq + + val modelUpdates = updates.map { update => + if (!update.hasEndpointUuid) { + throw new BadRequestException("Must include endpoint uuid") + } + if (!update.hasDisabled) { + throw new BadRequestException("Must include requested disable state") + } + + FrontEndModel.EndpointDisabledUpdate(protoUUIDToUuid(update.getEndpointUuid), update.getDisabled) + } + + val results = frontEndModel.putEndpointsDisabled(context.notifier, modelUpdates, filter) + + val eventTemplates = results.map { endpoint => + val attrs = EventAlarmModel.mapToAttributesList(Seq(("name", endpoint.getName))) + val eventType = if (endpoint.getDisabled) { + EventSeeding.System.endpointDisabled.eventType + } else { + EventSeeding.System.endpointEnabled.eventType + } + SysEventTemplate(context.auth.agentName, eventType, Some("services"), None, None, None, attrs) + } + eventModel.postEvents(context.notifier, eventTemplates) + + val response = PutEndpointDisabledResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def deleteEndpoints(request: DeleteEndpointsRequest, headers: Map[String, String], context: ServiceContext): Response[DeleteEndpointsResponse] = { + + val filter = context.auth.authorize(endpointResource, "delete") + + if (request.getEndpointUuidsCount == 0) { + throw new BadRequestException("Must include point ids to delete") + } + + val uuids = request.getEndpointUuidsList.map(protoUUIDToUuid) + + val results = frontEndModel.deleteEndpoints(context.notifier, uuids, filter) + + val response = DeleteEndpointsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def subscribeToEndpoints(request: SubscribeEndpointsRequest, headers: Map[String, String], context: ServiceContext): Response[SubscribeEndpointsResponse] = { + + val filter = context.auth.authorize(endpointResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val queue = headers.get("__subscription").getOrElse { + throw new BadRequestException("Must include subscription queue in headers") + } + + val query = request.getRequest + + val hasKeys = query.getUuidsCount != 0 || query.getNamesCount != 0 + val hasParams = hasKeys || query.getProtocolsCount != 0 + + val results = if (!hasParams) { + + if (filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions on entities when subscribing to all") + } + + endpointBinding.bindAll(queue) + + Nil + + } else if (query.getProtocolsCount != 0 && !hasKeys) { + + if (filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions on entities when subscribing by protocol") + } + + val protocols = query.getProtocolsList.toSeq + + endpointBinding.bindEach(queue, Seq(Nil, Nil, protocols)) + + Nil + + } else if (hasKeys && query.getProtocolsCount == 0) { + + val uuids = query.getUuidsList.toSeq + val names = query.getNamesList.toSeq + + val results = frontEndModel.endpointKeyQuery(uuids map protoUUIDToUuid, names, filter) + + val filteredUuids = results.map(_.getUuid.getValue) + + if (filteredUuids.nonEmpty) { + endpointBinding.bindEach(queue, Seq(filteredUuids, Nil, Nil)) + } + + results + + } else { + + throw new BadRequestException("Must not include key parameters if not subscribing by protocol") + } + + val response = SubscribeEndpointsResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + +} + +object EntitySubscriptionDescriptor extends SubscriptionDescriptor[Entity] { + def keyParts(payload: Entity): Seq[String] = { + Seq(payload.getUuid.getValue, payload.getName) + } + + def notification(eventType: SubscriptionEventType, payload: Entity): Array[Byte] = { + EntityNotification.newBuilder + .setValue(payload) + .setEventType(eventType) + .build() + .toByteArray + } +} + +case class EntityKeyValueWithEndpoint(payload: EntityKeyValue, endpoint: Option[ModelUUID]) + +object EntityKeyValueSubscriptionDescriptor extends SubscriptionDescriptor[EntityKeyValueWithEndpoint] { + def keyParts(payload: EntityKeyValueWithEndpoint): Seq[String] = { + Seq(payload.payload.getUuid.getValue, payload.payload.getKey, payload.endpoint.map(_.getValue).getOrElse("")) + } + + def notification(eventType: SubscriptionEventType, payload: EntityKeyValueWithEndpoint): Array[Byte] = { + EntityKeyValueNotification.newBuilder + .setValue(payload.payload) + .setEventType(eventType) + .build() + .toByteArray + } +} + +object EntityEdgeSubscriptionDescriptor extends SubscriptionDescriptor[EntityEdge] { + def keyParts(payload: EntityEdge): Seq[String] = { + Seq(payload.getParent.getValue, payload.getChild.getValue, payload.getRelationship, payload.getDistance.toString) + } + + def notification(eventType: SubscriptionEventType, payload: EntityEdge): Array[Byte] = { + EntityEdgeNotification.newBuilder + .setValue(payload) + .setEventType(eventType) + .build() + .toByteArray + } +} \ No newline at end of file diff --git a/services/src/main/scala/io/greenbus/services/core/ProcessingServices.scala b/services/src/main/scala/io/greenbus/services/core/ProcessingServices.scala new file mode 100644 index 0000000..8f646a7 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/core/ProcessingServices.scala @@ -0,0 +1,208 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.core + +import io.greenbus.client.exception.{ BadRequestException, ForbiddenException } +import io.greenbus.client.proto.Envelope +import io.greenbus.client.proto.Envelope.SubscriptionEventType +import io.greenbus.client.service.ProcessingService.Descriptors +import io.greenbus.client.service.proto.Model.ModelUUID +import io.greenbus.client.service.proto.Processing.{ MeasOverride, OverrideNotification } +import io.greenbus.client.service.proto.ProcessingRequests._ +import io.greenbus.services.framework.{ ServiceContext, Success, _ } +import io.greenbus.services.model.EventAlarmModel.SysEventTemplate +import io.greenbus.services.model.{ FrontEndModel, EventAlarmModel, EventSeeding, ProcessingModel } +import io.greenbus.services.model.UUIDHelpers._ + +import scala.collection.JavaConversions._ + +object ProcessingServices { + + val overrideResource = "meas_override" +} + +class ProcessingServices(services: ServiceRegistry, processingModel: ProcessingModel, eventModel: EventAlarmModel, frontEndModel: FrontEndModel, overSubMgr: SubscriptionChannelBinder) { + import io.greenbus.services.core.ProcessingServices._ + + services.fullService(Descriptors.GetOverrides, getOverrides) + services.fullService(Descriptors.SubscribeToOverrides, subscribeToOverrides) + services.fullService(Descriptors.PutOverrides, putOverrides) + services.fullService(Descriptors.DeleteOverrides, deleteOverrides) + + def getOverrides(request: GetOverridesRequest, headers: Map[String, String], context: ServiceContext): Response[GetOverridesResponse] = { + + val filter = context.auth.authorize(overrideResource, "read") + + if (!request.hasRequest) { + throw new BadRequestException("Must include request content") + } + + val keySet = request.getRequest + + if (keySet.getUuidsCount == 0 && keySet.getNamesCount == 0) { + throw new BadRequestException("Must include at least one id or name") + } + + val uuids = keySet.getUuidsList.toSeq.map(protoUUIDToUuid) + val names = keySet.getNamesList.toSeq + + val results = processingModel.overrideKeyQuery(uuids, names, filter) + + val response = GetOverridesResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def subscribeToOverrides(request: SubscribeOverridesRequest, headers: Map[String, String], context: ServiceContext): Response[SubscribeOverridesResponse] = { + + val filter = context.auth.authorize(overrideResource, "read") + + if (!request.hasQuery) { + throw new BadRequestException("Must include request content") + } + + val query = request.getQuery + + val queue = headers.get("__subscription").getOrElse { + throw new BadRequestException("Must include subscription queue in headers") + } + + val hasKeys = query.getPointUuidsCount != 0 || query.getPointNamesCount != 0 + val hasParams = hasKeys || query.getEndpointUuidsCount != 0 + + val immediateResults = if (!hasParams) { + + if (filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions on entities when subscribing to all entities") + } + + overSubMgr.bindAll(queue) + Seq() + + } else if (query.getEndpointUuidsCount != 0 && !hasKeys) { + + if (query.getPointUuidsCount != 0 || query.getPointNamesCount != 0) { + throw new BadRequestException("Must not include key parameters when subscribing by endpoints") + } + if (filter.nonEmpty) { + throw new ForbiddenException("Must have blanket read permissions on entities when subscribing by endpoints") + } + + val endpoints = query.getEndpointUuidsList.toSeq + + overSubMgr.bindEach(queue, Seq(Nil, endpoints.map(_.getValue))) + + Nil + + } else if (hasKeys && query.getEndpointUuidsCount == 0) { + + val names = query.getPointNamesList.toSeq + val uuids = query.getPointUuidsList.toSeq.map(protoUUIDToUuid) + + val results = processingModel.overrideKeyQuery(uuids, names, filter) + + val filteredUuids = results.map(_.getPointUuid.getValue) + + if (filteredUuids.nonEmpty) { + overSubMgr.bindEach(queue, Seq(filteredUuids, Nil)) + } + results + + } else { + + throw new BadRequestException("Must not include key parameters when subscribing by endpoints") + } + + val response = SubscribeOverridesResponse.newBuilder().addAllResults(immediateResults).build + Success(Envelope.Status.OK, response) + } + + def putOverrides(request: PutOverridesRequest, headers: Map[String, String], context: ServiceContext): Response[PutOverridesResponse] = { + + val filter = context.auth.authorize(overrideResource, "update") + + if (request.getOverridesCount == 0) { + throw new BadRequestException("Must include at least one measurement override") + } + + request.getOverridesList.toList.foreach { measOver => + if (!measOver.hasPointUuid) { + throw new BadRequestException("Measurement overrides must include point uuid") + } + } + + val measOverrides = request.getOverridesList.toList.map(o => (protoUUIDToUuid(o.getPointUuid), o)) + + val results = processingModel.putOverrides(context.notifier, measOverrides, filter) + + publishEventsForOverrides(context, results, EventSeeding.System.setNotInService.eventType, EventSeeding.System.setOverride.eventType) + + val response = PutOverridesResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + def deleteOverrides(request: DeleteOverridesRequest, headers: Map[String, String], context: ServiceContext): Response[DeleteOverridesResponse] = { + + val filter = context.auth.authorize(overrideResource, "delete") + + if (request.getPointUuidsCount == 0) { + throw new BadRequestException("Must include at least one item to delete") + } + + val uuids = request.getPointUuidsList.map(protoUUIDToUuid) + + val results = processingModel.deleteOverrides(context.notifier, uuids, filter) + + publishEventsForOverrides(context, results, EventSeeding.System.removeNotInService.eventType, EventSeeding.System.removeOverride.eventType) + + val response = DeleteOverridesResponse.newBuilder().addAllResults(results).build + Success(Envelope.Status.OK, response) + } + + private def publishEventsForOverrides(context: ServiceContext, results: Seq[MeasOverride], nisType: String, replaceType: String): Unit = { + val points = frontEndModel.pointKeyQuery(results.map(_.getPointUuid).map(protoUUIDToUuid), Seq()) + val uuidToNameMap = points.map(p => (p.getUuid, p.getName)).toMap + + val eventTemplates = results.map { over => + val pointName = uuidToNameMap.getOrElse(over.getPointUuid, "unknown") + val attrs = EventAlarmModel.mapToAttributesList(Seq(("point", pointName))) + val eventType = if (over.hasMeasurement) replaceType else nisType + SysEventTemplate(context.auth.agentName, eventType, Some("services"), None, None, None, attrs) + } + + eventModel.postEvents(context.notifier, eventTemplates) + } +} + +case class OverrideWithEndpoint(payload: MeasOverride, endpoint: Option[ModelUUID]) + +object OverrideSubscriptionDescriptor extends SubscriptionDescriptor[OverrideWithEndpoint] { + + def keyParts(obj: OverrideWithEndpoint): Seq[String] = { + Seq(obj.payload.getPointUuid.getValue, obj.endpoint.map(_.getValue).getOrElse("")) + } + + def notification(eventType: SubscriptionEventType, obj: OverrideWithEndpoint): Array[Byte] = { + OverrideNotification.newBuilder + .setValue(obj.payload) + .setEventType(eventType) + .build() + .toByteArray + } +} + diff --git a/services/src/main/scala/io/greenbus/services/core/event/MessageFormatter.scala b/services/src/main/scala/io/greenbus/services/core/event/MessageFormatter.scala new file mode 100755 index 0000000..e48fe3a --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/core/event/MessageFormatter.scala @@ -0,0 +1,159 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.core.event + +import io.greenbus.client.service.proto.Events.Attribute + +/** + * + * Render the resource string using an AttributeList. + * + * This class is like MessageFormat.java, but uses custom resource strings that + * have named attributes to fill in. The standard Java classes uses ordered + * attributes. + * + *

Goals

+ * The resource strings should use the same syntax as the standard Java resource libraries, + * with the exception that properties are named. + * + *

Use Cases

+ * Render a message given a resource string and an AttributeList proto. + * + *
+ * val (severity, designation, alarmState, resource) = eventConfig.getProperties(req.getEventType)
+ * theString = MessageFormatter.format( resource, req.getArgs)
+ * 
+ * + * @author flint + */ + +object MessageFormatter { + + def attributeListToMap(alist: Seq[Attribute]): Map[String, String] = { + alist.map(attributeToPair).toMap + } + + def attributeToPair(a: Attribute): (String, String) = { + val key = if (a.hasName) { + a.getName + } else { + throw new IllegalArgumentException("Attribute had no name") + } + + val v = if (a.hasValueBool) { + a.getValueBool.toString + } else if (a.hasValueDouble) { + a.getValueDouble.toString + } else if (a.hasValueSint64) { + a.getValueSint64.toString + } else if (a.hasValueString) { + a.getValueString + } else { + throw new IllegalArgumentException("Attribute had no value") + } + + (key, v) + } + + /** + * Render the resource string using the AttributeList. + */ + def format(resource: String, alist: Seq[Attribute]): String = { + val attrMap = attributeListToMap(alist) + parseResource(resource).map(_.apply(attrMap)).mkString("") + } + + /** + * Return a list of ResourceSegment + */ + protected def parseResource(resource: String): List[ResourceSegment] = { + var segments: List[ResourceSegment] = List() + var index = 0; + var leftBrace = indexOfWithEscape(resource, '{', index) + while (leftBrace >= 0) { + val rightBrace = indexOfWithEscape(resource, '}', leftBrace + 1) + if (rightBrace < 0) + throw new IllegalArgumentException("Braces do not match in resource string") + + // Is there a string before the brace? + if (leftBrace > index) + segments ::= ResourceSegmentString(resource.substring(index, leftBrace)) + + // For now we have a simple name inside the brace + segments ::= ResourceSegmentNamedValue(resource.substring(leftBrace + 1, rightBrace).trim, resource.substring(leftBrace, rightBrace + 1)) + + index = rightBrace + 1 + leftBrace = indexOfWithEscape(resource, '{', index) + } + + // Either there were never any braces or there's a string after the last brace + if (index < resource.length) + segments ::= ResourceSegmentString(resource.substring(index)) + + segments.reverse + } + + /** + * Return the index that the delimiter occurs or -1. + * Escape sequence for resource strings is '{'name'}' or '{name}' + * + */ + def indexOfWithEscape(s: String, delimiter: Int, index: Int) = { + s.indexOf(delimiter, index) + // The escape sequence for resource strings uses single quotes and it's complicated! + /*var foundAt = s.indexOf(delimiter, index) + var i = index; + + while (foundAt >= 0 /* &&bla bla bla*/) { + i = foundAt + 1 + foundAt = s.indexOf(delimiter, i) + } + + foundAt + */ + } +} + +/** + * A resource string is chopped up into segments. A "segment" is either + * a named variable or a string of characters between named variables. + */ +trait ResourceSegment { + /** + * Apply the values from the AttributeList to the named + * properties in the resource segment. + */ + def apply(alist: Map[String, String]): String +} + +/** + * Just a fixed string segment of a resource string. + */ +case class ResourceSegmentString(s: String) extends ResourceSegment { + def apply(alist: Map[String, String]) = s +} + +/** + * A named value part of a resource string. + */ +case class ResourceSegmentNamedValue(name: String, original: String) extends ResourceSegment { + + def apply(alist: Map[String, String]) = alist.get(name).getOrElse(original) +} + diff --git a/services/src/main/scala/io/greenbus/services/data/Auth.scala b/services/src/main/scala/io/greenbus/services/data/Auth.scala new file mode 100644 index 0000000..de88e29 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/data/Auth.scala @@ -0,0 +1,103 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.data + +import java.util.UUID +import org.squeryl.KeyedEntity + +/** + * Helpers for handling the implementation of salted password and encoded passwords + * http://www.jasypt.org/howtoencryptuserpasswords.html + */ +object SaltedPasswordHelper { + import org.apache.commons.codec.binary.Base64 + import java.security.{ SecureRandom, MessageDigest } + + def enc64(original: String): String = { + new String(Base64.encodeBase64(original.getBytes("UTF-8")), "US-ASCII") + } + + def dec64(original: String): String = { + new String(Base64.decodeBase64(original.getBytes("US-ASCII")), "UTF-8") + } + + /** + * @returns tuple of (digest, salt) + */ + def makeDigestAndSalt(password: String): (String, String) = { + val b = new Array[Byte](20) + new SecureRandom().nextBytes(b) + val salt = dec64(enc64(new String(b, "UTF-8"))) + (calcDigest(salt, password), salt) + } + + def calcDigest(salt: String, pass: String) = { + val combinedSaltPassword = (salt + pass).getBytes("UTF-8") + val digestBytes = MessageDigest.getInstance("SHA-256").digest(combinedSaltPassword) + // TODO: figure out how to roundtrip bytes through UTF-8 + dec64(enc64(new String(digestBytes, "UTF-8"))) + } +} + +object AgentRow { + import SaltedPasswordHelper._ + + def apply(id: UUID, name: String, password: String): AgentRow = { + val (digest, salt) = SaltedPasswordHelper.makeDigestAndSalt(password) + AgentRow(id, name, enc64(digest), enc64(salt)) + } +} +case class AgentRow(id: UUID, + name: String, + digest: String, + salt: String) extends KeyedEntity[UUID] { + + def checkPassword(password: String): Boolean = { + import SaltedPasswordHelper._ + dec64(digest) == calcDigest(dec64(salt), password) + } + + def withUpdatedPassword(password: String): AgentRow = { + import SaltedPasswordHelper._ + val (digest, saltText) = makeDigestAndSalt(password) + this.copy(digest = enc64(digest), salt = enc64(saltText)) + } + + override def toString: String = { + s"AgentRow($id, $name, [digest], [salt])" + } +} + +case class PermissionSetRow(id: Long, + name: String, + protoBytes: Array[Byte]) extends KeyedEntity[Long] { +} + +case class AuthTokenRow(id: Long, + token: String, + agentId: UUID, + loginLocation: String, + clientVersion: String, + revoked: Boolean, + issueTime: Long, + expirationTime: Long) extends KeyedEntity[Long] { +} + +case class AgentPermissionSetJoinRow(permissionSetId: Long, agentId: UUID) +case class AuthTokenPermissionSetJoinRow(permissionSetId: Long, authTokenId: Long) \ No newline at end of file diff --git a/services/src/main/scala/io/greenbus/services/data/Commands.scala b/services/src/main/scala/io/greenbus/services/data/Commands.scala new file mode 100644 index 0000000..cccd813 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/data/Commands.scala @@ -0,0 +1,26 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.data + +import java.util.UUID +import org.squeryl.KeyedEntity + +case class CommandLockRow(id: Long, access: Int, expireTime: Option[Long], agent: UUID) extends KeyedEntity[Long] + +case class LockJoinRow(commandId: Long, lockId: Long) diff --git a/services/src/main/scala/io/greenbus/services/data/Events.scala b/services/src/main/scala/io/greenbus/services/data/Events.scala new file mode 100644 index 0000000..bff0460 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/data/Events.scala @@ -0,0 +1,73 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.data + +import java.util.UUID +import io.greenbus.client.service.proto.Events.EventConfig.Designation +import io.greenbus.client.service.proto.Events.Alarm.State +import org.squeryl.KeyedEntity + +case class EventRow(id: Long, + eventType: String, + alarm: Boolean, + time: Long, + deviceTime: Option[Long], + severity: Int, + subsystem: String, + userId: String, + entityId: Option[UUID], + modelGroup: Option[String], + args: Array[Byte], + rendered: String) extends KeyedEntity[Long] + +object EventConfigRow { + val ALARM = Designation.ALARM.getNumber + val EVENT = Designation.EVENT.getNumber + val LOG = Designation.LOG.getNumber +} + +case class EventConfigRow( + id: Long, + eventType: String, + severity: Int, + designation: Int, // Alarm, Event, or Log + alarmState: Int, // Initial alarm start state: UNACK_AUDIBLE, UNACK_SILENT, or ACKNOWLEDGED + resource: String, + builtIn: Boolean) extends KeyedEntity[Long] + +/** + * The Model for the Alarm. It's part DB map and part Model. + */ +object AlarmRow { + + // Get the enum values from the proto. + // + val UNACK_AUDIBLE = State.UNACK_AUDIBLE.getNumber + val UNACK_SILENT = State.UNACK_SILENT.getNumber + val ACKNOWLEDGED = State.ACKNOWLEDGED.getNumber + val REMOVED = State.REMOVED.getNumber +} + +/** + * The Model for the Alarm. It's part DB map and part Model. + */ +case class AlarmRow( + id: Long, + state: Int, + eventId: Long) extends KeyedEntity[Long] diff --git a/services/src/main/scala/io/greenbus/services/data/FrontEnd.scala b/services/src/main/scala/io/greenbus/services/data/FrontEnd.scala new file mode 100644 index 0000000..1263842 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/data/FrontEnd.scala @@ -0,0 +1,32 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.data + +import java.util.UUID +import org.squeryl.KeyedEntity + +case class PointRow(id: Long, entityId: UUID, pointCategory: Int, unit: String) extends EntityBased + +case class CommandRow(id: Long, entityId: UUID, displayName: String, commandCategory: Int, lastSelectId: Option[Long]) extends EntityBased + +case class EndpointRow(id: Long, entityId: UUID, protocol: String, disabled: Boolean) extends EntityBased + +case class FrontEndConnectionRow(id: Long, endpointId: UUID, inputAddress: String, commandAddress: Option[String]) extends KeyedEntity[Long] + +case class FrontEndCommStatusRow(id: Long, endpointId: UUID, status: Int, updateTime: Long) extends KeyedEntity[Long] diff --git a/services/src/main/scala/io/greenbus/services/data/ProcessingLockSchema.scala b/services/src/main/scala/io/greenbus/services/data/ProcessingLockSchema.scala new file mode 100644 index 0000000..ccf5166 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/data/ProcessingLockSchema.scala @@ -0,0 +1,35 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.data + +import java.util.UUID + +import org.squeryl.{ KeyedEntity, Schema } + +object ProcessingLockSchema extends Schema { + + val processingLocks = table[ProcessingLock] + + def reset() { + drop + create + } +} + +case class ProcessingLock(id: UUID, nodeId: String, lastCheckIn: Long) extends KeyedEntity[UUID] diff --git a/services/src/main/scala/io/greenbus/services/data/ServicesSchema.scala b/services/src/main/scala/io/greenbus/services/data/ServicesSchema.scala new file mode 100644 index 0000000..df4bcca --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/data/ServicesSchema.scala @@ -0,0 +1,111 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.data + +import org.squeryl.{ KeyedEntity, Schema } +import org.squeryl.PrimitiveTypeMode._ +import java.util.UUID + +trait EntityBased extends KeyedEntity[Long] { + def id: Long + def entityId: UUID +} + +case class EntityRow(id: UUID, name: String) extends KeyedEntity[UUID] + +case class EntityTypeRow(entityId: UUID, entType: String) + +case class EntityEdgeRow(id: Long, + parentId: UUID, + childId: UUID, + relationship: String, + distance: Int) extends KeyedEntity[Long] + +case class EntityKeyStoreRow(id: Long, uuid: UUID, key: String, data: Array[Byte]) extends KeyedEntity[Long] + +object ServicesSchema extends Schema { + + // Auth + val agents = table[AgentRow] + val permissionSets = table[PermissionSetRow] + val authTokens = table[AuthTokenRow] + + on(authTokens)(s => declare(s.token is (indexed), s.expirationTime is (indexed))) + on(agents)(s => declare( + s.name is (unique, indexed))) + on(permissionSets)(s => declare( + s.name is (unique, indexed))) + + val tokenSetJoins = table[AuthTokenPermissionSetJoinRow] + val agentSetJoins = table[AgentPermissionSetJoinRow] + + on(tokenSetJoins)(s => declare(s.authTokenId is (indexed))) + on(agentSetJoins)(s => declare(s.agentId is (indexed))) + + // Model + val entities = table[EntityRow] + val edges = table[EntityEdgeRow] + val entityTypes = table[EntityTypeRow] + + val entityKeyValues = table[EntityKeyStoreRow] + + on(entities)(s => declare( + s.name is (unique, indexed))) + on(edges)(s => declare( + columns(s.parentId, s.childId, s.relationship) are (unique), + columns(s.childId, s.relationship) are (indexed), + columns(s.parentId, s.relationship) are (indexed))) + on(entityTypes)(s => declare( + s.entType is (indexed), + s.entityId is (indexed))) + + on(entityKeyValues)(s => declare( + columns(s.uuid, s.key) are (unique, indexed))) + + val points = table[PointRow] + val commands = table[CommandRow] + val endpoints = table[EndpointRow] + + on(points)(s => declare( + s.entityId is (unique, indexed))) + on(commands)(s => declare( + s.entityId is (unique, indexed))) + on(endpoints)(s => declare( + s.entityId is (unique, indexed))) + + val locks = table[CommandLockRow] + val lockJoins = table[LockJoinRow] + + val frontEndConnections = table[FrontEndConnectionRow] + val frontEndCommStatuses = table[FrontEndCommStatusRow] + + val events = table[EventRow] + val eventConfigs = table[EventConfigRow] + val alarms = table[AlarmRow] + + on(events)(s => declare( + s.time is (indexed))) + on(alarms)(s => declare( + s.eventId is (indexed))) + + def reset() { + drop + create + } +} diff --git a/services/src/main/scala/io/greenbus/services/framework/DecodingServiceHandler.scala b/services/src/main/scala/io/greenbus/services/framework/DecodingServiceHandler.scala new file mode 100644 index 0000000..b3aa5c5 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/framework/DecodingServiceHandler.scala @@ -0,0 +1,131 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.framework + +import java.sql.SQLException + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.RequestDescriptor +import io.greenbus.msg.service.ServiceHandler +import io.greenbus.client.exception.ServiceException +import io.greenbus.client.proto.Envelope +import io.greenbus.jmx.Metrics +import io.greenbus.services.model.{ ModelInputException, ModelPermissionException } + +import scala.collection.JavaConversions._ + +class DecodingServiceHandler[A, B](decoder: RequestDescriptor[A, B], handler: TypedServiceHandler[A, B]) extends ExtractedServiceHandler { + def handle(request: Array[Byte], headers: Map[String, String], responseHandler: Response[Array[Byte]] => Unit): Unit = { + + def onResponse(result: Response[B]) { + result match { + case Failure(status, message) => responseHandler(Failure(status, message)) + case Success(status, obj) => + val bytes = decoder.encodeResponse(obj) + val mapped = Success(status, bytes) + responseHandler(mapped) + } + } + + val requestObj = decoder.decodeRequest(request) + handler.handle(requestObj, headers, onResponse) + } +} + +class TypedServiceInstrumenter[A, B](requestId: String, handler: TypedServiceHandler[A, B], metrics: Metrics) extends TypedServiceHandler[A, B] with Logging { + + val counter = metrics.counter("Count") + val handleTime = metrics.average("HandleTime") + + def handle(request: A, headers: Map[String, String], responseHandler: (Response[B]) => Unit): Unit = { + counter(1) + val startTime = System.currentTimeMillis() + + def logHandled() { + val elapsed = System.currentTimeMillis() - startTime + logger.debug(s"Handled $requestId in $elapsed") + handleTime(elapsed.toInt) + } + + def onResponse(response: Response[B]): Unit = { + logHandled() + responseHandler(response) + } + + try { + handler.handle(request, headers, onResponse) + } catch { + case ex: Throwable => + logHandled() + throw ex + } + } +} + +class ServiceHandlerMetricsInstrumenter(requestId: String, metrics: Metrics, handler: ServiceHandler) extends ServiceHandler with Logging { + + val counter = metrics.counter("Count") + val handleTime = metrics.average("HandleTime") + + def handleMessage(request: Array[Byte], responseHandler: Array[Byte] => Unit): Unit = { + counter(1) + val startTime = System.currentTimeMillis() + + def logHandled() { + val elapsed = System.currentTimeMillis() - startTime + logger.debug(s"Handled $requestId in $elapsed") + handleTime(elapsed.toInt) + } + + def onResponse(response: Array[Byte]): Unit = { + logHandled() + responseHandler(response) + } + + handler.handleMessage(request, onResponse) + } +} + +class ModelErrorTransformingHandler[A, B](handler: TypedServiceHandler[A, B]) extends TypedServiceHandler[A, B] with Logging { + def handle(request: A, headers: Map[String, String], responseHandler: (Response[B]) => Unit): Unit = { + try { + handler.handle(request, headers, responseHandler) + + } catch { + case ex: ModelPermissionException => + responseHandler(Failure(Envelope.Status.FORBIDDEN, ex.getMessage)) + case ex: ModelInputException => + responseHandler(Failure(Envelope.Status.BAD_REQUEST, ex.getMessage)) + case rse: ServiceException => + responseHandler(Failure(rse.getStatus, rse.getMessage)) + case sqlEx: SQLException => + if (sqlEx.iterator().toList.exists(ex => ex.getClass == classOf[java.net.ConnectException])) { + logger.error("Database connection error: " + sqlEx.getMessage) + responseHandler(Failure(Envelope.Status.BUS_UNAVAILABLE, "Data unavailable.")) + } else { + val next = Option(sqlEx.getNextException) + next.foreach(ex => logger.error("Sql next exception: " + ex)) + logger.error("Internal service error: " + sqlEx) + responseHandler(Failure(Envelope.Status.BUS_UNAVAILABLE, "Data unavailable.")) + } + case ex: IllegalArgumentException => + responseHandler(Failure(Envelope.Status.BAD_REQUEST, ex.getMessage)) + } + } +} diff --git a/services/src/main/scala/io/greenbus/services/framework/EnvelopeParsingHandler.scala b/services/src/main/scala/io/greenbus/services/framework/EnvelopeParsingHandler.scala new file mode 100644 index 0000000..3892311 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/framework/EnvelopeParsingHandler.scala @@ -0,0 +1,83 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.framework + +import com.google.protobuf.{ ByteString, InvalidProtocolBufferException } +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.msg.service.ServiceHandler +import io.greenbus.client.proto.Envelope +import io.greenbus.client.proto.Envelope.{ ServiceRequest, ServiceResponse } + +import scala.collection.JavaConversions._ + +object EnvelopeParsingHandler { + def buildErrorEnvelope(status: Envelope.Status, message: String): ServiceResponse = { + ServiceResponse.newBuilder() + .setStatus(status) + .setErrorMessage(message) + .build() + } +} + +class EnvelopeParsingHandler(handler: ExtractedServiceHandler) extends ServiceHandler with Logging { + import io.greenbus.services.framework.EnvelopeParsingHandler._ + + def handleMessage(msg: Array[Byte], responseHandler: (Array[Byte]) => Unit) { + + def onResponse(result: Response[Array[Byte]]) { + result match { + case Failure(status, message) => + val responseBytes = buildErrorEnvelope(status, message).toByteArray + responseHandler(responseBytes) + + case Success(status, bytes) => + val responseBytes = ServiceResponse.newBuilder() + .setStatus(status) + .setPayload(ByteString.copyFrom(bytes)) + .build() + .toByteArray + + responseHandler(responseBytes) + } + } + + def internalError(ex: Throwable) { + logger.error("Internal service error: " + ex) + val response = buildErrorEnvelope(Envelope.Status.INTERNAL_ERROR, "Internal service error.") + responseHandler(response.toByteArray) + } + + try { + val requestEnvelope = ServiceRequest.parseFrom(msg) + val headers: Map[String, String] = requestEnvelope.getHeadersList.map(hdr => (hdr.getKey, hdr.getValue)).toMap + if (requestEnvelope.hasPayload) { + val payload = requestEnvelope.getPayload.toByteArray + handler.handle(payload, headers, onResponse) + } + } catch { + case protoEx: InvalidProtocolBufferException => + logger.warn("Error parsing client request: " + protoEx) + val response = buildErrorEnvelope(Envelope.Status.BAD_REQUEST, "Couldn't parse service request.") + responseHandler(response.toByteArray) + case ex: Throwable => + internalError(ex) + } + } + +} diff --git a/services/src/main/scala/io/greenbus/services/framework/ExtractedServiceHandler.scala b/services/src/main/scala/io/greenbus/services/framework/ExtractedServiceHandler.scala new file mode 100644 index 0000000..b239509 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/framework/ExtractedServiceHandler.scala @@ -0,0 +1,23 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.framework + +trait ExtractedServiceHandler { + def handle(request: Array[Byte], headers: Map[String, String], responseHandler: Response[Array[Byte]] => Unit) +} diff --git a/services/src/main/scala/io/greenbus/services/framework/MarshallingHandler.scala b/services/src/main/scala/io/greenbus/services/framework/MarshallingHandler.scala new file mode 100644 index 0000000..7715c1d --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/framework/MarshallingHandler.scala @@ -0,0 +1,29 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.framework + +import io.greenbus.msg.service.ServiceHandler + +class MarshallingHandler(marshaller: ServiceMarshaller, handler: ServiceHandler) extends ServiceHandler { + def handleMessage(msg: Array[Byte], responseHandler: (Array[Byte]) => Unit) { + marshaller.run { + handler.handleMessage(msg, responseHandler) + } + } +} diff --git a/services/src/main/scala/io/greenbus/services/framework/RequestResponseDecoder.scala b/services/src/main/scala/io/greenbus/services/framework/RequestResponseDecoder.scala new file mode 100644 index 0000000..1971aba --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/framework/RequestResponseDecoder.scala @@ -0,0 +1,24 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.framework + +trait RequestResponseDecoder[A, B] { + def decodeRequest(bytes: Array[Byte]): A + def encodeResponse(response: B): Array[Byte] +} diff --git a/services/src/main/scala/io/greenbus/services/framework/ServiceMarshaller.scala b/services/src/main/scala/io/greenbus/services/framework/ServiceMarshaller.scala new file mode 100644 index 0000000..e4b2854 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/framework/ServiceMarshaller.scala @@ -0,0 +1,23 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.framework + +trait ServiceMarshaller { + def run[U](runner: => U) +} diff --git a/services/src/main/scala/io/greenbus/services/framework/ServiceRegistry.scala b/services/src/main/scala/io/greenbus/services/framework/ServiceRegistry.scala new file mode 100644 index 0000000..f6e5378 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/framework/ServiceRegistry.scala @@ -0,0 +1,177 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.framework + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.jmx.{ Metrics, MetricsManager, MetricsSource, Tag } +import io.greenbus.msg.RequestDescriptor +import io.greenbus.msg.amqp.AmqpServiceOperations +import io.greenbus.msg.service.ServiceHandler +import io.greenbus.services.authz.{ AuthContext, AuthLookup } +import io.greenbus.sql.{ DbConnection, TransactionMetrics, TransactionMetricsListener } +import org.squeryl.logging.StatisticsListener + +object ServiceHandlerRegistry { + + def fullHandlerChain[A, B](desc: RequestDescriptor[A, B], handler: TypedServiceHandler[A, B], metrics: Metrics): ServiceHandler = { + new ServiceHandlerMetricsInstrumenter(desc.requestId, metrics, + new EnvelopeParsingHandler( + new DecodingServiceHandler(desc, + new ModelErrorTransformingHandler(handler)))) + } +} + +trait ServiceHandlerRegistry { + + protected val metricsSource: MetricsSource + + private var registered = List.empty[(String, ServiceHandler)] + + def getRegistered: Seq[(String, ServiceHandler)] = registered + + protected def register(requestId: String, handler: ServiceHandler) { + registered ::= (requestId, handler) + } + +} + +trait ServiceRegistry { + def simpleAsync[A, B](desc: RequestDescriptor[A, B], handler: (A, Map[String, String], (Response[B]) => Unit) => Unit) + def simpleSync[A, B](desc: RequestDescriptor[A, B], handler: (A, Map[String, String]) => Response[B]) + + def fullService[A, B](desc: RequestDescriptor[A, B], handler: (A, Map[String, String], ServiceContext) => Response[B]) +} + +trait SimpleServiceRegistry extends ServiceHandlerRegistry { + + def simpleAsync[A, B](desc: RequestDescriptor[A, B], handler: (A, Map[String, String], (Response[B]) => Unit) => Unit) { + + val metrics = metricsSource.metrics(desc.requestId, Tag("SubHead", "Handlers")) + + val methodHandler = new TypedServiceHandler[A, B] { + def handle(request: A, headers: Map[String, String], responseHandler: (Response[B]) => Unit) { + handler(request, headers, responseHandler) + } + } + + val fullHandler = ServiceHandlerRegistry.fullHandlerChain(desc, methodHandler, metrics) + register(desc.requestId, fullHandler) + } + + def simpleSync[A, B](desc: RequestDescriptor[A, B], handler: (A, Map[String, String]) => Response[B]) { + + val metrics = metricsSource.metrics(desc.requestId, Tag("SubHead", "Handlers")) + + val methodHandler = new SyncTypedServiceHandler[A, B] { + def handle(request: A, headers: Map[String, String]): Response[B] = handler(request, headers) + } + + val fullHandler = ServiceHandlerRegistry.fullHandlerChain(desc, methodHandler, metrics) + register(desc.requestId, fullHandler) + } +} + +case class ServiceContext(auth: AuthContext, notifier: ModelNotifier) + +class FullServiceRegistry(sql: DbConnection, ops: AmqpServiceOperations, auth: AuthLookup, mapper: ModelEventMapper, protected val metricsSource: MetricsSource) extends ServiceRegistry with SimpleServiceRegistry { + + def fullService[A, B](desc: RequestDescriptor[A, B], handler: (A, Map[String, String], ServiceContext) => Response[B]) { + + val metrics = metricsSource.metrics(desc.requestId, Tag("SubHead", "Handlers")) + val sqlMetrics = new TransactionMetrics(metrics) + + val wrappingHandler = new SyncTypedServiceHandler[A, B] { + def handle(request: A, headers: Map[String, String]): Response[B] = { + val listener = new TransactionMetricsListener(request.getClass.getSimpleName) + + val result = ServiceTransactionSource.transaction(sql, ops, auth, mapper, headers, Some(listener)) { context => + handler(request, headers, context) + } + + listener.report(sqlMetrics) + + result + } + } + + val fullHandler = ServiceHandlerRegistry.fullHandlerChain(desc, wrappingHandler, metrics) + register(desc.requestId, fullHandler) + } +} + +trait ServiceTransactionSource { + def transaction[A](headers: Map[String, String])(handler: ServiceContext => A): A +} + +object ServiceTransactionSource extends Logging { + + private object GlobalServiceMetrics { + val metricsMgr = MetricsManager("io.greenbus.services") + val metrics = metricsMgr.metrics("Auth") + + val authTime = metrics.timer("AuthTime") + + val notifierMetrics = metricsMgr.metrics("Notifier") + val notificationAverage = notifierMetrics.average("Average") + val notificationCount = notifierMetrics.counter("Counter") + val notificationTime = notifierMetrics.average("FlushTime") + val notificationTimePerNotification = notifierMetrics.average("FlushTimePerMessage") + + metricsMgr.register() + } + + import GlobalServiceMetrics._ + + def notificationObserver(width: Int, time: Int) = { + notificationAverage(width) + notificationCount(width) + notificationTime(time) + notificationTimePerNotification(if (width != 0) (time.toDouble / width.toDouble).toInt else 0) + } + + def apply(sql: DbConnection, ops: AmqpServiceOperations, auth: AuthLookup, mapper: ModelEventMapper): ServiceTransactionSource = { + new DefaultTransactionSource(sql, ops, auth, mapper) + } + + def transaction[A](sql: DbConnection, ops: AmqpServiceOperations, auth: AuthLookup, mapper: ModelEventMapper, headers: Map[String, String], listener: Option[StatisticsListener])(handler: ServiceContext => A): A = { + + var transStartEnd = 0L + // We flush the notifications outside of the transaction so that when observers receive a notification + // the database is consistent. + val notificationBuffer = new BufferingModelNotifier(mapper, ops, notificationObserver) + val result = sql.transaction(listener) { + val authContext = authTime { auth.validateAuth(headers) } + val context = ServiceContext(authContext, notificationBuffer) + val result = handler(context) + transStartEnd = System.currentTimeMillis() + result + } + logger.trace("Transaction end time: " + (System.currentTimeMillis() - transStartEnd)) + notificationBuffer.flush() + result + } + + private class DefaultTransactionSource(sql: DbConnection, ops: AmqpServiceOperations, auth: AuthLookup, mapper: ModelEventMapper) extends ServiceTransactionSource { + def transaction[A](headers: Map[String, String])(handler: (ServiceContext) => A): A = { + ServiceTransactionSource.transaction(sql, ops, auth, mapper, headers, None)(handler) + } + } + +} + diff --git a/services/src/main/scala/io/greenbus/services/framework/SubscriptionChannelManager.scala b/services/src/main/scala/io/greenbus/services/framework/SubscriptionChannelManager.scala new file mode 100644 index 0000000..56c4346 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/framework/SubscriptionChannelManager.scala @@ -0,0 +1,194 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.framework + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.client.proto.Envelope.SubscriptionEventType +import io.greenbus.msg.amqp.{ AmqpAddressedMessage, AmqpMessage, AmqpServiceOperations } +import io.greenbus.services.NotificationConversions + +import scala.annotation.tailrec +import scala.collection.mutable + +object SubscriptionChannelManager { + def toRoutingKey(parts: Seq[Option[String]]): String = { + val mapped: Seq[String] = parts.map { + case None => "*" + case Some(part) => part.replace(".", "-") + } + + mapped.mkString(".") + } + + def routingKeyPermutations(parts: Seq[Seq[String]]): Seq[String] = { + val count = parts.size + + @tailrec + def makePerms(results: List[String], prefix: Int, remaining: List[Seq[String]]): Seq[String] = { + remaining match { + case Nil => results + case head :: tail => + val column = head.map(str => Nil.padTo(prefix, None) ::: (Some(str) :: Nil.padTo(count - prefix - 1, None))) + val built = (column map toRoutingKey).toList + makePerms(built ++ results, prefix + 1, tail) + } + } + + makePerms(Nil, 0, parts.toList) + } + + def makeKey(parts: Seq[String]): String = { + parts.map(_.replace('.', '-')).mkString(".") + } +} + +trait SubscriptionDescriptor[Proto] { + + def keyParts(payload: Proto): Seq[String] + + def notification(eventType: SubscriptionEventType, payload: Proto): Array[Byte] +} + +trait SubscriptionChannelBinder { + def bindAll(queue: String) + def bindEach(queue: String, params: Seq[Seq[String]]) + def bindTogether(queue: String, params: Seq[Seq[Option[String]]]) +} + +class SubscriptionChannelManager[Proto](descriptor: SubscriptionDescriptor[Proto], protected val ops: AmqpServiceOperations, protected val exchange: String) + extends SubscriptionChannelBinder + with ChannelBinderImpl + with ModelEventTransformer[Proto] + with Logging { + + import NotificationConversions._ + import SubscriptionChannelManager._ + + ops.declareExchange(exchange) + + def toMessage(typ: ModelEvent, event: Proto): AmqpAddressedMessage = { + val key = makeKey(descriptor.keyParts(event)) + val notification = descriptor.notification(protoType(typ), event) + AmqpAddressedMessage(exchange, key, AmqpMessage(notification, None, None)) + } +} + +class SubscriptionChannelBinderOnly(protected val ops: AmqpServiceOperations, protected val exchange: String) extends SubscriptionChannelBinder with ChannelBinderImpl { + + ops.declareExchange(exchange) +} + +trait ChannelBinderImpl extends Logging { + protected val ops: AmqpServiceOperations + protected val exchange: String + + import SubscriptionChannelManager._ + + def bindAll(queue: String) { + logger.debug(s"Binding queue $queue to exchange $exchange with key #") + ops.bindQueue(queue, exchange, "#") + } + + def bindEach(queue: String, params: Seq[Seq[String]]): Unit = { + val keys = routingKeyPermutations(params) + + if (keys.isEmpty) { + bindAll(queue) + } else { + logger.debug(s"Binding queue $queue to exchange $exchange with keys: " + keys) + keys.foreach(key => ops.bindQueue(queue, exchange, key)) + } + } + + def bindTogether(queue: String, params: Seq[Seq[Option[String]]]): Unit = { + val keys = params.map(toRoutingKey) + + if (keys.isEmpty) { + bindAll(queue) + } else { + logger.debug(s"Binding queue $queue to exchange $exchange with keys: " + keys) + keys.foreach(key => ops.bindQueue(queue, exchange, key)) + } + + } +} + +trait ModelEventTransformer[A] { + def toMessage(typ: ModelEvent, event: A): AmqpAddressedMessage +} + +sealed trait ModelEvent +case object Created extends ModelEvent +case object Updated extends ModelEvent +case object Deleted extends ModelEvent + +trait ModelNotifier { + def notify[A](typ: ModelEvent, event: A) +} + +object NullModelNotifier extends ModelNotifier { + def notify[A](typ: ModelEvent, event: A) {} +} + +class BufferingModelNotifier(mapper: ModelEventMapper, ops: AmqpServiceOperations, observer: (Int, Int) => Unit) extends ModelNotifier { + private val buffer = scala.collection.mutable.ArrayBuffer.empty[AmqpAddressedMessage] + + def notify[A](typ: ModelEvent, event: A) { + mapper.toMessage(typ, event).foreach(buffer +=) + } + + def flush() { + val size = buffer.size + val flushStart = System.currentTimeMillis() + + ops.publishBatch(buffer.toSeq) + + val flushTime = (System.currentTimeMillis() - flushStart).toInt + observer(size, flushTime) + } + +} + +class SimpleModelNotifier(mapper: ModelEventMapper, ops: AmqpServiceOperations, observer: (Int, Int) => Unit) extends ModelNotifier { + def notify[A](typ: ModelEvent, event: A) { + mapper.toMessage(typ, event).foreach { msg => + ops.publishEvent(msg.exchange, msg.message.msg, msg.key) + } + } +} + +class ModelEventMapper extends Logging { + + private val map = mutable.Map.empty[Class[_], ModelEventTransformer[_]] + private val missed = mutable.Set.empty[Class[_]] + + def register[A](klass: Class[A], publisher: ModelEventTransformer[A]) { + map.update(klass, publisher) + } + + def toMessage[A](typ: ModelEvent, event: A): Option[AmqpAddressedMessage] = { + val klass = event.getClass + val transformerOpt = map.get(klass) + if (transformerOpt.isEmpty && !missed.contains(klass)) { + logger.warn("No event publisher for class: " + klass) + missed += klass + } + transformerOpt.map(trans => trans.asInstanceOf[ModelEventTransformer[A]].toMessage(typ, event)) + } +} diff --git a/services/src/main/scala/io/greenbus/services/framework/TypedServiceHandler.scala b/services/src/main/scala/io/greenbus/services/framework/TypedServiceHandler.scala new file mode 100644 index 0000000..c783896 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/framework/TypedServiceHandler.scala @@ -0,0 +1,38 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.framework + +import io.greenbus.client.proto.Envelope + +sealed trait Response[+A] +case class Failure(status: Envelope.Status, message: String) extends Response[Nothing] +case class Success[A](status: Envelope.Status, response: A) extends Response[A] + +trait TypedServiceHandler[A, B] { + def handle(request: A, headers: Map[String, String], responseHandler: Response[B] => Unit) +} + +trait SyncTypedServiceHandler[A, B] extends TypedServiceHandler[A, B] { + + def handle(request: A, headers: Map[String, String], responseHandler: (Response[B]) => Unit) { + responseHandler(handle(request, headers)) + } + + def handle(request: A, headers: Map[String, String]): Response[B] +} diff --git a/services/src/main/scala/io/greenbus/services/model/AuthModel.scala b/services/src/main/scala/io/greenbus/services/model/AuthModel.scala new file mode 100644 index 0000000..4752a34 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/model/AuthModel.scala @@ -0,0 +1,693 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.model + +import java.util.UUID + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.client.service.proto.Auth.{ Agent, Permission, PermissionSet } +import io.greenbus.client.service.proto.Model.ModelUUID +import io.greenbus.services.SqlAuthenticationModule +import io.greenbus.services.data.ServicesSchema._ +import io.greenbus.services.data._ +import io.greenbus.services.framework._ +import io.greenbus.services.model.UUIDHelpers._ +import org.squeryl.PrimitiveTypeMode._ +import org.squeryl.dsl.ast.{ LogicalBoolean, ExpressionNode } + +import scala.collection.JavaConversions._ + +object AuthModel { + val permissionSetType = "PermissionSet" + val agentType = "Agent" + + case class AgentInfo(passwordOpt: Option[String], permissionSets: Seq[String]) + case class PermissionSetInfo(permissions: Seq[Permission]) + + case class CoreUntypedTemplate[ID, A](idOpt: Option[ID], name: String, info: A) + case class IdentifiedUntypedTemplate[ID, A](id: ID, name: String, info: A) + case class NamedUntypedTemplate[A](name: String, info: A) + + def splitTemplates[ID, A](templates: Seq[CoreUntypedTemplate[ID, A]]): (Seq[IdentifiedUntypedTemplate[ID, A]], Seq[NamedUntypedTemplate[A]]) = { + val (hasIds, noIds) = templates.partition(_.idOpt.nonEmpty) + + val withIds = hasIds.map(t => IdentifiedUntypedTemplate(t.idOpt.get, t.name, t.info)) + val withNames = noIds.map(t => NamedUntypedTemplate(t.name, t.info)) + + (withIds, withNames) + } +} + +trait AuthModel { + import io.greenbus.services.model.AuthModel._ + + def agentIdForName(name: String): Option[UUID] + def createTokenAndStore(agentId: UUID, expiration: Long, clientVersion: String, location: String): String + + def simpleLogin(name: String, password: String, expiration: Long, clientVersion: String, location: String): Either[String, (String, ModelUUID)] + def simpleLogout(token: String) + def authValidate(token: String): Boolean + + def agentKeyQuery(uuids: Seq[UUID], names: Seq[String]): Seq[Agent] + def agentQuery(self: Option[UUID], setNames: Seq[String], lastUuid: Option[UUID], lastName: Option[String], pageSize: Int, pageByName: Boolean = true): Seq[Agent] + def putAgents(notifier: ModelNotifier, templates: Seq[CoreUntypedTemplate[UUID, AgentInfo]], allowCreate: Boolean = true): Seq[Agent] + def modifyAgentPasswords(updates: Seq[(UUID, String)]): Seq[Agent] + def deleteAgents(notifier: ModelNotifier, ids: Seq[UUID]): Seq[Agent] + + def permissionSetKeyQuery(ids: Seq[Long], names: Seq[String]): Seq[PermissionSet] + def permissionSetQuery(last: Option[Long], pageSize: Int): Seq[PermissionSet] + def permissionSetSelfKeyQuery(self: UUID, ids: Seq[Long], names: Seq[String]): Seq[PermissionSet] + def permissionSetSelfQuery(self: UUID, last: Option[Long], pageSize: Int): Seq[PermissionSet] + def putPermissionSets(notifier: ModelNotifier, templates: Seq[CoreUntypedTemplate[Long, PermissionSetInfo]], allowCreate: Boolean = true): Seq[PermissionSet] + def deletePermissionSets(notifier: ModelNotifier, ids: Seq[Long]): Seq[PermissionSet] +} + +class SquerylAuthModule extends SqlAuthenticationModule { + def authenticate(name: String, password: String /*, expiration: Long, clientVersion: String, location: String*/ ): (Boolean, Option[UUID]) = { + val agentOpt = from(agents)(agent => + where(agent.name === name) + select (agent)).toSeq.headOption + + agentOpt match { + case None => (false, None) + case Some(agent) => + agent.checkPassword(password) match { + case false => (false, Some(agent.id)) + case true => (true, Some(agent.id)) + } + } + + } +} + +object SquerylAuthModel extends AuthModel with Logging { + import io.greenbus.services.model.AuthModel._ + + def agentIdForName(name: String): Option[UUID] = { + from(agents)(agent => + where(agent.name === name) + select (agent)).toSeq.headOption.map(_.id) + } + + def createTokenAndStore(agentId: UUID, expiration: Long, clientVersion: String, location: String): String = { + // Random UUID is cryptographically sound and unguessable + // http://docs.oracle.com/javase/1.5.0/docs/api/java/util/UUID.html#randomUUID() + val authToken = UUID.randomUUID().toString + val issueTime = System.currentTimeMillis() + + val tokenRow = authTokens.insert(AuthTokenRow(0, authToken, agentId, location, clientVersion, false, issueTime, expiration)) + + val agentPermissions = from(agentSetJoins)(j => where(j.agentId === agentId) select (j.permissionSetId)).toSeq + + val joinInserts = agentPermissions.map(AuthTokenPermissionSetJoinRow(_, tokenRow.id)) + tokenSetJoins.insert(joinInserts) + + authToken + } + + def simpleLogin(name: String, password: String, expiration: Long, clientVersion: String, location: String): Either[String, (String, ModelUUID)] = { + + val agentOpt = from(agents)(agent => + where(agent.name === name) + select (agent)).toSeq.headOption + + agentOpt match { + case None => Left("Invalid username or password") + case Some(agent) => { + agent.checkPassword(password) match { + case false => Left("Invalid username or password") + case true => { + // Random UUID is cryptographically sound and unguessable + // http://docs.oracle.com/javase/1.5.0/docs/api/java/util/UUID.html#randomUUID() + val authToken = UUID.randomUUID().toString + val issueTime = System.currentTimeMillis() + + val tokenRow = authTokens.insert(AuthTokenRow(0, authToken, agent.id, location, clientVersion, false, issueTime, expiration)) + + val agentPermissions = from(agentSetJoins)(j => where(j.agentId === agent.id) select (j.permissionSetId)).toSeq + + val joinInserts = agentPermissions.map(AuthTokenPermissionSetJoinRow(_, tokenRow.id)) + tokenSetJoins.insert(joinInserts) + Right((authToken, uuidToProtoUUID(agent.id))) + } + } + } + } + } + + def simpleLogout(token: String) { + val current = authTokens.where(t => t.token === token).headOption + + current match { + case None => + case Some(tokenRow) => + if (!tokenRow.revoked) { + authTokens.update(tokenRow.copy(revoked = true)) + } + } + } + + private def lookupToken(token: String): Option[(AuthTokenRow, UUID, String, Seq[Array[Byte]])] = { + val now = System.currentTimeMillis + + val tokenAndSets = join(authTokens, permissionSets.leftOuter)((tok, set) => + where(tok.token === token and + (tok.expirationTime gt now) and + tok.revoked === false) + select (tok, set.map(_.protoBytes)) + on (set.map(_.id) in + from(tokenSetJoins)(j => + where(j.authTokenId === tok.id) + select (j.permissionSetId)))).toSeq + + val authTokenRow = tokenAndSets.headOption.map(_._1) + + authTokenRow.flatMap { tokenRow => + val permBytes = tokenAndSets.flatMap(_._2) + val resultOpt = + from(agents)(a => + where(a.id === tokenRow.agentId) + select (a.id, a.name)).headOption + + if (resultOpt.isEmpty) { + logger.warn("Auth token retrieved without associated agent") + } + + resultOpt.map { + case (agentId, agentName) => + (tokenRow, agentId, agentName, permBytes) + } + } + } + + def authLookup(token: String): Option[(UUID, String, Seq[Array[Byte]])] = { + lookupToken(token).map { + case (_, uuid, name, perms) => (uuid, name, perms) + } + } + + def authValidate(token: String): Boolean = { + lookupToken(token).nonEmpty + } + + def agentKeyQuery(uuids: Seq[UUID], names: Seq[String]): Seq[Agent] = { + + val agentInfo: Seq[(UUID, String)] = + from(agents)(agent => + where((agent.id in uuids) or (agent.name in names)) + select ((agent.id, agent.name))).toSeq + + val agentIds = agentInfo.map(_._1) + + val agentIdToSetNames: Map[UUID, Seq[String]] = + from(agentSetJoins, permissionSets)((j, set) => + where((j.agentId in agentIds) and + j.permissionSetId === set.id) + select (j.agentId, set.name)) + .groupBy(_._1) + .toMap + .mapValues(_.map(_._2).toSeq) + + agentInfo.map { + case (uuid, name) => + val sets = agentIdToSetNames.getOrElse(uuid, Seq()) + Agent.newBuilder() + .setUuid(uuid) + .setName(name) + .addAllPermissionSets(sets) + .build + } + + /* + Runtime error ?!?! + val results: Seq[((UUID, String), Option[String])] = + from(entityQuery, agents, entities.leftOuter)((ent, agent, setEnt) => + where(ent.id === agent.entityId and + (setEnt.map(_.id) in from(agentSetJoins, permissionSets)((j, set) => + where(j.agentId === agent.id and j.permissionSetId === set.id) + select(set.entityId)))) + select((ent.id, ent.name), setEnt.map(_.name)) + orderBy(ent.id)).toSeq + + val grouped: Seq[((UUID, String), Seq[String])] = groupSortedAndPreserveOrder(results) map { tup => (tup._1, tup._2.flatten) } + + grouped.map { + case ((uuid, name), sets) => + Agent.newBuilder() + .setUuid(uuid) + .setName(name) + .addAllPermissionSets(sets) + .build + } */ + } + + def agentQuery(self: Option[UUID], setNames: Seq[String], lastUuid: Option[UUID], lastName: Option[String], pageSize: Int, pageByName: Boolean = true): Seq[Agent] = { + + def ordering(ag: AgentRow): ExpressionNode = { + if (pageByName) { + ag.name + } else { + ag.id + } + } + + def guardFunc(lastUuid: Option[UUID], lastName: Option[String], pageByName: Boolean = true): AgentRow => LogicalBoolean = { + if (pageByName) { + + val nameOpt = lastName match { + case Some(name) => Some(name) + case None => + lastUuid match { + case Some(uuid) => entities.where(t => t.id === uuid).headOption.map(_.name) + case None => None + } + } + + def pageGuard(ag: AgentRow) = ag.name gt nameOpt.? + pageGuard + + } else { + + val uuidOpt = lastUuid match { + case Some(uuid) => Some(uuid) + case None => + lastName match { + case Some(name) => entities.where(t => t.name === name).headOption.map(_.id) + case None => None + } + } + + def pageGuard(ag: AgentRow) = ag.id > uuidOpt.? + pageGuard + } + } + + val guard = guardFunc(lastUuid, lastName, pageByName) + + val agentResults: Seq[(UUID, String)] = if (setNames.nonEmpty) { + from(agents, agentSetJoins, permissionSets)((agent, j, set) => + where( + guard(agent) and + agent.id === self.? and + j.agentId === agent.id and + j.permissionSetId === set.id and + (set.name in setNames).inhibitWhen(setNames.isEmpty)) + select (agent.id, agent.name) + orderBy (ordering(agent))) + .page(0, pageSize) + .toVector + .distinct + } else { + from(agents)(agent => + where(guard(agent) and + agent.id === self.?) + select (agent.id, agent.name) + orderBy (ordering(agent))) + .page(0, pageSize) + .toVector + .distinct + } + + val agentIds = agentResults.map(_._1).toSet + + val agentUuidToSetNames: Map[UUID, Seq[String]] = + from(agents, agentSetJoins, permissionSets)((agent, j, set) => + where( + (agent.id in agentIds) and + j.agentId === agent.id and + j.permissionSetId === set.id) + select (agent.id, set.name)) + .groupBy(_._1) + .toMap + .mapValues(_.map(_._2).toSeq) + + agentResults.map { + case (uuid, name) => + val sets = agentUuidToSetNames.getOrElse(uuid, Nil) + Agent.newBuilder() + .setUuid(uuid) + .setName(name) + .addAllPermissionSets(sets) + .build + } + + } + + private def agentsByKey(uuids: Seq[UUID], names: Seq[String]): Seq[(AgentRow, Seq[String])] = { + val agentInfo: Seq[AgentRow] = + from(agents)(agent => + where((agent.id in uuids) or (agent.name in names)) + select (agent)).toVector + + val agentIds = agentInfo.map(_.id) + + val agentIdToSetNames: Map[UUID, Seq[String]] = + from(agentSetJoins, permissionSets)((j, set) => + where((j.agentId in agentIds) and + j.permissionSetId === set.id) + select (j.agentId, set.name)) + .groupBy(_._1) + .toMap + .mapValues(_.map(_._2).toVector) + + agentInfo.map { agent => + val sets = agentIdToSetNames.getOrElse(agent.id, Seq()) + (agent, sets) + } + } + + def putAgents(notifier: ModelNotifier, templates: Seq[CoreUntypedTemplate[UUID, AgentInfo]], allowCreate: Boolean = true): Seq[Agent] = { + + // For any given agent, three things can change: the entity name/types, the agent table entry (password) or the permission set joins + // We do the first two with common code and handle the latter manually + + val allSetNames = templates.flatMap(_.info.permissionSets).toSet + + val existingSets = if (allSetNames.nonEmpty) { + from(permissionSets)(perm => + where(perm.name in allSetNames) + select (perm.name, perm.id)).toVector + } else { + Seq() + } + + val setMap: Map[String, Long] = existingSets.toMap + + val bogusSets = allSetNames.filterNot(setMap.contains) + if (bogusSets.nonEmpty) { + throw new ModelInputException(s"Permission sets requested do not exist: " + bogusSets.mkString(", ")) + } + + val (withIds, withNames) = AuthModel.splitTemplates(templates) + + val templateIds = withIds.map(_.id) + val templateNames = withNames.map(_.name) + + val existing = agentsByKey(templateIds, templateNames) + + val existingIdSet = existing.map(_._1.id).toSet + val existingNameSet = existing.map(_._1.name).toSet + + val nonexistentByIds = withIds.filterNot(t => existingIdSet.contains(t.id)) + val nonexistentByNames = withNames.filterNot(t => existingNameSet.contains(t.name)) + + if ((nonexistentByIds.nonEmpty || nonexistentByNames.nonEmpty) && !allowCreate) { + throw new ModelPermissionException("Must have blanket create permissions to create entities") + } + + val withIdsById: Map[UUID, IdentifiedUntypedTemplate[UUID, AgentInfo]] = withIds.map(temp => (temp.id, temp)).toMap + val withNamesByName: Map[String, NamedUntypedTemplate[AgentInfo]] = withNames.map(temp => (temp.name, temp)).toMap + + val agentUpdates: Seq[(UUID, Option[AgentRow], Set[String], Set[String])] = existing.flatMap { + case (row, permSets) => + + val optTemp: Option[(String, AgentInfo)] = withIdsById.get(row.id).map(temp => (temp.name, temp.info)) + .orElse(withNamesByName.get(row.name).map(temp => (temp.name, temp.info))) + + optTemp.flatMap { + case (updateName, updateInfo) => + + val passwordUpdatedOpt = updateInfo.passwordOpt.map(pass => row.withUpdatedPassword(pass)) + + val passwordChanged = passwordUpdatedOpt.exists(updateRow => updateRow.digest != row.digest || updateRow.salt != row.salt) + + val existPermsSet = permSets.toSet + val updatePermsSet = updateInfo.permissionSets.toSet + + val permAdds = updatePermsSet diff existPermsSet + val permRems = existPermsSet diff updatePermsSet + + if (updateName != row.name || passwordChanged) { + val startFrom = passwordUpdatedOpt.getOrElse(row) + Some((row.id, Some(startFrom.copy(name = updateName)), permAdds, permRems)) + } else { + if (permAdds.isEmpty && permRems.isEmpty) { + None + } else { + Some((row.id, None, permAdds, permRems)) + } + } + } + } + + val toCreateByIds = nonexistentByIds.map { temp => + val password = temp.info.passwordOpt.getOrElse { throw new ModelInputException(s"Password must be provided for non-existent agent ${temp.name}") } + val permAdds = temp.info.permissionSets.map(perm => (temp.id, perm)) + (AgentRow(temp.id, temp.name, password), permAdds) + } + + val toCreateByNames = nonexistentByNames.map { temp => + val uuid = UUID.randomUUID() + val password = temp.info.passwordOpt.getOrElse { throw new ModelInputException(s"Password must be provided for non-existent agent ${temp.name}") } + val permAdds = temp.info.permissionSets.map(perm => (uuid, perm)) + (AgentRow(uuid, temp.name, password), permAdds) + } + + val toCreateTupAll = toCreateByIds ++ toCreateByNames + + val createRowInserts = toCreateTupAll.map(_._1) + val createPermAdds = toCreateTupAll.flatMap(_._2) + + val rowUpdates = agentUpdates.flatMap(_._2) + val updatePermAdds = agentUpdates.flatMap(tup => tup._3.map(perm => (tup._1, perm))) + val updatePermRems = agentUpdates.flatMap(tup => tup._4.map(perm => (tup._1, perm))) + + val allPermAdds = createPermAdds ++ updatePermAdds + + val permAddRows = allPermAdds.map { + case (uuid, permName) => + val permId = setMap.getOrElse(permName, throw new ModelAssertionException(s"Could not find ID for permission $permName")) + AgentPermissionSetJoinRow(permId, uuid) + } + + agents.insert(createRowInserts) + + agents.update(rowUpdates) + + agentSetJoins.insert(permAddRows) + + val permsToBeDeleted = updatePermRems.map(_._2) + + val delSetMap: Map[String, Long] = + from(permissionSets)(set => + where(set.name in permsToBeDeleted) + select ((set.name, set.id))).toVector.toMap + + val groupedPermRems: Map[UUID, Seq[String]] = updatePermRems.groupBy(_._1).mapValues(_.map(_._2)) + + groupedPermRems.foreach { + case (uuid, names) => + val permIds = names.flatMap(delSetMap.get) + agentSetJoins.deleteWhere(j => j.agentId === uuid and (j.permissionSetId in permIds)) + } + + val results = agentKeyQuery(templateIds, templateNames) + + val createIds = toCreateTupAll.map(_._1.id).toSet + val updateIds = agentUpdates.map(_._1).toSet + + val created = results.filter(r => createIds.contains(protoUUIDToUuid(r.getUuid))) + val updated = results.filter(r => updateIds.contains(protoUUIDToUuid(r.getUuid))) + + created.foreach(notifier.notify(Created, _)) + updated.foreach(notifier.notify(Updated, _)) + + results + } + + def modifyAgentPasswords(updates: Seq[(UUID, String)]): Seq[Agent] = { + val uuids = updates.map(_._1).distinct + + val all = agents.where(a => a.id in uuids).toVector + + val agentByUuid: Map[UUID, AgentRow] = all.map(a => (a.id, a)).toMap + + val updatedRows = updates.map { + case (uuid, pass) => + val row = agentByUuid.getOrElse(uuid, throw new ModelInputException("Agent did not exist")) + row.withUpdatedPassword(pass) + } + + agents.update(updatedRows) + + agentKeyQuery(uuids, Seq()) + } + + def deleteAgents(notifier: ModelNotifier, ids: Seq[UUID]): Seq[Agent] = { + + val results = agentKeyQuery(ids, Nil) + + authTokens.deleteWhere(tok => tok.agentId in ids) + agentSetJoins.deleteWhere(j => j.agentId in ids) + agents.deleteWhere(agent => agent.id in ids) + + results.foreach { notifier.notify(Deleted, _) } + + results + } + + def permissionSetSelfKeyQuery(self: UUID, ids: Seq[Long], names: Seq[String]): Seq[PermissionSet] = { + + val results: Seq[(Long, String, Array[Byte])] = + from(permissionSets, agentSetJoins, agents)((set, j, agent) => + where( + agent.id === self and + j.agentId === agent.id and j.permissionSetId === set.id and + ((set.id in ids) or (set.name in names))) + select (set.id, set.name, set.protoBytes)).toVector + + results.map { + case (id, name, bytes) => + PermissionSet.newBuilder(PermissionSet.parseFrom(bytes)) + .setId(id) + .setName(name) + .build() + } + } + + def permissionSetKeyQuery(ids: Seq[Long], names: Seq[String]): Seq[PermissionSet] = { + + val results: Seq[(Long, String, Array[Byte])] = + from(permissionSets)(set => + where((set.id in ids) or (set.name in names)) + select (set.id, set.name, set.protoBytes)).toVector + + results.map { + case (id, name, bytes) => + PermissionSet.newBuilder(PermissionSet.parseFrom(bytes)) + .setId(id) + .setName(name) + .build() + } + } + + def permissionSetSelfQuery(self: UUID, last: Option[Long], pageSize: Int): Seq[PermissionSet] = { + + val results = from(permissionSets, agentSetJoins, agents)((set, j, agent) => + where(set.id > last.? and + agent.id === self and + j.agentId === agent.id and + j.permissionSetId === set.id) + select (set) + orderBy (set.id)) + .page(0, pageSize) + .toVector + + results.map { + case (row) => + PermissionSet.newBuilder(PermissionSet.parseFrom(row.protoBytes)) + .setId(row.id) + .setName(row.name) + .build() + } + } + + def permissionSetQuery(last: Option[Long], pageSize: Int): Seq[PermissionSet] = { + + val results = from(permissionSets)(set => + where(set.id > last.?) + select (set) + orderBy (set.id)) + .page(0, pageSize) + .toVector + + results.map { + case (row) => + PermissionSet.newBuilder(PermissionSet.parseFrom(row.protoBytes)) + .setId(row.id) + .setName(row.name) + .build() + } + } + + def putPermissionSets(notifier: ModelNotifier, templates: Seq[CoreUntypedTemplate[Long, PermissionSetInfo]], allowCreate: Boolean = true): Seq[PermissionSet] = { + + val (withIds, withNames) = AuthModel.splitTemplates(templates) + + val templateIds = withIds.map(_.id) + val templateNames = withNames.map(_.name) + + val existing = permissionSets.where(set => (set.id in templateIds) or (set.name in templateNames)).toVector + + val existingIdSet = existing.map(_.id).toSet + val existingNameSet = existing.map(_.name).toSet + + val nonexistentByIds = withIds.filterNot(t => existingIdSet.contains(t.id)) + val nonexistentByNames = withNames.filterNot(t => existingNameSet.contains(t.name)) + + if (nonexistentByIds.nonEmpty) { + throw new ModelInputException("Cannot create new permission sets with an ID") + } + + if (nonexistentByNames.nonEmpty && !allowCreate) { + throw new ModelPermissionException("Must have blanket create permissions to create entities") + } + + val withIdsById: Map[Long, IdentifiedUntypedTemplate[Long, PermissionSetInfo]] = withIds.map(temp => (temp.id, temp)).toMap + val withNamesByName: Map[String, NamedUntypedTemplate[PermissionSetInfo]] = withNames.map(temp => (temp.name, temp)).toMap + + val updates = existing.flatMap { row => + val optTemp: Option[(String, PermissionSetInfo)] = withIdsById.get(row.id).map(temp => (temp.name, temp.info)) + .orElse(withNamesByName.get(row.name).map(temp => (temp.name, temp.info))) + + optTemp.flatMap { + case (updateName, info) => + val updateBytes = PermissionSet.newBuilder().addAllPermissions(info.permissions).build().toByteArray + + if (updateName != row.name || !java.util.Arrays.equals(row.protoBytes, updateBytes)) { + Some(row.copy(name = updateName, protoBytes = updateBytes)) + } else { + None + } + } + } + + val createRows = nonexistentByNames.map { temp => + val bytes = PermissionSet.newBuilder().addAllPermissions(temp.info.permissions).build().toByteArray + PermissionSetRow(0, temp.name, bytes) + } + + permissionSets.update(updates) + permissionSets.insert(createRows) + + val createNames = createRows.map(_.name).toSet + val updateIds = updates.map(_.id).toSet + + val results = permissionSetKeyQuery(templateIds, templateNames) + + results.filter(set => createNames.contains(set.getName)).foreach(notifier.notify(Created, _)) + results.filter(set => updateIds.contains(protoIdToLong(set.getId))).foreach(notifier.notify(Updated, _)) + + results + } + + def deletePermissionSets(notifier: ModelNotifier, ids: Seq[Long]): Seq[PermissionSet] = { + + val results = permissionSetKeyQuery(ids, Seq()) + + permissionSets.deleteWhere(ps => ps.id in ids) + agentSetJoins.deleteWhere(j => j.permissionSetId in ids) + tokenSetJoins.deleteWhere(j => j.permissionSetId in ids) + + results.foreach(notifier.notify(Deleted, _)) + + results + } + +} diff --git a/services/src/main/scala/io/greenbus/services/model/AuthSeeding.scala b/services/src/main/scala/io/greenbus/services/model/AuthSeeding.scala new file mode 100644 index 0000000..5bd4e7c --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/model/AuthSeeding.scala @@ -0,0 +1,95 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.model + +import java.util.UUID + +import io.greenbus.client.service.proto.Auth.{ Agent, EntitySelector, Permission, PermissionSet } +import io.greenbus.services.framework.NullModelNotifier +import io.greenbus.services.model.AuthModel.{ AgentInfo, CoreUntypedTemplate, PermissionSetInfo } + +trait AuthSeeder { + + def addPermissionSets(descs: Seq[(String, Seq[Permission])]): Seq[PermissionSet] + + def addUsers(descs: Seq[(String, String, Seq[String])]): Seq[Agent] +} + +object ModelAuthSeeder extends AuthSeeder { + + def addPermissionSets(descs: Seq[(String, Seq[Permission])]): Seq[PermissionSet] = { + val templates = descs.map { + case (name, perms) => + CoreUntypedTemplate(Option.empty[Long], name, PermissionSetInfo(perms)) + } + + SquerylAuthModel.putPermissionSets(NullModelNotifier, templates) + } + + def addUsers(descs: Seq[(String, String, Seq[String])]): Seq[Agent] = { + val templates = descs.map { + case (name, pass, perms) => CoreUntypedTemplate(Option.empty[UUID], name, AgentInfo(Some(pass), perms)) + } + + SquerylAuthModel.putAgents(NullModelNotifier, templates) + } +} + +object AuthSeedData { + + private def makeSelector(style: String, arguments: List[String] = Nil) = { + val b = EntitySelector.newBuilder.setStyle(style) + arguments.foreach(b.addArguments) + b.build + } + + private def makePermission(allow: Boolean, verbs: List[String] = List("*"), resources: List[String] = List("*"), selectors: List[EntitySelector] = Nil) = { + val b = Permission.newBuilder.setAllow(allow) + verbs.foreach(b.addActions) + resources.foreach(b.addResources) + if (selectors.isEmpty) b.addSelectors(makeSelector("*")) + else selectors.foreach(b.addSelectors) + b.build + } + + def seed(seeder: AuthSeeder, systemPassword: String) { + val all = makePermission(true) + val readOnly = makePermission(true, List("read")) + + val selfSelector = makeSelector("self") + + val readAndDeleteOwnTokens = makePermission(true, List("read", "delete"), List("auth_token"), List(selfSelector)) + val denyAuthTokens = makePermission(false, List("read", "delete"), List("auth_token")) + + var permSetDescs = List.empty[(String, Seq[Permission])] + + permSetDescs ::= ("read_only", List(readAndDeleteOwnTokens, denyAuthTokens, readOnly)) + permSetDescs ::= ("all", List(all)) + seeder.addPermissionSets(permSetDescs) + + val standardRoles = List("user_role", "system_viewer") + + val agentDescs = List( + ("system", systemPassword, List("all")), + ("guest", systemPassword, List("read_only"))) + + seeder.addUsers(agentDescs) + + } +} diff --git a/services/src/main/scala/io/greenbus/services/model/CommandModel.scala b/services/src/main/scala/io/greenbus/services/model/CommandModel.scala new file mode 100644 index 0000000..8e07d80 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/model/CommandModel.scala @@ -0,0 +1,327 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.model + +import org.squeryl.PrimitiveTypeMode._ +import io.greenbus.services.data.ServicesSchema._ +import io.greenbus.services.data._ +import UUIDHelpers._ +import java.util.UUID +import scala.collection.JavaConversions._ +import io.greenbus.services.framework.{ Deleted, Created, ModelNotifier } +import io.greenbus.services.authz.EntityFilter +import io.greenbus.client.service.proto.Commands.CommandLock +import org.squeryl.Query + +class CommandLockException(msg: String) extends ModelInputException(msg) + +trait CommandModel { + + def lockKeyQuery(lockIds: Seq[Long], agentFilter: Option[EntityFilter] = None): Seq[CommandLock] + def lockQuery(cmdIds: Seq[UUID], agents: Seq[UUID], accessMode: Option[CommandLock.AccessMode], lastId: Option[Long], pageSize: Int, agentFilter: Option[EntityFilter] = None): Seq[CommandLock] + + def agentHasSelect(cmd: UUID, agent: UUID, filter: Option[EntityFilter] = None): Boolean + def agentCanIssueCommand(cmd: UUID, agent: UUID, filter: Option[EntityFilter] = None): Boolean + + def selectCommands(notifier: ModelNotifier, cmdIds: Seq[UUID], agent: UUID, expireTime: Long, filter: Option[EntityFilter] = None): CommandLock + def blockCommands(notifier: ModelNotifier, cmdIds: Seq[UUID], agent: UUID, filter: Option[EntityFilter] = None): CommandLock + + def deleteLocks(notifier: ModelNotifier, lockIds: Seq[Long], agentFilter: Option[UUID] = None): Seq[CommandLock] +} + +object SquerylCommandModel extends CommandModel { + import ModelHelpers._ + + private def findApplicableLocks(cmd: UUID, agent: UUID, filter: Option[EntityFilter] = None) = { + filter.foreach { filt => + if (filt.isOutsideSet(cmd)) { + throw new ModelPermissionException("Tried to lookup restricted commands") + } + } + + val now = System.currentTimeMillis + + from(commands, locks, lockJoins)((c, l, j) => + where((c.entityId === cmd) and + l.agent === agent and + j.commandId === c.id and + j.lockId === l.id and + (l.expireTime.isNull or (l.expireTime gt now))) + select (l)).toVector + } + + def agentCanIssueCommand(cmd: UUID, agent: UUID, filter: Option[EntityFilter] = None): Boolean = { + + filter.foreach { filt => + if (filt.isOutsideSet(cmd)) { + throw new ModelPermissionException("Tried to lookup restricted commands") + } + } + + val now = System.currentTimeMillis + + val locksForCommands = from(commands, locks, lockJoins)((c, l, j) => + where((c.entityId === cmd) and + j.commandId === c.id and + j.lockId === l.id and + (l.expireTime.isNull or (l.expireTime gt now))) + select (l)).toVector + + (locksForCommands.isEmpty || + locksForCommands.exists(lock => lock.access == CommandLock.AccessMode.ALLOWED.getNumber && lock.agent == agent)) && + !locksForCommands.exists(lock => lock.access == CommandLock.AccessMode.BLOCKED.getNumber) + } + + def agentHasSelect(cmd: UUID, agent: UUID, filter: Option[EntityFilter] = None): Boolean = { + + val applicableLocks = findApplicableLocks(cmd, agent, filter) + + applicableLocks.exists(lock => lock.access == CommandLock.AccessMode.ALLOWED.getNumber && lock.agent == agent) && + !applicableLocks.exists(lock => lock.access == CommandLock.AccessMode.BLOCKED.getNumber) + } + + def lockKeyQuery(lockIds: Seq[Long], agentFilter: Option[EntityFilter] = None): Seq[CommandLock] = { + + val results: Seq[(CommandLockRow, Option[UUID])] = + join(locks, commands.leftOuter)((lock, cmd) => + where(EntityFilter.optional(agentFilter, lock.agent).inhibitWhen(agentFilter.isEmpty) and + (lock.id in lockIds)) + select (lock, cmd.map(_.entityId)) + orderBy (lock.id) + on (cmd.map(_.id) in from(lockJoins)(j => where(j.lockId in lockIds) select (j.commandId)))).toSeq + + val grouped = groupSortedAndPreserveOrder(results) + + grouped.map { + case (row, optCmdList) => + val b = CommandLock.newBuilder() + .setId(row.id) + .setAccess(CommandLock.AccessMode.valueOf(row.access)) + .addAllCommandUuids(optCmdList.flatten.map(uuidToProtoUUID)) + .setAgentUuid(row.agent) + + row.expireTime.foreach(b.setExpireTime) + + b.build() + } + + } + + def lockQuery(cmdIds: Seq[UUID], agents: Seq[UUID], accessMode: Option[CommandLock.AccessMode], lastId: Option[Long], pageSize: Int, agentFilter: Option[EntityFilter] = None): Seq[CommandLock] = { + + val idQuery: Query[Long] = if (cmdIds.isEmpty) { + + from(locks)((lock) => + where(EntityFilter.optional(agentFilter, lock.agent).inhibitWhen(agentFilter.isEmpty) and + (lock.agent in agents).inhibitWhen(agents.isEmpty) and + (Some(lock.access) === accessMode.map(_.getNumber)).inhibitWhen(accessMode.isEmpty) and + (lock.id gt lastId.?)) + select (lock.id)).page(0, pageSize) + + } else { + + from(locks, lockJoins, commands)((lock, lj, cmd) => + where(EntityFilter.optional(agentFilter, lock.agent).inhibitWhen(agentFilter.isEmpty) and + (lock.id === lj.lockId and cmd.id === lj.commandId) and + (lock.agent in agents).inhibitWhen(agents.isEmpty) and + (Some(lock.access) === accessMode.map(_.getNumber)).inhibitWhen(accessMode.isEmpty) and + (cmd.entityId in cmdIds) and + (lock.id gt lastId.?)) + select (lock.id)).page(0, pageSize) + } + + val lockIdList = idQuery.toList + + val query: Query[(CommandLockRow, UUID)] = + join(locks, commands)((lock, cmd) => + where(lock.id in lockIdList) + select (lock, cmd.entityId) + orderBy (lock.id) + on (cmd.id in from(lockJoins)(j => where(j.lockId === lock.id) select (j.commandId)))) + + val results: Seq[(CommandLockRow, UUID)] = query.toSeq + + val locksWithCmds: Seq[(CommandLockRow, Seq[UUID])] = groupSortedAndPreserveOrder(results.toList) + + /*val locksWithCmds: Seq[(CommandLockRow, Seq[UUID])] = if (cmdIds.isEmpty) { + + val idQuery: Query[Long] = + from(locks)((lock) => + where(EntityFilter.optional(agentFilter, lock.agent).inhibitWhen(agentFilter.isEmpty) and + (lock.agent in agents).inhibitWhen(agents.isEmpty) and + (Some(lock.access) === accessMode.map(_.getNumber)).inhibitWhen(accessMode.isEmpty) and + (lock.id gt lastId.?)) + select (lock.id)).page(0, pageSize) + + println(idQuery) + + val lockIdList = idQuery.toList + + val query: Query[(CommandLockRow, UUID)] = + join(locks, commands)((lock, cmd) => + where(lock.id in lockIdList /*lks*/ /*from(lockQuery)(l => select(l.id)).page(0, pageSize)*/) + select (lock, cmd.entityId) + orderBy (lock.id) + on (cmd.id in from(lockJoins)(j => where(j.lockId === lock.id) select (j.commandId)))) + + println(query) + + val results: Seq[(CommandLockRow, UUID)] = query.toSeq + + groupSortedAndPreserveOrder(results.toList) + + } else { + + val lockQuery: Query[CommandLockRow] = + from(locks, lockJoins, commands)((lock, lj, cmd) => + where(EntityFilter.optional(agentFilter, lock.agent).inhibitWhen(agentFilter.isEmpty) and + (lock.id === lj.lockId and cmd.id === lj.commandId) and + (lock.agent in agents).inhibitWhen(agents.isEmpty) and + (Some(lock.access) === accessMode.map(_.getNumber)).inhibitWhen(accessMode.isEmpty) and + (cmd.entityId in cmdIds) and + (lock.id gt lastId.?)) + select (lock)).page(0, pageSize) + + val results: Seq[(CommandLockRow, UUID)] = + join(locks, commands)((lock, cmd) => + where(lock.id in from(lockQuery)(l => select(l.id))) + select (lock, cmd.entityId) + orderBy (lock.id) + on (cmd.id in from(lockJoins)(j => where(j.lockId === lock.id) select (j.commandId)))).toSeq + + groupSortedAndPreserveOrder(results.toList) + }*/ + + locksWithCmds.map { + case (row, cmdList) => + val b = CommandLock.newBuilder() + .setId(row.id) + .setAccess(CommandLock.AccessMode.valueOf(row.access)) + .addAllCommandUuids(cmdList.map(uuidToProtoUUID)) + .setAgentUuid(row.agent) + + row.expireTime.foreach(b.setExpireTime) + + b.build() + } + } + + def selectCommands(notifier: ModelNotifier, cmdIds: Seq[UUID], agent: UUID, expireTime: Long, filter: Option[EntityFilter] = None): CommandLock = { + filter.foreach { filt => + if (filt.anyOutsideSet(cmdIds)) { + throw new ModelPermissionException("Tried to lock restricted commands") + } + } + + val lockedCmds = commands.where(c => c.entityId in cmdIds).forUpdate.toSeq + val now = System.currentTimeMillis + + val lockedIds = lockedCmds.map(_.id) + + val existingLocks = + from(locks, lockJoins)((l, j) => + where((j.commandId in lockedIds) and + j.lockId === l.id and + (l.expireTime.isNull or (l.expireTime gt now))) + select (l)).toSeq + + if (existingLocks.nonEmpty) { + throw new CommandLockException("Commands already locked") + } + + val lock = locks.insert(CommandLockRow(0, CommandLock.AccessMode.ALLOWED.getNumber, Some(expireTime), agent)) + + val joins = lockedIds.map(cmdId => LockJoinRow(cmdId, lock.id)) + + lockJoins.insert(joins) + + update(commands)(c => + where(c.id in lockedIds) + set (c.lastSelectId := Some(lock.id))) + + val created = CommandLock.newBuilder() + .setId(lock.id) + .setAccess(CommandLock.AccessMode.ALLOWED) + .setExpireTime(expireTime) + .addAllCommandUuids(lockedCmds.map(_.entityId).map(uuidToProtoUUID)) + .setAgentUuid(agent) + .build() + + notifier.notify(Created, created) + + created + } + + def blockCommands(notifier: ModelNotifier, cmdIds: Seq[UUID], agent: UUID, filter: Option[EntityFilter] = None): CommandLock = { + filter.foreach { filt => + if (filt.anyOutsideSet(cmdIds)) { + throw new ModelPermissionException("Tried to lock restricted commands") + } + } + + val lockedCmds = commands.where(c => c.entityId in cmdIds).forUpdate.toSeq + val lockedIds = lockedCmds.map(_.id) + + val lock = locks.insert(CommandLockRow(0, CommandLock.AccessMode.BLOCKED.getNumber, None, agent)) + + val joins = lockedIds.map(cmdId => LockJoinRow(cmdId, lock.id)) + + lockJoins.insert(joins) + + update(commands)(c => + where(c.id in lockedIds) + set (c.lastSelectId := Some(lock.id))) + + val created = CommandLock.newBuilder() + .setId(lock.id) + .setAccess(CommandLock.AccessMode.BLOCKED) + .addAllCommandUuids(lockedCmds.map(_.entityId).map(uuidToProtoUUID)) + .setAgentUuid(agent) + .build() + + notifier.notify(Created, created) + + created + } + + def deleteLocks(notifier: ModelNotifier, lockIds: Seq[Long], agentFilter: Option[UUID] = None): Seq[CommandLock] = { + + agentFilter.foreach { agentUuid => + val nonOwnedIdCount = + from(locks)(lock => + where(not(lock.agent === agentUuid) and + (lock.id in lockIds)) + select (lock)).page(0, 1).nonEmpty + + if (nonOwnedIdCount) { + throw new ModelPermissionException("Tried to delete restricted command locks") + } + } + + val results = lockKeyQuery(lockIds) + + locks.deleteWhere(lock => lock.id in lockIds) + lockJoins.deleteWhere(j => j.lockId in lockIds) + + results.foreach(notifier.notify(Deleted, _)) + + results + } +} + diff --git a/services/src/main/scala/io/greenbus/services/model/EntityBasedModels.scala b/services/src/main/scala/io/greenbus/services/model/EntityBasedModels.scala new file mode 100644 index 0000000..f92f1f7 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/model/EntityBasedModels.scala @@ -0,0 +1,264 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.model + +import io.greenbus.services.data.{ EntityRow, EntityBased } +import org.squeryl.Table +import org.squeryl.dsl.ast.{ ExpressionNode, LogicalBoolean } +import java.util.UUID +import io.greenbus.services.authz.EntityFilter +import org.squeryl.PrimitiveTypeMode._ +import io.greenbus.services.data.ServicesSchema._ +import io.greenbus.services.framework.{ Deleted, Updated, Created, ModelNotifier } +import io.greenbus.services.model.UUIDHelpers._ +import io.greenbus.services.model.EntityModel.TypeParams +import io.greenbus.client.service.proto.Model.ModelUUID +import io.greenbus.services.model.FrontEndModel.{ NamedEntityTemplate, IdentifiedEntityTemplate } + +object EntityBasedModels { + + def entityBasedQuery[DbType <: EntityBased]( + table: Table[DbType], + typeParams: TypeParams, + rowClause: DbType => LogicalBoolean, + lastUuid: Option[UUID], + lastName: Option[String], + pageSize: Int, + pageByName: Boolean = true, + filter: Option[EntityFilter] = None): Seq[(UUID, String, Seq[String], DbType)] = { + + val guard = SquerylEntityModel.guardFunc(lastUuid, lastName, pageByName) + + def ordering(ent: EntityRow): ExpressionNode = { + if (pageByName) { + ent.name + } else { + ent.id + } + } + + val query = + from(entities, table)((ent, row) => + where(EntityFilter.optional(filter, ent).inhibitWhen(filter.isEmpty) and + guard(ent) and + ent.id === row.entityId and + SquerylEntityModel.entTypesClause(ent, typeParams) and + rowClause(row)) + select (ent.id, ent.name, row) + orderBy (ordering(ent))) + .page(0, pageSize) + + val results: Seq[(UUID, String, DbType)] = query.toVector + + val entIds = results.map(_._1) + + val typeMap: Map[UUID, Seq[String]] = SquerylEntityModel.entitiesWithTypes(entities.where(t => t.id in entIds)).map { case (row, types) => (row.id, types) }.toMap + + results.map { + case (uuid, name, row) => + val types = typeMap.get(uuid).getOrElse(Nil) + (uuid, name, types, row) + } + } + + def entityBasedKeyQuery[A <: EntityBased](uuids: Seq[UUID], names: Seq[String], table: Table[A], filter: Option[EntityFilter] = None): Seq[(UUID, String, Seq[String], A)] = { + + val entQuery = from(entities)(ent => + where(EntityFilter.optional(filter, ent).inhibitWhen(filter.isEmpty) and + ((ent.id in uuids).inhibitWhen(uuids.isEmpty) or + (ent.name in names).inhibitWhen(names.isEmpty))) + select (ent)) + + val typeMap: Map[UUID, Seq[String]] = SquerylEntityModel.entitiesWithTypes(entQuery).map { case (row, types) => (row.id, types) }.toMap + + val entAndRow: Seq[(UUID, String, A)] = + from(entities, table)((ent, mod) => + where(EntityFilter.optional(filter, ent).inhibitWhen(filter.isEmpty) and + ((ent.id in uuids).inhibitWhen(uuids.isEmpty) or + (ent.name in names).inhibitWhen(names.isEmpty)) and + (mod.entityId === ent.id)) + select (ent.id, ent.name, mod)).toSeq + + entAndRow.map { + case (uuid, name, row) => + val types = typeMap.get(uuid).getOrElse(Nil) + (uuid, name, types, row) + } + + } + + def existingEntityBased[DbType <: EntityBased, TemplateInfo](ids: Seq[UUID], names: Seq[String], table: Table[DbType], coreType: String): Seq[(UUID, String, DbType)] = { + + val currentStateOpt: Seq[(UUID, String, Option[DbType])] = + join(entities, table.leftOuter)((ent, mod) => + where((ent.id in ids) or (ent.name in names)) + select (ent.id, ent.name, mod) + on (Some(ent.id) === mod.map(_.entityId))).toList + + // It's illegal to have an entity that's not attached to a point/command/etc, and add it on top of it + val currentState: Seq[(UUID, String, DbType)] = currentStateOpt.map { + case (id, name, rowOpt) => rowOpt match { + case None => throw new ModelInputException(s"Entity $name already exists but is not a $coreType") + case Some(row) => (id, name, row) + } + } + + currentState + } + + def entityBasedCreatesAndUpdates[DbType <: EntityBased, TemplateInfo]( + withIds: Seq[IdentifiedEntityTemplate[TemplateInfo]], + withNames: Seq[NamedEntityTemplate[TemplateInfo]], + currentMap: Map[UUID, DbType], + nameToUuidMap: Map[String, UUID], + create: (UUID, TemplateInfo) => DbType, + update: (UUID, DbType, TemplateInfo) => Option[DbType]): (Seq[DbType], Seq[DbType]) = { + + var creates = List.empty[DbType] + var updates = List.empty[DbType] + + withIds.map { temp => + currentMap.get(temp.uuid) match { + case None => + creates ::= create(temp.uuid, temp.info) + case Some(row) => + update(temp.uuid, row, temp.info).foreach { up => updates ::= up } + } + } + + withNames.map { temp => + nameToUuidMap.get(temp.name) match { + case None => throw new ModelAssertionException(s"Failed to find or create entity for '${temp.name}'") + case Some(uuid) => + currentMap.get(uuid) match { + case None => + creates ::= create(uuid, temp.info) + case Some(row) => + update(uuid, row, temp.info).foreach { up => updates ::= up } + } + } + } + + (creates, updates) + } + + def performEntityBasedCreateAndUpdates[DbType <: EntityBased, TemplateInfo]( + notifier: ModelNotifier, + withIds: Seq[IdentifiedEntityTemplate[TemplateInfo]], + withNames: Seq[NamedEntityTemplate[TemplateInfo]], + coreType: String, + create: (UUID, TemplateInfo) => DbType, + update: (UUID, DbType, TemplateInfo) => Option[DbType], + table: Table[DbType], + allowCreate: Boolean = true, + updateFilter: Option[EntityFilter] = None): (Set[UUID], Set[UUID], Set[UUID], Map[String, UUID]) = { + + val ids = withIds.map(_.uuid) + val names = withNames.map(_.name) + + updateFilter.foreach { filter => + if (filter.anyOutsideSet(entities.where(t => (t.id in ids) and (t.name in names)))) { + throw new ModelPermissionException("Tried to put restricted entities") + } + } + + val currentState: Seq[(UUID, String, DbType)] = existingEntityBased(ids, names, table, coreType) + + val currentMap: Map[UUID, DbType] = currentState.map(cs => (cs._1, cs._3)).toMap + + val withIdDescs = withIds.map(tup => (tup.uuid, tup.name, tup.types)) + val withNameDescs = withNames.map(tup => (tup.name, tup.types)) + + val (entCreateIds, entUpdateIds, foundOrCreatedEntities) = SquerylEntityModel.findOrCreateEntities(notifier, withIdDescs, withNameDescs, allowCreate, updateFilter) + + val nameToUuidMap: Map[String, UUID] = { + val currentStateNameToUuidMap: Map[String, UUID] = currentState.map(cs => (cs._2, cs._1)).toMap + val updatedNameToUuidMap: Map[String, UUID] = foundOrCreatedEntities.map(e => (e.getName, protoUUIDToUuid(e.getUuid))).toMap + + currentStateNameToUuidMap ++ updatedNameToUuidMap + } + + val (creates, updates) = entityBasedCreatesAndUpdates(withIds, withNames, currentMap, nameToUuidMap, create, update) + + if (creates.nonEmpty) { + table.insert(creates) + } + if (updates.nonEmpty) { + table.update(updates) + } + + val allCreateIds = (creates.map(_.entityId) ++ entCreateIds).toSet + val allUpdateIds = (updates.map(_.entityId) ++ entUpdateIds).toSet + + val allUuids = (currentState.map(_._1) ++ entCreateIds).toSet + + (allCreateIds, allUpdateIds, allUuids, nameToUuidMap) + } + + def putEntityBased[DbType <: EntityBased, TemplateInfo, ProtoType]( + notifier: ModelNotifier, + withIds: Seq[IdentifiedEntityTemplate[TemplateInfo]], + withNames: Seq[NamedEntityTemplate[TemplateInfo]], + coreType: String, + create: (UUID, TemplateInfo) => DbType, + update: (UUID, DbType, TemplateInfo) => Option[DbType], + query: (Seq[UUID], Seq[String]) => Seq[ProtoType], + uuidFromProto: ProtoType => ModelUUID, + table: Table[DbType], + allowCreate: Boolean = true, + updateFilter: Option[EntityFilter] = None): Seq[ProtoType] = { + + val ids = withIds.map(_.uuid) + val names = withNames.map(_.name) + + val (allCreateIds, allUpdateIds, _, _) = performEntityBasedCreateAndUpdates(notifier, withIds, withNames, coreType, create, update, table, allowCreate, updateFilter) + + val allResults = query(ids, names) + lazy val resultMap: Map[UUID, ProtoType] = allResults.map(proto => (protoUUIDToUuid(uuidFromProto(proto)), proto)).toMap + + allCreateIds.flatMap(resultMap.get).foreach(notifier.notify(Created, _)) + allUpdateIds.flatMap(resultMap.get).foreach(notifier.notify(Updated, _)) + + allResults + } + + def deleteEntityBased[A <: EntityBased, C]( + notifier: ModelNotifier, + ids: Seq[UUID], + query: Seq[UUID] => Seq[C], + table: Table[A], + filter: Option[EntityFilter] = None): Seq[C] = { + + filter.foreach { filt => + if (filt.anyOutsideSet(ids)) { + throw new ModelPermissionException("Tried to delete restricted agents") + } + } + + val results = query(ids) + + SquerylEntityModel.deleteEntityBased(notifier, ids) + + table.deleteWhere(row => row.entityId in ids) + + results.foreach(notifier.notify(Deleted, _)) + + results + } +} diff --git a/services/src/main/scala/io/greenbus/services/model/EntityModel.scala b/services/src/main/scala/io/greenbus/services/model/EntityModel.scala new file mode 100644 index 0000000..e972bb7 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/model/EntityModel.scala @@ -0,0 +1,1148 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.model + +import java.util.UUID + +import org.squeryl.PrimitiveTypeMode._ +import org.squeryl.Query +import org.squeryl.dsl.ast.{ ExpressionNode, LogicalBoolean } +import io.greenbus.client.service.proto.Model._ +import io.greenbus.client.service.proto.ModelRequests.EntityKeyPair +import io.greenbus.services.authz.EntityFilter +import io.greenbus.services.core.EntityKeyValueWithEndpoint +import io.greenbus.services.data.ServicesSchema._ +import io.greenbus.services.data.{ EntityEdgeRow, EntityKeyStoreRow, EntityRow, EntityTypeRow } +import io.greenbus.services.framework.{ Created, Deleted, ModelNotifier, Updated } + +import scala.annotation.tailrec +import scala.collection.JavaConversions._ +import scala.collection.immutable.VectorBuilder + +object EntityModel { + + class DiamondException(msg: String) extends ModelInputException(msg) + + case class TypeParams(includeTypes: Seq[String], matchTypes: Seq[String], filterNotTypes: Seq[String]) + + val reservedTypes = Set("Point", "Command", "Endpoint", "ConfigFile") +} + +trait EntityModel { + import io.greenbus.services.model.EntityModel._ + + def fullQuery(typeParams: TypeParams, lastUuid: Option[UUID], lastName: Option[String], pageSize: Int, pageByName: Boolean = true, filter: Option[EntityFilter] = None): Seq[Entity] + def keyQuery(uuids: Seq[UUID], names: Seq[String], filter: Option[EntityFilter] = None): Seq[Entity] + + def keyQueryFilter(uuids: Seq[UUID], names: Seq[String], filter: Option[EntityFilter] = None): Seq[UUID] + + def idsRelationFlatQuery(ids: Seq[UUID], relation: String, descendantOf: Boolean, endTypes: Seq[String], depthLimit: Option[Int], lastUuid: Option[UUID], lastName: Option[String], pageSize: Int, pageByName: Boolean = true, filter: Option[EntityFilter] = None): Seq[Entity] + def namesRelationFlatQuery(names: Seq[String], relation: String, descendantOf: Boolean, endTypes: Seq[String], depthLimit: Option[Int], lastUuid: Option[UUID], lastName: Option[String], pageSize: Int, pageByName: Boolean = true, filter: Option[EntityFilter] = None): Seq[Entity] + def typesRelationFlatQuery(types: Seq[String], relation: String, descendantOf: Boolean, endTypes: Seq[String], depthLimit: Option[Int], lastUuid: Option[UUID], lastName: Option[String], pageSize: Int, pageByName: Boolean = true, filter: Option[EntityFilter] = None): Seq[Entity] + + def putEntities(notifier: ModelNotifier, withIds: Seq[(UUID, String, Set[String])], withNames: Seq[(String, Set[String])], allowCreate: Boolean = true, updateFilter: Option[EntityFilter] = None): Seq[Entity] + def deleteEntities(notifier: ModelNotifier, ids: Seq[UUID], filter: Option[EntityFilter] = None): Seq[Entity] + + def edgeQuery(parents: Seq[UUID], relations: Seq[String], children: Seq[UUID], depthLimit: Option[Int], lastId: Option[Long], pageSize: Int, filter: Option[EntityFilter] = None): Seq[EntityEdge] + + //def putEdgesOnParent(parent: UUID, children: Seq[UUID], relation: String): Seq[EntityEdge] + def putEdges(notifier: ModelNotifier, edgeList: Seq[(UUID, UUID)], relation: String): Seq[EntityEdge] + def deleteEdgesFromParent(notifier: ModelNotifier, parent: UUID, children: Seq[UUID], relation: String): Seq[EntityEdge] + def deleteEdges(notifier: ModelNotifier, edgeList: Seq[(UUID, UUID)], relation: String): Seq[EntityEdge] + + //def getKeyValues(ids: Seq[UUID], key: String, filter: Option[EntityFilter] = None): Seq[(UUID, Array[Byte])] + //def putKeyValues(values: Seq[(UUID, Array[Byte])], key: String, filter: Option[EntityFilter] = None): (Seq[(UUID, Array[Byte])], Seq[UUID], Seq[UUID]) + //def deleteKeyValues(ids: Seq[UUID], key: String, filter: Option[EntityFilter] = None): Seq[(UUID, Array[Byte])] + + def getKeyValues2(set: Seq[(UUID, String)], filter: Option[EntityFilter] = None): Seq[EntityKeyValue] + def getKeyValuesForUuids(set: Seq[UUID], filter: Option[EntityFilter] = None): Seq[EntityKeyValue] + def getKeys(ids: Seq[UUID], filter: Option[EntityFilter] = None): Seq[EntityKeyPair] + def putKeyValues2(notifier: ModelNotifier, set: Seq[(UUID, String, StoredValue)], allowCreate: Boolean = true, updateFilter: Option[EntityFilter] = None): Seq[EntityKeyValue] + def deleteKeyValues2(notifier: ModelNotifier, set: Seq[(UUID, String)], filter: Option[EntityFilter] = None): Seq[EntityKeyValue] +} + +object SquerylEntityModel extends EntityModel { + import io.greenbus.services.model.EntityModel._ + import io.greenbus.services.model.ModelHelpers._ + import io.greenbus.services.model.UUIDHelpers._ + + private def entitiesFromJoin(results: Seq[(EntityRow, Option[String])]): Seq[Entity] = { + groupSortedAndPreserveOrder(results).map { + case (row, types) => + val flatTypes = types.flatten + Entity.newBuilder + .setUuid(row.id) + .setName(row.name) + .addAllTypes(flatTypes) + .build() + } + } + + private def simpleEntity(id: UUID, name: String, types: Seq[String]): Entity = { + Entity.newBuilder + .setUuid(id) + .setName(name) + .addAllTypes(types) + .build() + } + + private def filterEntities(ent: EntityRow, filter: Option[EntityFilter]): LogicalBoolean = { + filter match { + case None => true === true + case Some(f) => f(ent.id) + } + } + + def allQuery(last: Option[UUID], pageSize: Int): Seq[Entity] = { + + val sql = last match { + case None => + join(entities, entityTypes.leftOuter)((ent, typ) => + select(ent, typ.map(_.entType)) + orderBy (ent.id) + on (Some(ent.id) === typ.map(_.entityId))).page(0, pageSize) + case Some(lastUuid) => + join(entities, entityTypes.leftOuter)((ent, typ) => + where(ent.id > lastUuid) + select (ent, typ.map(_.entType)) + orderBy (ent.id) + on (Some(ent.id) === typ.map(_.entityId))).page(0, pageSize) + } + + entitiesFromJoin(sql.toSeq) + } + + def typeMatchClause(ent: EntityRow, matchTypes: Seq[String]): LogicalBoolean = { + if (matchTypes.nonEmpty) { + def typeExists(typ: String): LogicalBoolean = exists(from(entityTypes)(t => + where(t.entityId === ent.id and t.entType === typ) + select (t.entityId))) + + matchTypes.map(typeExists).reduceLeft(logicalAnd) + //matchTypes.map(typeExists).reduceLeft((a, b) => new BinaryOperatorNodeLogicalBoolean(a, b, "and")) + } else { + true === true + } + } + + def entTypesClause(ent: EntityRow, params: TypeParams): LogicalBoolean = { + (ent.id in from(entityTypes)(t => + where(t.entType in params.includeTypes) + select (t.entityId))).inhibitWhen(params.includeTypes.isEmpty) and + (ent.id notIn from(entityTypes)(t => + where(t.entType in params.filterNotTypes) + select (t.entityId))).inhibitWhen(params.filterNotTypes.isEmpty) and + SquerylEntityModel.typeMatchClause(ent, params.matchTypes).inhibitWhen(params.matchTypes.isEmpty) + } + + def guardFunc(lastUuid: Option[UUID], lastName: Option[String], pageByName: Boolean = true): EntityRow => LogicalBoolean = { + if (pageByName) { + + val nameOpt = lastName match { + case Some(name) => Some(name) + case None => + lastUuid match { + case Some(uuid) => entities.where(t => t.id === uuid).headOption.map(_.name) + case None => None + } + } + + def pageGuard(ent: EntityRow) = ent.name gt nameOpt.? + pageGuard + + } else { + + val uuidOpt = lastUuid match { + case Some(uuid) => Some(uuid) + case None => + lastName match { + case Some(name) => entities.where(t => t.name === name).headOption.map(_.id) + case None => None + } + } + + def pageGuard(ent: EntityRow) = ent.id > uuidOpt.? + pageGuard + } + } + + def fullQuery(typeParams: TypeParams, lastUuid: Option[UUID], lastName: Option[String], pageSize: Int, pageByName: Boolean = true, filter: Option[EntityFilter] = None): Seq[Entity] = { + + val guard = SquerylEntityModel.guardFunc(lastUuid, lastName, pageByName) + + def ordering(ent: EntityRow): ExpressionNode = { + if (pageByName) { + ent.name + } else { + ent.id + } + } + + val entSet = from(entities)(ent => + where(filterEntities(ent, filter) and + guard(ent) and + (ent.id in from(entityTypes)(t => + where(t.entType in typeParams.includeTypes) + select (t.entityId))).inhibitWhen(typeParams.includeTypes.isEmpty) and + (ent.id notIn from(entityTypes)(t => + where(t.entType in typeParams.filterNotTypes) + select (t.entityId))).inhibitWhen(typeParams.filterNotTypes.isEmpty) and + typeMatchClause(ent, typeParams.matchTypes).inhibitWhen(typeParams.matchTypes.isEmpty)) + select (ent.id) + orderBy (ordering(ent))).page(0, pageSize) + + val entList = entSet.toList + + val sql = join(entities, entityTypes.leftOuter)((ent, typ) => + where(ent.id in entList) + select (ent, typ.map(_.entType)) + orderBy (ordering(ent)) + on (Some(ent.id) === typ.map(_.entityId))) + + entitiesFromJoin(sql.toVector) + } + + def keyQueryFilter(uuids: Seq[UUID], names: Seq[String], filter: Option[EntityFilter] = None): Seq[UUID] = { + from(entities)(ent => + where(filterEntities(ent, filter).inhibitWhen(filter.isEmpty) and + ((ent.id in uuids).inhibitWhen(uuids.isEmpty) or + (ent.name in names).inhibitWhen(names.isEmpty))) + select (ent.id)).toList + } + + def keyQuery(uuids: Seq[UUID], names: Seq[String], filter: Option[EntityFilter] = None): Seq[Entity] = { + if (uuids.isEmpty && names.isEmpty) { + throw new ModelInputException("Must include uuids or names for querying") + } + + val sql = join(entities, entityTypes.leftOuter)((ent, typ) => + where(filterEntities(ent, filter).inhibitWhen(filter.isEmpty) and + ((ent.id in uuids).inhibitWhen(uuids.isEmpty) or + (ent.name in names).inhibitWhen(names.isEmpty))) + select (ent, typ.map(_.entType)) + orderBy (ent.id) + on (Some(ent.id) === typ.map(_.entityId))) + + entitiesFromJoin(sql.toSeq) + } + + def keyQueryAbstract(uuids: Seq[UUID], names: Seq[String], filter: Option[EntityFilter] = None): Query[EntityRow] = { + if (uuids.isEmpty && names.isEmpty) { + throw new ModelInputException("Must include uuids or names for querying") + } + + from(entities)(ent => + where(filterEntities(ent, filter).inhibitWhen(filter.isEmpty) and + ((ent.id in uuids).inhibitWhen(uuids.isEmpty) or + (ent.name in names).inhibitWhen(names.isEmpty))) + select (ent)) + } + + def idQuery(uuids: Seq[UUID], filter: Option[EntityFilter] = None): Seq[Entity] = { + keyQuery(uuids, Nil, filter) + } + + def nameQuery(names: Seq[String], filter: Option[EntityFilter] = None): Seq[Entity] = { + keyQuery(Nil, names, filter) + } + + def idsRelationFlatQuery(ids: Seq[UUID], relation: String, descendantOf: Boolean, endTypes: Seq[String], depthLimit: Option[Int], lastUuid: Option[UUID], lastName: Option[String], pageSize: Int, pageByName: Boolean = true, filter: Option[EntityFilter] = None): Seq[Entity] = { + val start = from(entities)(e => where(filterEntities(e, filter) and (e.id in ids)) select (e.id)) + + relationFlatQuery(start, relation, descendantOf, endTypes, depthLimit, lastUuid, lastName, pageSize, pageByName, filter) + } + + def namesRelationFlatQuery(names: Seq[String], relation: String, descendantOf: Boolean, endTypes: Seq[String], depthLimit: Option[Int], lastUuid: Option[UUID], lastName: Option[String], pageSize: Int, pageByName: Boolean = true, filter: Option[EntityFilter] = None): Seq[Entity] = { + val start = from(entities)(e => where(filterEntities(e, filter) and (e.name in names)) select (e.id)) + + relationFlatQuery(start, relation, descendantOf, endTypes, depthLimit, lastUuid, lastName, pageSize, pageByName, filter) + } + + def typesRelationFlatQuery(types: Seq[String], relation: String, descendantOf: Boolean, endTypes: Seq[String], depthLimit: Option[Int], lastUuid: Option[UUID], lastName: Option[String], pageSize: Int, pageByName: Boolean = true, filter: Option[EntityFilter] = None): Seq[Entity] = { + val start = from(entities, entityTypes)((ent, typ) => + where(filterEntities(ent, filter) and ent.id === typ.entityId and (typ.entType in types)) + select (ent.id)) + + relationFlatQuery(start, relation, descendantOf, endTypes, depthLimit, lastUuid, lastName, pageSize, pageByName, filter) + } + + private def relationFlatQuery(startQuery: Query[UUID], relation: String, descendantOf: Boolean, endTypes: Seq[String], depthLimit: Option[Int], lastUuid: Option[UUID], lastName: Option[String], pageSize: Int, pageByName: Boolean = true, filter: Option[EntityFilter] = None): Seq[Entity] = { + + val guard = SquerylEntityModel.guardFunc(lastUuid, lastName, pageByName) + + def ordering(ent: EntityRow): ExpressionNode = { + if (pageByName) { + ent.name + } else { + ent.id + } + } + + // Note: inhibitWhen appears not to work on exists() + val entSet = endTypes.isEmpty match { + case true => + from(entities, edges)((ent, edge) => + where(filterEntities(ent, filter) and + edge.relationship === relation and + (edge.distance lte depthLimit.?) and + ((edge.parentId in startQuery) and + (edge.childId === ent.id)).inhibitWhen(!descendantOf) and + ((edge.childId in startQuery) and + (edge.parentId === ent.id)).inhibitWhen(descendantOf)) + select (ent.id)) + case false => + from(entities, edges)((ent, edge) => + where(filterEntities(ent, filter) and + edge.relationship === relation and + (edge.distance lte depthLimit.?) and + ((edge.parentId in startQuery) and + (edge.childId === ent.id)).inhibitWhen(!descendantOf) and + ((edge.childId in startQuery) and + (edge.parentId === ent.id)).inhibitWhen(descendantOf) and + exists( + from(entityTypes)(typ => + where(typ.entityId === ent.id and + (typ.entType in endTypes)) + select (typ.entityId)))) + select (ent.id)) + } + + val results = join(entities, entityTypes.leftOuter)((ent, typ) => + where(guard(ent) and (ent.id in entSet)) + select (ent, typ.map(_.entType)) + orderBy (ordering(ent)) + on (Some(ent.id) === typ.map(_.entityId))).page(0, pageSize) + + entitiesFromJoin(results.toSeq) + } + + private def entityIdFilter(id: UUID, filter: Option[EntityFilter]): LogicalBoolean = { + filter match { + case None => true === true + case Some(filt) => filt(id) + } + } + + def edgeQuery(parents: Seq[UUID], relations: Seq[String], children: Seq[UUID], depthLimit: Option[Int], lastId: Option[Long], pageSize: Int, filter: Option[EntityFilter] = None): Seq[EntityEdge] = { + + val edgeResults = + from(edges)(edge => + where((edge.id > lastId.?) and + (entityIdFilter(edge.parentId, filter) and + entityIdFilter(edge.childId, filter)).inhibitWhen(filter.isEmpty) and + (edge.parentId in parents).inhibitWhen(parents.isEmpty) and + (edge.childId in children).inhibitWhen(children.isEmpty) and + (edge.relationship in relations).inhibitWhen(relations.isEmpty) and + (edge.distance lte depthLimit.?)) + select (edge) + orderBy (edge.id)).page(0, pageSize).toVector + + edgeResults map edgeRowToProto + } + + def putEntity(notifier: ModelNotifier, name: String, types: Seq[String], preserveTypes: Boolean, mustHaveAllTypes: Boolean, mustOnlyHaveTypes: Boolean, allowCreate: Boolean = true, filter: Option[EntityFilter] = None): EntityRow = { + + val entOption = entities.where(e => e.name === name).toSeq.headOption + + entOption match { + case Some(existing) => { + + filter.foreach { filt => + if (filt.isOutsideSet(existing.id)) { + throw new ModelPermissionException("Don't have permissions for entity") + } + } + + val existingTypes = + from(entityTypes)(t => + where(t.entityId === existing.id) + select (t.entType)).toSeq + + val existingTypesSet = existingTypes.toSet + val wantedTypesSet = types.toSet + + val onlyInExisting = existingTypesSet &~ wantedTypesSet + if (mustOnlyHaveTypes && onlyInExisting.nonEmpty) { + throw new ModelInputException("Existing entity had extra types, refusing to overwrite") + } + + val onlyInWanted = wantedTypesSet &~ existingTypesSet + if (mustHaveAllTypes && onlyInWanted.nonEmpty) { + throw new ModelInputException("Existing entity did not include types, refusing to overwrite") + } + + if (!preserveTypes && onlyInExisting.nonEmpty) { + entityTypes.deleteWhere(t => t.entityId === existing.id and (t.entType in onlyInExisting)) + } + + if (onlyInWanted.nonEmpty) { + val typeInserts = onlyInWanted.map(EntityTypeRow(existing.id, _)) + entityTypes.insert(typeInserts) + } + + val allTypes = if (!preserveTypes) wantedTypesSet else wantedTypesSet ++ onlyInExisting + + if ((!preserveTypes && onlyInExisting.nonEmpty) || onlyInWanted.nonEmpty) { + notifier.notify(Updated, simpleEntity(existing.id, name, allTypes.toSeq)) + } + + existing + } + case None => { + if (!allowCreate) { + throw new ModelPermissionException("Entity create not allowed") + } + + val ent = entities.insert(EntityRow(UUID.randomUUID(), name)) + val typeInserts = types.map(EntityTypeRow(ent.id, _)) + entityTypes.insert(typeInserts) + + notifier.notify(Created, simpleEntity(ent.id, name, types)) + + ent + } + } + } + + def entitiesWithTypes(ents: Query[EntityRow]): Seq[(EntityRow, Seq[String])] = { + + val joins = join(ents, entityTypes.leftOuter)((ent, typ) => + select(ent, typ.map(_.entType)) + orderBy (ent.id) + on (Some(ent.id) === typ.map(_.entityId))) + + groupSortedAndPreserveOrder(joins.toSeq) + .map { case (row, optTypes) => (row, optTypes.flatten) } + } + + private def typeDiff(existingTypes: Set[String], wantedTypes: Set[String]): (Set[String], Set[String]) = { + + lazy val existingTypesSet = existingTypes.toSet + lazy val wantedTypesSet = wantedTypes.toSet + lazy val onlyInWanted = wantedTypesSet &~ existingTypesSet + + val toBeRemoved = existingTypesSet &~ wantedTypesSet + val toBeAdded = onlyInWanted + + (toBeAdded, toBeRemoved) + } + + private case class EntityPutResult(created: Seq[EntityRow], + updated: Seq[UUID], + updatedRows: Seq[EntityRow], + typeInserts: Seq[EntityTypeRow], + typeDeletes: Seq[EntityTypeRow]) { + + def merge(rhs: EntityPutResult): EntityPutResult = { + EntityPutResult( + created ++ rhs.created, + updated ++ rhs.updated, + updatedRows ++ rhs.updatedRows, + typeInserts ++ rhs.typeInserts, + typeDeletes ++ rhs.typeDeletes) + } + } + + private def computePutEntities( + withIds: Seq[(UUID, String, Set[String])], + withNames: Seq[(String, Set[String])], + enforceReserved: Boolean, + allowCreate: Boolean = true, + updateFilter: Option[EntityFilter] = None): EntityPutResult = { + + val specifiedIds = withIds.map(_._1) + val specifiedNames = withNames.map(_._1) + + val existingQuery = entities.where(ent => (ent.id in specifiedIds) or (ent.name in specifiedNames)) + + val existing: Seq[(EntityRow, Seq[String])] = entitiesWithTypes(existingQuery) + + if (enforceReserved) { + val foundReservedExisting = existing.flatMap(_._2).filter(reservedTypes.contains) + if (foundReservedExisting.nonEmpty) { + throw new ModelInputException("Cannot modify entities with reserved types: " + foundReservedExisting.mkString("(", ", ", ")") + ", use specific service") + } + + val foundReservedUpdates = (withIds.flatMap(_._3) ++ withNames.flatMap(_._2)).filter(reservedTypes.contains) + if (foundReservedUpdates.nonEmpty) { + throw new ModelInputException("Cannot add reserved types: " + foundReservedUpdates.mkString("(", ", ", ")") + ", use specific service") + } + } + + val existingIdSet = existing.map(_._1.id).toSet + val existingNameSet = existing.map(_._1.name).toSet + + val notExistingWithIds = withIds.filterNot(entry => existingIdSet.contains(entry._1)) + val notExistingWithNames = withNames.filterNot(entry => existingNameSet.contains(entry._1)) + + if ((notExistingWithIds.nonEmpty || notExistingWithNames.nonEmpty) && !allowCreate) { + throw new ModelPermissionException("Must have blanket create permissions to create entities") + } + + val withIdsById: Map[UUID, (UUID, String, Set[String])] = withIds.map(tup => (tup._1, tup)).toMap + val withNamesByName: Map[String, (String, Set[String])] = withNames.map(tup => (tup._1, tup)).toMap + + val updates: Seq[(UUID, Option[EntityRow], Set[String], Set[String])] = existing.flatMap { + case (currentRow, currentTypes) => + val id = currentRow.id + val currentName = currentRow.name + + val update: Option[(String, Set[String])] = withIdsById.get(id).map(tup => (tup._2, tup._3)).orElse(withNamesByName.get(currentName)) + + update.flatMap { + case (updateName, updateTypes) => + + val rowUpdate = if (currentName != updateName) Some(currentRow.copy(name = updateName)) else None + + val (adds, deletes) = typeDiff(currentTypes.toSet, updateTypes) + + if (rowUpdate.nonEmpty || adds.nonEmpty || deletes.nonEmpty) { + Some((id, rowUpdate, adds, deletes)) + } else { + None + } + } + } + + val idRowAndTypeInserts: Seq[(EntityRow, Seq[EntityTypeRow])] = notExistingWithIds.map { + case (id, name, typeSet) => (EntityRow(id, name), typeSet.toSeq.map(EntityTypeRow(id, _))) + } + + val nameRowAndTypeInserts: Seq[(EntityRow, Seq[EntityTypeRow])] = notExistingWithNames.map { + case (name, typeSet) => + val uuid = UUID.randomUUID() + val typeInserts = typeSet.toSeq.map(EntityTypeRow(uuid, _)) + (EntityRow(uuid, name), typeInserts) + } + + val rowInserts = idRowAndTypeInserts.map(_._1) ++ nameRowAndTypeInserts.map(_._1) + + val updatedIds = updates.map(_._1) + + val rowUpdates = updates.flatMap(_._2) + + val typeInserts = updates.flatMap(tup => tup._3.toSeq.map(EntityTypeRow(tup._1, _))) ++ idRowAndTypeInserts.flatMap(_._2) ++ nameRowAndTypeInserts.flatMap(_._2) + + val typeDeletes = updates.flatMap(tup => tup._4.map(EntityTypeRow(tup._1, _))) + + EntityPutResult(rowInserts, updatedIds, rowUpdates, typeInserts, typeDeletes) + } + + private def performPutEntities(notifier: ModelNotifier, result: EntityPutResult): Seq[Entity] = { + if (result.typeDeletes.nonEmpty) { + result.typeDeletes.foreach { delRow => + entityTypes.deleteWhere(t => t.entityId === delRow.entityId and t.entType === delRow.entType) + } + } + + if (result.created.nonEmpty) { + entities.insert(result.created) + } + if (result.updatedRows.nonEmpty) { + entities.update(result.updatedRows) + } + if (result.typeInserts.nonEmpty) { + entityTypes.insert(result.typeInserts) + } + + val createdIds = result.created.map(_.id) + val updatedIds = result.updated + + val allIds = createdIds ++ updatedIds + + val results = if (allIds.nonEmpty) { + keyQuery(createdIds ++ updatedIds, Nil) + } else { + Nil + } + + val resultMap: Map[UUID, Entity] = results.map(r => (protoUUIDToUuid(r.getUuid), r)).toMap + + createdIds.flatMap(resultMap.get).foreach(notifier.notify(Created, _)) + updatedIds.flatMap(resultMap.get).foreach(notifier.notify(Updated, _)) + + results + } + + def findOrCreateEntities(notifier: ModelNotifier, + withIds: Seq[(UUID, String, Set[String])], + withNames: Seq[(String, Set[String])], + allowCreate: Boolean = true, + updateFilter: Option[EntityFilter] = None): (Seq[UUID], Seq[UUID], Seq[Entity]) = { + + val computeResult = computePutEntities(withIds, withNames, false, allowCreate, updateFilter) + + val entities = performPutEntities(notifier, computeResult) + + (computeResult.created.map(_.id), computeResult.updated, entities) + } + + def putEntities(notifier: ModelNotifier, + withIds: Seq[(UUID, String, Set[String])], + withNames: Seq[(String, Set[String])], + allowCreate: Boolean = true, + updateFilter: Option[EntityFilter] = None): Seq[Entity] = { + + val specifiedIds = withIds.map(_._1) + val specifiedNames = withNames.map(_._1) + + // Reject them even if they're NOT changed, otherwise it's an information leak + // about the current name/types (rather than just existence, which is unavoidable) + updateFilter.foreach { filter => + + val existingQuery = from(entities)(ent => + where((ent.id in specifiedIds).inhibitWhen(specifiedIds.isEmpty) or + (ent.name in specifiedNames).inhibitWhen(specifiedNames.isEmpty)) + select (ent)) + + if (filter.anyOutsideSet(existingQuery)) { + throw new ModelPermissionException("Tried to update restricted entities") + } + } + + if (withIds.isEmpty && withNames.isEmpty) { + throw new ModelInputException("Must specify at least one entity to query for") + } + + val result = computePutEntities(withIds, withNames, true, allowCreate, updateFilter) + + performPutEntities(notifier, result) + + keyQuery(specifiedIds, specifiedNames) + } + + def deleteEntities(notifier: ModelNotifier, ids: Seq[UUID], filter: Option[EntityFilter] = None): Seq[Entity] = { + deleteByIds(notifier, ids, true, filter) + } + + def deleteEntityBased(notifier: ModelNotifier, ids: Seq[UUID], filter: Option[EntityFilter] = None): Seq[Entity] = { + deleteByIds(notifier, ids, false, filter) + } + + private def deleteByIds(notifier: ModelNotifier, ids: Seq[UUID], enforceReserved: Boolean, filter: Option[EntityFilter] = None): Seq[Entity] = { + filter.foreach { filt => + if (filt.anyOutsideSet(ids)) { + throw new ModelPermissionException("Tried to delete restricted entities") + } + } + + val results = idQuery(ids) + + if (enforceReserved) { + val foundReserved = results.flatMap(_.getTypesList.toSeq).filter(reservedTypes.contains) + if (foundReserved.nonEmpty) { + throw new ModelInputException("Cannot delete entities with reserved types: " + foundReserved.mkString("(", ", ", ")") + ", use specific service") + } + } + + entities.deleteWhere(t => t.id in ids) + entityTypes.deleteWhere(t => t.entityId in ids) + + ids.foreach(deleteAllEdgesFromNode(notifier, _)) + + deleteAllKeyValues2(notifier, ids) + + results.foreach(notifier.notify(Deleted, _)) + + results + } + + private def checkForDiamonds(parent: UUID, children: Seq[UUID], relation: String) { + val childrenOfChildren = from(edges)(e => + where((e.parentId in children) and e.relationship === relation) + select (e.childId)) + + val ourParents = from(edges)(e => + where(e.childId === parent and e.relationship === relation) + select (e.parentId)) + + val existingPathToChildren = from(edges)(e => + where((e.parentId === parent or (e.parentId in ourParents)) and + ((e.childId in children) or (e.childId in childrenOfChildren)) and + e.relationship === relation) + select ((e.parentId, e.childId, e.relationship))).page(0, 3).toSeq + + if (existingPathToChildren.nonEmpty) { + val summary = existingPathToChildren.map { + case (parent, child, relation) => s"$parent -($relation)-> $child" + }.mkString("", ", ", " ...") + throw new DiamondException("Edge insertion would create diamond: " + summary) + } + } + + private def edgeRowToProto(edgeRow: EntityEdgeRow): EntityEdge = { + EntityEdge.newBuilder + .setId(ModelID.newBuilder.setValue(edgeRow.id.toString)) + .setParent(edgeRow.parentId) + .setChild(edgeRow.childId) + .setRelationship(edgeRow.relationship) + .setDistance(edgeRow.distance) + .build + } + + def deleteAllEdgesFromNode(notifier: ModelNotifier, node: UUID): Seq[EntityEdge] = { + + val relationships = + from(edges)(e => + where(e.parentId === node or e.childId === node) + select (e.relationship)).distinct.toList + + val relationEdgeSets: Seq[Query[EntityEdgeRow]] = relationships.map { relation => + // Node and its parents + val upperSet = + from(edges)(e => + where(e.childId === node and + e.relationship === relation) + select (e.parentId)) + + // Node and its children + val lowerSet = + from(edges)(e => + where(e.parentId === node and + e.relationship === relation) + select (e.childId)) + + // All paths that go through node (simple paths between all nodes) + from(edges)(e => + where(((e.parentId in upperSet) or e.parentId === node) and + ((e.childId in lowerSet) or (e.childId === node)) and + e.relationship === relation) + select (e)) + } + + val results = relationEdgeSets.flatMap(_.toList map edgeRowToProto) + + relationEdgeSets.foreach(edges.delete) + + results.foreach(notifier.notify(Deleted, _)) + + results + } + + def deleteEdgesFromParent(notifier: ModelNotifier, parent: UUID, children: Seq[UUID], relation: String): Seq[EntityEdge] = { + + // Parent and parents of parent + val upper = from(edges)(e => + where((e.childId === parent or + (e.parentId === parent and (e.childId in children))) and + e.relationship === relation) + select (e.parentId)) + + // Children and children of children + val lower = from(edges)(e => + where((e.parentId in children or + (e.parentId === parent and (e.childId in children))) and + e.relationship === relation) + select (e.childId)) + + // Because we have simple paths between all nodes, deleted edges completely separate + // the component the parent is attached to from the child components + val allEdges = from(edges)(e => + where(e.relationship === relation and + (e.childId in lower) and + (e.parentId in upper)) + select (e)) + + val results = allEdges.toSeq map edgeRowToProto + + edges.delete(allEdges) + + results.foreach(notifier.notify(Deleted, _)) + + results + } + + def deleteEdges(notifier: ModelNotifier, edgeList: Seq[(UUID, UUID)], relation: String): Seq[EntityEdge] = { + + val parentGrouped: Map[UUID, Seq[UUID]] = edgeList.groupBy(_._1).mapValues(_.map(_._2)) + + parentGrouped.toSeq.flatMap { + case (parent, children) => + deleteEdgesFromParent(notifier, parent, children, relation) + } + } + + def existingEdges(edgeList: Seq[(UUID, UUID)], relation: String): Seq[EntityEdgeRow] = { + + val parentGrouped: Map[UUID, Seq[UUID]] = edgeList.groupBy(_._1).mapValues(_.map(_._2)) + + parentGrouped.flatMap { case (parent, children) => existingEdges(parent, children, relation).toSeq }.toSeq + } + + def existingEdges(parent: UUID, children: Seq[UUID], relation: String): Seq[EntityEdgeRow] = { + from(edges)(e => + where(e.parentId === parent and + (e.childId in children) and + e.relationship === relation and + e.distance === 1) + select (e)).toSeq + } + + def calculateEdgeAdditions(parent: UUID, child: UUID, parentsOfParent: Seq[(UUID, Int)], childrenOfChild: Seq[(UUID, Int)]): Seq[(UUID, UUID, Int)] = { + + val upper: Seq[(UUID, Int)] = parentsOfParent :+ (parent, 0) + val lower: Seq[(UUID, Int)] = childrenOfChild :+ (child, 0) + + upper.flatMap { + case (parentId, height) => + lower.map { + case (childId, depth) => + (parentId, childId, height + depth + 1) + } + } + } + + def groupEdgeList(list: Seq[(UUID, UUID, Int)]): (Map[UUID, Seq[(UUID, Int)]], Map[UUID, Seq[(UUID, Int)]]) = { + val childToParents = list.groupBy(_._2).mapValues(_.map(tup => (tup._1, tup._3))) + val parentToChildren = list.groupBy(_._1).mapValues(_.map(tup => (tup._2, tup._3))) + + (childToParents, parentToChildren) + } + + private def mergeMaps[A, B](a: Map[A, Seq[B]], b: Map[A, Seq[B]]): Map[A, Seq[B]] = { + (a.keySet ++ b.keySet).map { key => + (key, a.get(key).getOrElse(Nil) ++ b.get(key).getOrElse(Nil)) + }.toMap + } + + @tailrec + private def findEdgeAdditions( + results: List[(UUID, UUID, Int)], + toAdd: List[(UUID, UUID)], + childToParents: Map[UUID, Seq[(UUID, Int)]], + parentsToChildren: Map[UUID, Seq[(UUID, Int)]]): List[(UUID, UUID, Int)] = { + + toAdd match { + case Nil => results + case (parent, child) :: tail => + + val parentSet = childToParents.get(parent).getOrElse(Nil) + val childSet = parentsToChildren.get(child).getOrElse(Nil) + + val added = calculateEdgeAdditions(parent, child, parentSet, childSet) + + val (childMapAdditions, parentMapAdditions) = groupEdgeList(added) + + findEdgeAdditions(results ++ added, tail, mergeMaps(childToParents, childMapAdditions), mergeMaps(parentsToChildren, parentMapAdditions)) + } + } + + def putEdges(notifier: ModelNotifier, edgeList: Seq[(UUID, UUID)], relation: String): Seq[EntityEdge] = { + + if (edgeList.exists(tup => tup._1 == tup._2)) { + throw new ModelInputException("Edges must have different parent and child UUIDs") + } + + val edgeListDistinct = edgeList.distinct + + val existing = existingEdges(edgeListDistinct, relation) + val existingSet: Set[(UUID, UUID)] = existing.map(r => (r.parentId, r.childId)).toSet + val nonExistent = edgeListDistinct.filterNot(existingSet.contains) + + val (parents, children) = nonExistent.unzip + + val parentsAndTheirParents: Seq[(UUID, UUID, Int)] = + from(edges)(e => + where((e.childId in parents) and + e.relationship === relation) + select (e.childId, e.parentId, e.distance)).toVector + + val parentsOfParents: Map[UUID, Seq[(UUID, Int)]] = parentsAndTheirParents.groupBy(_._1).mapValues(_.map(tup => (tup._2, tup._3))) + + val childrenAndTheirChildren: Seq[(UUID, UUID, Int)] = + from(edges)(e => + where((e.parentId in children) and + e.relationship === relation) + select (e.parentId, e.childId, e.distance)).toVector + + val childrenOfChildren: Map[UUID, Seq[(UUID, Int)]] = childrenAndTheirChildren.groupBy(_._1).mapValues(_.map(tup => (tup._2, tup._3))) + + val additions = findEdgeAdditions(Nil, nonExistent.toList, parentsOfParents, childrenOfChildren) + + val rowAdditions = additions.map { + case (parent, child, dist) => EntityEdgeRow(0, parent, child, relation, dist) + } + + val insertedRows = try { + //edges.insert(rowAdditions) + rowAdditions.map(edges.insert) + } catch { + case ex: RuntimeException => + // TODO: HACK!! this might have been a disconnect, but we're going to assume it was a 'unique_violation' in postgres (23505), otherwise user gets unhelpful internal service error + throw new DiamondException("Edge insertion would create diamond") + } + + val insertedProtos = insertedRows map edgeRowToProto + val existingProtos = existing map edgeRowToProto + + insertedProtos.foreach(notifier.notify(Created, _)) + + insertedProtos ++ existingProtos + } + + def getKeyValues(ids: Seq[UUID], key: String, filter: Option[EntityFilter] = None): Seq[(UUID, Array[Byte])] = { + + from(entities, entityKeyValues)((ent, kv) => + where(EntityFilter.optional(filter, ent).inhibitWhen(filter.isEmpty) and + (ent.id in ids) and + ent.id === kv.uuid and + kv.key === key) + select (ent.id, kv.data)).toSeq + } + + def getAllKeyValues(ids: Seq[UUID], filter: Option[EntityFilter] = None): Seq[(UUID, String, Array[Byte])] = { + + from(entities, entityKeyValues)((ent, kv) => + where(EntityFilter.optional(filter, ent).inhibitWhen(filter.isEmpty) and + (ent.id in ids) and + ent.id === kv.uuid) + select (ent.id, kv.key, kv.data)).toSeq + } + + def putKeyValues(values: Seq[(UUID, Array[Byte])], key: String, filter: Option[EntityFilter] = None): (Seq[(UUID, Array[Byte])], Seq[UUID], Seq[UUID]) = { + + val ids = values.map(_._1) + filter.foreach { filt => + if (filt.anyOutsideSet(ids)) { + throw new ModelPermissionException("Tried to update restricted entities") + } + } + + val current: Map[UUID, EntityKeyStoreRow] = + from(entities, entityKeyValues)((ent, kv) => + where((ent.id in ids) and + kv.key === key and + ent.id === kv.uuid) + select (ent.id, kv)).toList.toMap + + val isCreatedAndRow: Seq[(Boolean, (UUID, EntityKeyStoreRow))] = values.flatMap { + case (id, bytes) => + current.get(id) match { + case None => + Some(true, (id, EntityKeyStoreRow(0, id, key, bytes))) + case Some(ks) => + if (!java.util.Arrays.equals(bytes, ks.data)) { + Some(false, (id, ks.copy(data = bytes))) + } else { + None + } + } + } + + val creates: Seq[(UUID, EntityKeyStoreRow)] = isCreatedAndRow.filter(_._1).map(_._2) + val updates: Seq[(UUID, EntityKeyStoreRow)] = isCreatedAndRow.filter(!_._1).map(_._2) + + val createRows = creates.map(_._2) + val updateRows = updates.map(_._2) + + entityKeyValues.insert(createRows) + entityKeyValues.update(updateRows) + + val results = getKeyValues(ids, key) + + (results, creates.map(_._1), updates.map(_._1)) + } + + def deleteKeyValues(ids: Seq[UUID], key: String, filter: Option[EntityFilter] = None): Seq[(UUID, Array[Byte])] = { + filter.foreach { filt => + if (filt.anyOutsideSet(ids)) { + throw new ModelPermissionException("Tried to delete restricted entity key values") + } + } + + val results = getKeyValues(ids, key) + + entityKeyValues.deleteWhere(t => (t.uuid in ids) and t.key === key) + + results + } + + def deleteAllKeyValues(ids: Seq[UUID], filter: Option[EntityFilter] = None): Seq[(UUID, String, Array[Byte])] = { + filter.foreach { filt => + if (filt.anyOutsideSet(ids)) { + throw new ModelPermissionException("Tried to delete restricted entity key values") + } + } + + val results = getAllKeyValues(ids) + + entityKeyValues.deleteWhere(t => t.uuid in ids) + + results + } + + def getCurrentKeyValues(set: Seq[(UUID, String)], filter: Option[EntityFilter] = None): Seq[EntityKeyStoreRow] = { + from(entityKeyValues)(kv => + where( + EntityFilter.optional(filter, kv.uuid).inhibitWhen(filter.isEmpty) and + set.map(tup => tup._1 === kv.uuid and tup._2 === kv.key).reduce(ModelHelpers.logicalOr)) + select (kv)).toVector + } + + def getCurrentKeyValuesByUuids(ids: Seq[UUID], filter: Option[EntityFilter] = None): Seq[EntityKeyStoreRow] = { + from(entityKeyValues)(kv => + where( + EntityFilter.optional(filter, kv.uuid).inhibitWhen(filter.isEmpty) and + (kv.uuid in ids)) + select (kv)).toVector + } + + def kvToProto(row: EntityKeyStoreRow): EntityKeyValue = { + EntityKeyValue.newBuilder() + .setUuid(uuidToProtoUUID(row.uuid)) + .setKey(row.key) + .setValue(StoredValue.parseFrom(row.data)) + .build() + } + + def getKeys(ids: Seq[UUID], filter: Option[EntityFilter] = None): Seq[EntityKeyPair] = { + val uuidAndKeys = from(entityKeyValues)(kv => + where( + EntityFilter.optional(filter, kv.uuid).inhibitWhen(filter.isEmpty) and + (kv.uuid in ids)) + select (kv.uuid, kv.key)) + .toVector + .filterNot(_._2 == ProcessingModel.overrideKey) + + uuidAndKeys.map { + case (uuid, key) => + EntityKeyPair.newBuilder() + .setUuid(uuid) + .setKey(key) + .build + } + } + + def getKeyValues2(set: Seq[(UUID, String)], filter: Option[EntityFilter] = None): Seq[EntityKeyValue] = { + getCurrentKeyValues(set, filter) + .filterNot(_.key == ProcessingModel.overrideKey) + .map(kvToProto) + } + + def getKeyValuesForUuids(set: Seq[UUID], filter: Option[EntityFilter] = None): Seq[EntityKeyValue] = { + getCurrentKeyValuesByUuids(set, filter) + .filterNot(_.key == ProcessingModel.overrideKey) + .map(kvToProto) + } + + def putKeyValues2(notifier: ModelNotifier, set: Seq[(UUID, String, StoredValue)], allowCreate: Boolean = true, updateFilter: Option[EntityFilter] = None): Seq[EntityKeyValue] = { + val ids = set.map(_._1) + updateFilter.foreach { filt => + if (filt.anyOutsideSet(ids)) { + throw new ModelPermissionException("Tried to update restricted entities") + } + } + + if (set.map(_._2).contains(ProcessingModel.overrideKey)) { + throw new ModelInputException(s"Cannot put reserved key ${ProcessingModel.overrideKey}") + } + + val uuidsAndKeys = set.map { case (id, key, _) => (id, key) } + + val current = getCurrentKeyValues(uuidsAndKeys) + + val currentMap = current.map(r => ((r.uuid, r.key), r)).toMap + + val (creates, updates, same) = { + val crd = new VectorBuilder[EntityKeyStoreRow] + val upd = new VectorBuilder[EntityKeyStoreRow] + val unch = new VectorBuilder[EntityKeyStoreRow] + + set.foreach { + case (uuid, key, protoValue) => + currentMap.get((uuid, key)) match { + case None => crd += EntityKeyStoreRow(0, uuid, key, protoValue.toByteArray) + case Some(existing) => + val bytes = protoValue.toByteArray + if (!java.util.Arrays.equals(bytes, existing.data)) { + upd += existing.copy(data = bytes) + } else { + unch += existing + } + } + } + + (crd.result(), upd.result(), unch.result()) + } + + val endpointMap = keyEndpointMap(ids) + + val createProtosAndEndpoint = creates.map(row => (kvToProto(row), endpointMap.get(row.uuid).map(uuidToProtoUUID))) + val updateProtosAndEndpoint = updates.map(row => (kvToProto(row), endpointMap.get(row.uuid).map(uuidToProtoUUID))) + + entityKeyValues.insert(creates) + createProtosAndEndpoint.map(t => EntityKeyValueWithEndpoint(t._1, t._2)).foreach(notifier.notify(Created, _)) + + entityKeyValues.update(updates) + updateProtosAndEndpoint.map(t => EntityKeyValueWithEndpoint(t._1, t._2)).foreach(notifier.notify(Updated, _)) + + createProtosAndEndpoint.map(_._1) ++ updateProtosAndEndpoint.map(_._1) ++ same.map(kvToProto) + } + + def deleteKeyValues2(notifier: ModelNotifier, set: Seq[(UUID, String)], filter: Option[EntityFilter] = None): Seq[EntityKeyValue] = { + + val ids = set.map(_._1) + filter.foreach { filt => + if (filt.anyOutsideSet(ids)) { + throw new ModelPermissionException("Tried to delete restricted entity key value pairs") + } + } + if (set.map(_._2).contains(ProcessingModel.overrideKey)) { + throw new ModelInputException(s"Cannot delete reserved key ${ProcessingModel.overrideKey}") + } + + val originalRows = getCurrentKeyValues(set, filter) + val endpointMap = keyEndpointMap(ids) + + val origProtosAndEndpoint = originalRows.map(row => (kvToProto(row), endpointMap.get(row.uuid).map(uuidToProtoUUID))) + + entityKeyValues.deleteWhere(kv => set.map(tup => tup._1 === kv.uuid and tup._2 === kv.key).reduce(ModelHelpers.logicalOr)) + + origProtosAndEndpoint.map(t => EntityKeyValueWithEndpoint(t._1, t._2)).foreach(notifier.notify(Deleted, _)) + + origProtosAndEndpoint.map(_._1) + } + + def deleteAllKeyValues2(notifier: ModelNotifier, ids: Seq[UUID], filter: Option[EntityFilter] = None): Seq[EntityKeyValue] = { + filter.foreach { filt => + if (filt.anyOutsideSet(ids)) { + throw new ModelPermissionException("Tried to delete restricted entity key values") + } + } + + val results = getKeyValuesForUuids(ids).filterNot(_.getKey == ProcessingModel.overrideKey) + val endpointMap = keyEndpointMap(ids).map { case (u1, u2) => (uuidToProtoUUID(u1), uuidToProtoUUID(u2)) } + + entityKeyValues.deleteWhere(t => t.uuid in ids) + + val resultsWithEndpoint = results.map(r => EntityKeyValueWithEndpoint(r, endpointMap.get(r.getUuid))) + + resultsWithEndpoint.foreach(notifier.notify(Deleted, _)) + + results + } + + def keyEndpointMap(ids: Seq[UUID]): Map[UUID, UUID] = { + from(edges)(e => + where(e.relationship === "source" and + (e.childId in ids)) + select (e.childId, e.parentId)).toVector.toMap + } + +} diff --git a/services/src/main/scala/io/greenbus/services/model/EventAlarmModel.scala b/services/src/main/scala/io/greenbus/services/model/EventAlarmModel.scala new file mode 100644 index 0000000..42293a9 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/model/EventAlarmModel.scala @@ -0,0 +1,529 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.model + +import org.squeryl.PrimitiveTypeMode._ +import io.greenbus.services.data.ServicesSchema._ +import io.greenbus.services.data._ +import io.greenbus.services.framework._ +import io.greenbus.client.service.proto.Events._ +import io.greenbus.client.service.proto.Model.{ ModelID, ModelUUID } +import UUIDHelpers._ +import io.greenbus.services.core.event.MessageFormatter +import java.util.UUID +import org.squeryl.Query +import io.greenbus.services.authz.EntityFilter + +object EventAlarmModel { + + val defaultEventQueryPageSize = 200 + + val unconfiguredEventSeverity = 9 + val unconfiguredEventMessage = "Event type not configured" + + def mapToAttributesList(map: Seq[(String, String)]): Seq[Attribute] = { + map.map { + case (k, v) => + Attribute.newBuilder() + .setName(k) + .setValueString(v) + .build() + } + } + + case class SysEventTemplate(user: String, eventType: String, subsystem: Option[String], deviceTime: Option[Long], entityUuid: Option[ModelUUID], modelGroup: Option[String], args: Seq[Attribute]) + + case class EventConfigTemp(eventType: String, severity: Int, designation: EventConfig.Designation, alarmState: Alarm.State, resource: String) + + case class EventQueryParams( + eventTypes: Seq[String] = Nil, + timeFrom: Option[Long] = None, + timeTo: Option[Long] = None, + severities: Seq[Int] = Nil, + severityOrHigher: Option[Int] = None, + subsystems: Seq[String] = Nil, + agents: Seq[String] = Nil, + entityUuids: Seq[UUID] = Nil, + entityNames: Seq[String] = Nil, + modelGroups: Seq[String] = Nil, + isAlarm: Option[Boolean] = None, + latest: Boolean = true, + last: Option[Long] = None, + pageSize: Int = defaultEventQueryPageSize) +} + +trait EventAlarmModel { + import EventAlarmModel._ + + def eventConfigQuery(severities: Seq[Int], severityOrHigher: Option[Int], designations: Seq[EventConfig.Designation], alarmStates: Seq[Alarm.State], lastEventType: Option[String], pageSize: Int): Seq[EventConfig] + def getEventConfigs(eventTypes: Seq[String]): Seq[EventConfig] + def putEventConfigs(notifier: ModelNotifier, configs: Seq[EventConfigTemp]): Seq[EventConfig] + def deleteEventConfigs(notifier: ModelNotifier, eventTypes: Seq[String]): Seq[EventConfig] + + def getEvents(eventIds: Seq[Long], filter: Option[EntityFilter] = None): Seq[Event] + def postEvents(notifier: ModelNotifier, templates: Seq[SysEventTemplate], filter: Option[EntityFilter] = None): Seq[Event] + def eventQuery(params: EventQueryParams, filter: Option[EntityFilter] = None): Seq[Event] + + def alarmQuery(alarmStates: Seq[Alarm.State], params: EventQueryParams, filter: Option[EntityFilter] = None): Seq[Alarm] + + def putAlarmStates(notifier: ModelNotifier, updates: Seq[(Long, Alarm.State)], filter: Option[EntityFilter] = None): Seq[Alarm] +} + +object SquerylEventAlarmModel extends EventAlarmModel { + import EventAlarmModel._ + + def eventConfigToProto(row: EventConfigRow): EventConfig = { + EventConfig.newBuilder() + .setEventType(row.eventType) + .setSeverity(row.severity) + .setDesignation(EventConfig.Designation.valueOf(row.designation)) + .setAlarmState(Alarm.State.valueOf(row.alarmState)) + .setResource(row.resource) + .setBuiltIn(row.builtIn) + .build() + } + + def eventConfigQuery(severities: Seq[Int], severityOrHigher: Option[Int], designations: Seq[EventConfig.Designation], alarmStates: Seq[Alarm.State], lastEventType: Option[String], pageSize: Int): Seq[EventConfig] = { + + val desigs = designations.map(_.getNumber) + val alarmStateNums = alarmStates.map(_.getNumber) + + val results = + from(eventConfigs)(ec => + where((ec.severity in severities).inhibitWhen(severities.isEmpty) and + (ec.severity lte severityOrHigher.?) and + (ec.designation in desigs).inhibitWhen(designations.isEmpty) and + (ec.alarmState in alarmStateNums).inhibitWhen(alarmStates.isEmpty) and + (ec.eventType gt lastEventType.?)) + select (ec) + orderBy (ec.eventType)) + .page(0, pageSize) + + results.toSeq map eventConfigToProto + } + + def getEventConfigs(eventTypes: Seq[String]): Seq[EventConfig] = { + val results = eventConfigs.where(ec => ec.eventType in eventTypes).toSeq + + results map eventConfigToProto + } + + def putEventConfigs(notifier: ModelNotifier, configs: Seq[EventConfigTemp]): Seq[EventConfig] = { + + val types = configs.map(_.eventType) + val existing = eventConfigs.where(ec => ec.eventType in types).toSeq + + val existNameSet = existing.map(_.eventType).toSet + + val (toBeUpdated, toBeCreated) = configs.partition(existNameSet contains _.eventType) + + val updateMap = toBeUpdated.map(u => (u.eventType, u)).toMap + + val creates = toBeCreated.map(c => new EventConfigRow(0, c.eventType, c.severity, c.designation.getNumber, c.alarmState.getNumber, c.resource, false)) + + val updates = existing.flatMap { row => + updateMap.get(row.eventType).map { up => + row.copy( + eventType = up.eventType, + severity = up.severity, + designation = up.designation.getNumber, + alarmState = up.alarmState.getNumber, + resource = up.resource) + } + } + + eventConfigs.insert(creates) + eventConfigs.update(updates) + + val results = getEventConfigs(configs.map(_.eventType)) + + val (updated, created) = results.partition(r => existNameSet contains r.getEventType) + + updated.foreach(notifier.notify(Updated, _)) + created.foreach(notifier.notify(Created, _)) + + results + } + + def deleteEventConfigs(notifier: ModelNotifier, eventTypes: Seq[String]): Seq[EventConfig] = { + val results = getEventConfigs(eventTypes) + + eventConfigs.deleteWhere(ec => ec.eventType in eventTypes) + + results.foreach(notifier.notify(Deleted, _)) + + results + } + + /*private def argsToArgList(args: Seq[Attribute]): AttributeList = { + import scala.collection.JavaConversions._ + AttributeList.newBuilder() + .addAllAttribute(args) + .build() + }*/ + + private def makeEvent(isAlarm: Boolean, template: SysEventTemplate, config: EventConfig): EventRow = { + + val now = System.currentTimeMillis() + val args = template.args + val argBytes = new Array[Byte](0) // argsToArgList(args).toByteArray + val rendered = MessageFormatter.format(config.getResource, args) + + EventRow( + 0, + template.eventType, + isAlarm, + now, + template.deviceTime, + config.getSeverity, + template.subsystem.getOrElse(""), + template.user, + template.entityUuid.map(protoUUIDToUuid), + template.modelGroup, + argBytes, + rendered) + } + + private def makeUnconfiguredEvent(template: SysEventTemplate): EventRow = { + val now = System.currentTimeMillis + val args = template.args + val argBytes = new Array[Byte](0) //argsToArgList(args).toByteArray + + EventRow(0, + template.eventType, + true, + now, + template.deviceTime, + unconfiguredEventSeverity, + template.subsystem.getOrElse(""), + template.user, + template.entityUuid.map(protoUUIDToUuid), + None, + argBytes, + unconfiguredEventMessage) + } + + private def eventToProto(row: EventRow): Event = eventToProto(row, false) + private def eventToProto(row: EventRow, skipId: Boolean = false): Event = { + val b = Event.newBuilder() + .setAgentName(row.userId) + .setAlarm(row.alarm) + .setEventType(row.eventType) + .setRendered(row.rendered) + .setSeverity(row.severity) + .setTime(row.time) + .setSubsystem(row.subsystem) + + if (!skipId) b.setId(ModelID.newBuilder.setValue(row.id.toString)) + + row.entityId.map(uuidToProtoUUID).foreach(b.setEntityUuid) + row.deviceTime.foreach(b.setDeviceTime) + row.modelGroup.foreach(b.setModelGroup) + + b.build() + } + + private def alarmToProto(row: AlarmRow, eventProto: Event): Alarm = { + Alarm.newBuilder() + .setId(ModelID.newBuilder.setValue(row.id.toString)) + .setEvent(eventProto) + .setState(Alarm.State.valueOf(row.state)) + .build + } + + def getEvents(eventIds: Seq[Long], filter: Option[EntityFilter] = None): Seq[Event] = { + if (eventIds.nonEmpty) { + events.where(ev => (ev.id in eventIds) and EntityFilter.optional(filter, ev.entityId)).toSeq map eventToProto + } else { + Nil + } + } + + private def eventQueryClause(ev: EventRow, params: EventQueryParams, filter: Option[EntityFilter] = None) = { + import params._ + + val hasEntitySelector = entityUuids.nonEmpty || entityNames.nonEmpty + + val entitySelector: Query[EntityRow] = if (hasEntitySelector) { + from(entities)(ent => + where((ent.id in entityUuids).inhibitWhen(entityUuids.isEmpty) or + (ent.name in entityNames).inhibitWhen(entityNames.isEmpty)) + select (ent)) + } else { + entities.where(t => true === true) + } + + EntityFilter.optional(filter, ev.entityId).inhibitWhen(filter.isEmpty) and + (ev.eventType in eventTypes).inhibitWhen(eventTypes.isEmpty) and + (ev.time gte timeFrom.?) and + (ev.time lte timeTo.?) and + (ev.severity in severities).inhibitWhen(severities.isEmpty) and + (ev.severity lte severityOrHigher.?) and + (ev.subsystem in subsystems).inhibitWhen(subsystems.isEmpty) and + (ev.userId in agents).inhibitWhen(agents.isEmpty) and + (ev.modelGroup in modelGroups).inhibitWhen(modelGroups.isEmpty) and + (ev.alarm === isAlarm.?) and + (ev.entityId in from(entitySelector)(ent => select(ent.id))).inhibitWhen(!hasEntitySelector) + } + + private def queryForEvents(params: EventQueryParams, filter: Option[EntityFilter] = None): Query[EventRow] = { + import params._ + + import org.squeryl.dsl.ast.{ OrderByArg, ExpressionNode } + def timeOrder(time: ExpressionNode) = { + if (!latest) { + new OrderByArg(time).asc + } else { + new OrderByArg(time).desc + } + } + def idOrder(id: ExpressionNode) = { + if (!latest) { + new OrderByArg(id).asc + } else { + new OrderByArg(id).desc + } + } + + from(events)(ev => + where( + eventQueryClause(ev, params, filter) and + (ev.id > params.last.?).inhibitWhen(params.latest) and + (ev.id < params.last.?).inhibitWhen(!params.latest)) + select (ev) + orderBy (timeOrder(ev.time), idOrder(ev.id))) + } + + def eventQuery(params: EventQueryParams, filter: Option[EntityFilter] = None): Seq[Event] = { + + val query = queryForEvents(params, filter) + + val results: Seq[EventRow] = query.page(0, params.pageSize).toSeq + + val protos = results map eventToProto + + if (!params.latest) protos else protos.reverse + } + + def postEvents(notifier: ModelNotifier, templates: Seq[SysEventTemplate], filter: Option[EntityFilter] = None): Seq[Event] = { + + filter.foreach { filt => + if (templates.exists(_.entityUuid.isEmpty)) { + throw new ModelPermissionException("Tried to create events with no entities with non-blanket create permissions") + } + + val relatedEntIds = templates.flatMap(t => t.entityUuid) + .distinct + .map(protoUUIDToUuid) + + if (filt.anyOutsideSet(relatedEntIds)) { + throw new ModelPermissionException("Tried to create events for restricted entities") + } + } + + val configTypes = templates.map(_.eventType).distinct + + val configs = getEventConfigs(configTypes) + + val configMap = configs.map(c => (c.getEventType, c)).toMap + + val mapped: Seq[(Boolean, EventRow, Option[Alarm.State])] = templates.map { temp => + + configMap.get(temp.eventType) match { + case None => (true, makeUnconfiguredEvent(temp), Some(Alarm.State.UNACK_SILENT)) + case Some(config) => config.getDesignation match { + case EventConfig.Designation.EVENT => + (true, makeEvent(false, temp, config), None) + case EventConfig.Designation.ALARM => + val initialState = config.getAlarmState + (true, makeEvent(true, temp, config), Some(initialState)) + case EventConfig.Designation.LOG => + (false, makeEvent(false, temp, config), None) + } + } + } + + val (real, logged) = mapped.partition(m => m._1) + + val insertResults: Seq[(EventRow, Option[AlarmRow])] = real.map { + case (_, eventRow, None) => + (events.insert(eventRow), None) + case (_, eventRow, Some(alarmState)) => + val event = events.insert(eventRow) + val alarm = alarms.insert(AlarmRow(0, alarmState.getNumber, event.id)) + (event, Some(alarm)) + } + + val realResults: Seq[(Event, Option[Alarm])] = insertResults.map { + case (eventRow, Some(alarmRow)) => + val eventProto = eventToProto(eventRow) + val alarmProto = alarmToProto(alarmRow, eventProto) + (eventProto, Some(alarmProto)) + case (eventRow, None) => + (eventToProto(eventRow), None) + } + + val loggedRows = logged.map(_._2) + + val logResults: Seq[Event] = loggedRows.map(eventToProto(_, skipId = true)) + + realResults.foreach { + case (eventProto, optAlarmProto) => + notifier.notify(Created, eventProto) + optAlarmProto.foreach(notifier.notify(Created, _)) + } + + logResults.foreach(notifier.notify(Created, _)) + + realResults.map(_._1) ++ logResults + } + + def alarmQuery(alarmStates: Seq[Alarm.State], params: EventQueryParams, filter: Option[EntityFilter] = None): Seq[Alarm] = { + + val eventQuery = queryForEvents(params, filter) + + val stateNums = alarmStates.map(_.getNumber) + + val latest = params.latest + + import org.squeryl.dsl.ast.{ OrderByArg, ExpressionNode } + def timeOrder(time: ExpressionNode) = { + if (!params.latest) { + new OrderByArg(time).asc + } else { + new OrderByArg(time).desc + } + } + def idOrder(id: ExpressionNode) = { + if (!latest) { + new OrderByArg(id).asc + } else { + new OrderByArg(id).desc + } + } + + val results: Seq[(EventRow, AlarmRow)] = + join(events, alarms)((ev, al) => + where( + eventQueryClause(ev, params, filter) and + (al.state in stateNums).inhibitWhen(stateNums.isEmpty) and + (al.id > params.last.?).inhibitWhen(params.latest) and + (al.id < params.last.?).inhibitWhen(!params.latest)) + select ((ev, al)) + orderBy (timeOrder(ev.time), idOrder(al.id)) + on (ev.id === al.eventId)) + .page(0, params.pageSize) + .toSeq + + val protos = results.map { + case (ev, al) => alarmToProto(al, eventToProto(ev)) + } + + if (!params.latest) protos else protos.reverse + } + + private def validAlarmTransition(prev: Alarm.State, next: Alarm.State): Boolean = { + import Alarm.State._ + (prev, next) match { + case (UNACK_AUDIBLE, UNACK_SILENT) => true + case (UNACK_AUDIBLE, ACKNOWLEDGED) => true + case (UNACK_SILENT, ACKNOWLEDGED) => true + case (ACKNOWLEDGED, REMOVED) => true + case (p, n) if p == n => true + case _ => false + } + } + + def alarmKeyQuery(ids: Seq[Long], filter: Option[EntityFilter] = None): Seq[Alarm] = { + + val results: Seq[(EventRow, AlarmRow)] = + from(events, alarms)((ev, al) => + where(EntityFilter.optional(filter, ev.entityId).inhibitWhen(filter.isEmpty) and + (al.id in ids) and + ev.id === al.eventId) + select (ev, al)).toSeq + + results.map { + case (ev, al) => alarmToProto(al, eventToProto(ev)) + } + } + + def putAlarmStates(notifier: ModelNotifier, updates: Seq[(Long, Alarm.State)], filter: Option[EntityFilter] = None): Seq[Alarm] = { + + val alarmIds = updates.map(_._1) + + filter.foreach { filt => + + val anyWithoutEnt = + from(events, alarms)((ev, al) => + where(al.id in alarmIds and + al.eventId === ev.id and + ev.entityId.isNull) + select (al.id)).page(0, 1).nonEmpty + + if (anyWithoutEnt) { + throw new ModelPermissionException("Tried to modify alarms with no associated entities without blanked update permissions") + } + + val relatedEntQuery = + from(events, alarms, entities)((ev, al, ent) => + where(al.id in alarmIds and + al.eventId === ev.id and + ev.entityId === Some(ent.id)) + select (ent)) + + if (filt.anyOutsideSet(relatedEntQuery)) { + throw new ModelPermissionException("Tried to modify alarms for restricted entities") + } + } + + val currentValues: Seq[AlarmRow] = alarms.where(al => al.id in alarmIds).toSeq + + val idMap = currentValues.map(a => (a.id, a)).toMap + + val toBeUpdated = updates.flatMap { + case (id, nextState) => + idMap.get(id) match { + case Some(current) => + val currentState = Alarm.State.valueOf(current.state) + if (currentState != nextState) { + if (validAlarmTransition(currentState, nextState)) { + Some(current.copy(state = nextState.getNumber)) + } else { + throw new ModelInputException(s"Invalid transition between alarm states $currentState -> $nextState") + } + } else { + None + } + case None => + throw new ModelInputException(s"No alarm exists for id $id") + } + } + + alarms.update(toBeUpdated) + + val results = alarmKeyQuery(alarmIds) + + val actuallyUpdatedIds = toBeUpdated.map(_.id).toSet + val actuallyUpdated = results.filter(a => actuallyUpdatedIds.contains(a.getId.getValue.toLong)) + actuallyUpdated.foreach(up => notifier.notify(Updated, up)) + + results + } +} diff --git a/services/src/main/scala/io/greenbus/services/model/EventSeeding.scala b/services/src/main/scala/io/greenbus/services/model/EventSeeding.scala new file mode 100644 index 0000000..b1b4421 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/model/EventSeeding.scala @@ -0,0 +1,70 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.model + +import io.greenbus.services.data.{ AlarmRow, EventConfigRow } + +object EventSeeding { + + import EventConfigRow.{ ALARM, EVENT } + + case class EventTypeConfig(eventType: String, designation: Int, severity: Int, resource: String) + + object System { + + val userLogin = EventTypeConfig("System.UserLogin", EVENT, 5, "User logged in: {user}") + val userLoginFailure = EventTypeConfig("System.UserLoginFailure", ALARM, 1, "User login failed {reason}") + val userLogout = EventTypeConfig("System.UserLogout", EVENT, 5, "User logged out") + + val controlExe = EventTypeConfig("System.ControlIssued", EVENT, 3, "Executed control {command}") + val updatedSetpoint = EventTypeConfig("System.SetpointIssued", EVENT, 3, "Updated setpoint {command} to {value}") + val setOverride = EventTypeConfig("System.SetOverride", EVENT, 3, "Point overridden: {point}") + val setNotInService = EventTypeConfig("System.SetNotInService", EVENT, 3, "Point removed from service: {point}") + val removeOverride = EventTypeConfig("System.RemoveOverride", EVENT, 3, "Removed override on point: {point}") + val removeNotInService = EventTypeConfig("System.RemoveNotInService", EVENT, 3, "Returned point to service: {point}") + + val endpointDisabled = EventTypeConfig("System.EndpointDisabled", EVENT, 3, "Endpoint {name} disabled") + val endpointEnabled = EventTypeConfig("System.EndpointEnabled", EVENT, 3, "Endpoint {name} enabled") + + val all = Seq( + userLogin, + userLoginFailure, + controlExe, + updatedSetpoint, + setOverride, + setNotInService, + removeOverride, + removeNotInService, + endpointDisabled, + endpointEnabled) + } + + def toRow(cfg: EventTypeConfig): EventConfigRow = { + import cfg._ + EventConfigRow(0, eventType, severity, designation, AlarmRow.UNACK_SILENT, resource, true) + } + + def seed() { + + val events = System.all.map(toRow) + + import io.greenbus.services.data.ServicesSchema._ + eventConfigs.insert(events) + } +} diff --git a/services/src/main/scala/io/greenbus/services/model/FrontEndModel.scala b/services/src/main/scala/io/greenbus/services/model/FrontEndModel.scala new file mode 100644 index 0000000..9d7a9b6 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/model/FrontEndModel.scala @@ -0,0 +1,526 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.model + +import java.util.UUID + +import io.greenbus.client.service.proto.FrontEnd._ +import io.greenbus.client.service.proto.Model._ +import io.greenbus.services.authz.EntityFilter +import io.greenbus.services.data.ServicesSchema._ +import io.greenbus.services.data.{ CommandRow, EndpointRow, PointRow, _ } +import io.greenbus.services.framework._ +import io.greenbus.services.model.EntityModel.TypeParams +import io.greenbus.services.model.UUIDHelpers._ +import org.squeryl.PrimitiveTypeMode._ +import org.squeryl.Query +import org.squeryl.dsl.ast.LogicalBoolean + +import scala.collection.JavaConversions._ + +object FrontEndModel { + val pointType = "Point" + val commandType = "Command" + + case class CoreTypeTemplate[A](uuidOpt: Option[UUID], name: String, types: Set[String], info: A) + + case class IdentifiedEntityTemplate[A](uuid: UUID, name: String, types: Set[String], info: A) + case class NamedEntityTemplate[A](name: String, types: Set[String], info: A) + + case class PointInfo(pointCategory: PointCategory, unit: String) + case class CommandInfo(displayName: String, commandCategory: CommandCategory) + case class EndpointInfo(protocol: String, disabled: Option[Boolean]) + case class ConfigFileInfo(mimeType: String, file: Array[Byte]) + + case class FrontEndConnectionTemplate(endpointId: UUID, inputAddress: String, commandAddress: Option[String]) + + case class EndpointDisabledUpdate(id: UUID, disabled: Boolean) + + def splitTemplates[A](templates: Seq[CoreTypeTemplate[A]]): (Seq[IdentifiedEntityTemplate[A]], Seq[NamedEntityTemplate[A]]) = { + + val (hasIds, noIds) = templates.partition(_.uuidOpt.nonEmpty) + + val withIds = hasIds.map(t => IdentifiedEntityTemplate(t.uuidOpt.get, t.name, t.types, t.info)) + val withNames = noIds.map(t => NamedEntityTemplate(t.name, t.types, t.info)) + + (withIds, withNames) + } +} +trait FrontEndModel { + import io.greenbus.services.model.FrontEndModel._ + + def pointExists(id: UUID, filter: Option[EntityFilter] = None): Boolean + + def pointKeyQuery(uuids: Seq[UUID], names: Seq[String], filter: Option[EntityFilter] = None): Seq[Point] + def pointQuery(pointCategories: Seq[PointCategory], units: Seq[String], typeParams: TypeParams, lastUuid: Option[UUID], lastName: Option[String], pageSize: Int, pageByName: Boolean = true, filter: Option[EntityFilter] = None): Seq[Point] + def putPoints(notifier: ModelNotifier, templates: Seq[CoreTypeTemplate[PointInfo]], allowCreate: Boolean = true, updateFilter: Option[EntityFilter] = None): Seq[Point] + def deletePoints(notifier: ModelNotifier, ids: Seq[UUID], filter: Option[EntityFilter] = None): Seq[Point] + + def commandKeyQuery(uuids: Seq[UUID], names: Seq[String], filter: Option[EntityFilter] = None): Seq[Command] + def commandQuery(commandCategories: Seq[CommandCategory], typeParams: TypeParams, lastUuid: Option[UUID], lastName: Option[String], pageSize: Int, pageByName: Boolean = true, filter: Option[EntityFilter] = None): Seq[Command] + def putCommands(notifier: ModelNotifier, points: Seq[CoreTypeTemplate[CommandInfo]], allowCreate: Boolean = true, filter: Option[EntityFilter] = None): Seq[Command] + def deleteCommands(notifier: ModelNotifier, ids: Seq[UUID], filter: Option[EntityFilter] = None): Seq[Command] + + def endpointKeyQuery(uuids: Seq[UUID], names: Seq[String], filter: Option[EntityFilter] = None): Seq[Endpoint] + def endpointQuery(protocols: Seq[String], disabledOpt: Option[Boolean], typeParams: TypeParams, lastUuid: Option[UUID], lastName: Option[String], pageSize: Int, pageByName: Boolean = true, filter: Option[EntityFilter] = None): Seq[Endpoint] + def putEndpoints(notifier: ModelNotifier, points: Seq[CoreTypeTemplate[EndpointInfo]], allowCreate: Boolean = true, filter: Option[EntityFilter] = None): Seq[Endpoint] + def putEndpointsDisabled(notifier: ModelNotifier, updates: Seq[EndpointDisabledUpdate], filter: Option[EntityFilter] = None): Seq[Endpoint] + def deleteEndpoints(notifier: ModelNotifier, ids: Seq[UUID], filter: Option[EntityFilter] = None): Seq[Endpoint] + + def putFrontEndConnections(notifier: ModelNotifier, templates: Seq[FrontEndConnectionTemplate], filter: Option[EntityFilter] = None): Seq[FrontEndRegistration] + def addressForCommand(command: UUID): Option[(Option[String], String)] + + def getFrontEndConnectionStatuses(uuids: Seq[UUID], names: Seq[String], filter: Option[EntityFilter] = None): Seq[FrontEndConnectionStatus] + def putFrontEndConnectionStatuses(notifier: ModelNotifier, updates: Seq[(UUID, FrontEndConnectionStatus.Status)], filter: Option[EntityFilter] = None): Seq[FrontEndConnectionStatus] +} + +object SquerylFrontEndModel extends FrontEndModel { + import io.greenbus.services.model.EntityBasedModels._ + import io.greenbus.services.model.FrontEndModel._ + + def pointExists(id: UUID, filter: Option[EntityFilter] = None): Boolean = { + from(points)((pt) => + where(EntityFilter.optional(filter, id) and + pt.entityId === id) + select (pt.id)).nonEmpty + } + + def sourceParentMap(children: Seq[UUID]): Map[UUID, UUID] = { + from(entities, edges, entities)((childEnt, edge, endEnt) => + where(childEnt.id in children and + edge.childId === childEnt.id and + edge.relationship === "source" and + edge.parentId === endEnt.id) + select (childEnt.id, endEnt.id)).toList.toMap + } + + private def buildPointProto(tup: (UUID, String, Seq[String], PointRow)): Point = { + val (id, name, types, row) = tup + Point.newBuilder() + .setUuid(id) + .setName(name) + .addAllTypes(types) + .setPointCategory(PointCategory.valueOf(row.pointCategory)) + .setUnit(row.unit) + .build() + } + + def pointKeyQuery(uuids: Seq[UUID], names: Seq[String], filter: Option[EntityFilter] = None): Seq[Point] = { + + val rows: Seq[(UUID, String, Seq[String], PointRow)] = entityBasedKeyQuery(uuids, names, points, filter) + + val pointToEndpointMap: Map[UUID, UUID] = sourceParentMap(rows.map(_._1)) + + rows.map { + case (id, name, types, row) => + val b = Point.newBuilder() + .setUuid(id) + .setName(name) + .addAllTypes(types) + .setPointCategory(PointCategory.valueOf(row.pointCategory)) + .setUnit(row.unit) + + pointToEndpointMap.get(id).foreach(uuid => b.setEndpointUuid(uuidToProtoUUID(uuid))) + + b.build + } + } + + def pointQuery(pointCategories: Seq[PointCategory], units: Seq[String], typeParams: TypeParams, lastUuid: Option[UUID], lastName: Option[String], pageSize: Int, pageByName: Boolean = true, filter: Option[EntityFilter] = None): Seq[Point] = { + + def rowClause(row: PointRow): LogicalBoolean = { + val pointTypeIds = pointCategories.map(_.getNumber) + + (row.pointCategory in pointTypeIds).inhibitWhen(pointTypeIds.isEmpty) and + (row.unit in units).inhibitWhen(units.isEmpty) + } + + val results = entityBasedQuery(points, typeParams, rowClause, lastUuid, lastName, pageSize, pageByName, filter) + + val pointToEndpointMap: Map[UUID, UUID] = sourceParentMap(results.map(_._1)) + + results.map { + case (uuid, name, types, row) => + val endpointUuid = pointToEndpointMap.get(uuid) + val b = Point.newBuilder() + .setUuid(uuid) + .setName(name) + .addAllTypes(types) + .setPointCategory(PointCategory.valueOf(row.pointCategory)) + .setUnit(row.unit) + + endpointUuid.map(uuidToProtoUUID).foreach(b.setEndpointUuid) + + b.build() + } + } + + def putPoints(notifier: ModelNotifier, templates: Seq[CoreTypeTemplate[PointInfo]], allowCreate: Boolean = true, updateFilter: Option[EntityFilter] = None): Seq[Point] = { + + val (withIds, withNames) = splitTemplates(templates.map(t => t.copy(types = t.types + "Point"))) + + def create(id: UUID, temp: PointInfo) = PointRow(0, id, temp.pointCategory.getNumber, temp.unit) + + def update(id: UUID, row: PointRow, temp: PointInfo) = { + if (row.pointCategory != temp.pointCategory.getNumber || row.unit != temp.unit) { + Some(row.copy(pointCategory = temp.pointCategory.getNumber, unit = temp.unit)) + } else { + None + } + } + + def toUuid(point: Point) = point.getUuid + + putEntityBased(notifier, withIds, withNames, "Point", create, update, pointKeyQuery(_, _, None), toUuid, points, allowCreate, updateFilter) + } + + def deletePoints(notifier: ModelNotifier, ids: Seq[UUID], filter: Option[EntityFilter] = None): Seq[Point] = { + deleteEntityBased(notifier, ids, pointKeyQuery(_, Nil), points, filter) + } + + private def buildCommandProto(tup: (UUID, String, Seq[String], CommandRow)): Command = { + val (id, name, types, row) = tup + Command.newBuilder() + .setUuid(id) + .setName(name) + .addAllTypes(types) + .setDisplayName(row.displayName) + .setCommandCategory(CommandCategory.valueOf(row.commandCategory)) + .build() + } + + def commandKeyQuery(uuids: Seq[UUID], names: Seq[String], filter: Option[EntityFilter] = None): Seq[Command] = { + + val rows: Seq[(UUID, String, Seq[String], CommandRow)] = entityBasedKeyQuery(uuids, names, commands, filter) + + val commandToEndpointMap: Map[UUID, UUID] = sourceParentMap(rows.map(_._1)) + + rows.map { + case (id, name, types, row) => + val b = Command.newBuilder() + .setUuid(id) + .setName(name) + .addAllTypes(types) + .setDisplayName(row.displayName) + .setCommandCategory(CommandCategory.valueOf(row.commandCategory)) + + commandToEndpointMap.get(id).foreach(uuid => b.setEndpointUuid(uuidToProtoUUID(uuid))) + b.build + } + } + + def commandQuery(commandCategories: Seq[CommandCategory], typeParams: TypeParams, lastUuid: Option[UUID], lastName: Option[String], pageSize: Int, pageByName: Boolean = true, filter: Option[EntityFilter] = None): Seq[Command] = { + + def rowClause(row: CommandRow): LogicalBoolean = { + val commandTypeIds = commandCategories.map(_.getNumber) + + (row.commandCategory in commandTypeIds).inhibitWhen(commandTypeIds.isEmpty) + } + + val results = entityBasedQuery(commands, typeParams, rowClause, lastUuid, lastName, pageSize, pageByName, filter) + + val pointToEndpointMap: Map[UUID, UUID] = sourceParentMap(results.map(_._1)) + + results.map { + case (uuid, name, types, row) => + val endpointUuid = pointToEndpointMap.get(uuid) + val b = Command.newBuilder() + .setUuid(uuid) + .setName(name) + .addAllTypes(types) + .setDisplayName(row.displayName) + .setCommandCategory(CommandCategory.valueOf(row.commandCategory)) + + endpointUuid.map(uuidToProtoUUID).foreach(b.setEndpointUuid) + + b.build() + } + } + + def putCommands(notifier: ModelNotifier, templates: Seq[CoreTypeTemplate[CommandInfo]], allowCreate: Boolean = true, updateFilter: Option[EntityFilter] = None): Seq[Command] = { + + val (withIds, withNames) = splitTemplates(templates.map(t => t.copy(types = t.types + "Command"))) + + def create(id: UUID, temp: CommandInfo) = CommandRow(0, id, temp.displayName, temp.commandCategory.getNumber, None) + + def update(id: UUID, row: CommandRow, temp: CommandInfo) = { + if (row.commandCategory != temp.commandCategory.getNumber || row.displayName != temp.displayName) { + Some(row.copy(commandCategory = temp.commandCategory.getNumber, displayName = temp.displayName)) + } else { + None + } + } + + def toUuid(command: Command) = command.getUuid + + putEntityBased(notifier, withIds, withNames, "Command", create, update, commandKeyQuery(_, _, None), toUuid, commands, allowCreate, updateFilter) + } + + def deleteCommands(notifier: ModelNotifier, ids: Seq[UUID], filter: Option[EntityFilter] = None): Seq[Command] = { + deleteEntityBased(notifier, ids, commandKeyQuery(_, Nil), commands, filter) + } + + private def buildEndpoint(tup: (UUID, String, Seq[String], EndpointRow)): Endpoint = tup match { + case (id, name, types, row) => + Endpoint.newBuilder() + .setUuid(id) + .setName(name) + .addAllTypes(types) + .setProtocol(row.protocol) + .setDisabled(row.disabled) + .build + } + + def endpointKeyQuery(uuids: Seq[UUID], names: Seq[String], filter: Option[EntityFilter] = None): Seq[Endpoint] = { + entityBasedKeyQuery(uuids, names, endpoints, filter).map(buildEndpoint) + } + + def endpointQuery(protocols: Seq[String], disabledOpt: Option[Boolean], typeParams: TypeParams, lastUuid: Option[UUID], lastName: Option[String], pageSize: Int, pageByName: Boolean = true, filter: Option[EntityFilter] = None): Seq[Endpoint] = { + + def rowClause(row: EndpointRow): LogicalBoolean = { + + (row.protocol in protocols).inhibitWhen(protocols.isEmpty) and + (row.disabled === disabledOpt.?) + } + + val results = entityBasedQuery(endpoints, typeParams, rowClause, lastUuid, lastName, pageSize, pageByName, filter) + + results.map(buildEndpoint) + } + + def putEndpoints(notifier: ModelNotifier, templates: Seq[CoreTypeTemplate[EndpointInfo]], allowCreate: Boolean = true, updateFilter: Option[EntityFilter] = None): Seq[Endpoint] = { + + val (withIds, withNames) = splitTemplates(templates.map(t => t.copy(types = t.types + "Endpoint"))) + + def create(id: UUID, temp: EndpointInfo) = EndpointRow(0, id, temp.protocol, temp.disabled.getOrElse(false)) + + def update(id: UUID, row: EndpointRow, temp: EndpointInfo) = { + val disabledChanged = temp.disabled.nonEmpty && temp.disabled != Some(row.disabled) + if (row.protocol != temp.protocol || disabledChanged) { + val withDisabled = temp.disabled.map(d => row.copy(disabled = d)).getOrElse(row) + Some(withDisabled.copy(protocol = temp.protocol)) + } else { + None + } + } + + def toUuid(proto: Endpoint) = proto.getUuid + + putEntityBased(notifier, withIds, withNames, "Endpoint", create, update, endpointKeyQuery(_, _, None), toUuid, endpoints, allowCreate, updateFilter) + } + + def deleteEndpoints(notifier: ModelNotifier, ids: Seq[UUID], filter: Option[EntityFilter] = None): Seq[Endpoint] = { + deleteEntityBased(notifier, ids, endpointKeyQuery(_, Nil), endpoints, filter) + } + + def putEndpointsDisabled(notifier: ModelNotifier, updates: Seq[EndpointDisabledUpdate], filter: Option[EntityFilter] = None): Seq[Endpoint] = { + + val ids = updates.map(_.id) + + filter.foreach { filter => + if (filter.anyOutsideSet(ids)) { + throw new ModelPermissionException("Tried to modify restricted objects") + } + } + + val current: Map[UUID, EndpointRow] = endpoints.where(end => end.entityId in ids).toList.map(row => (row.entityId, row)).toMap + + val updateRows = updates.flatMap { up => + current.get(up.id) match { + case None => throw new ModelInputException(s"Endpoint with id ${up.id} not found") + case Some(row) => + if (row.disabled != up.disabled) { + Some(row.copy(disabled = up.disabled)) + } else { + None + } + } + } + + if (updateRows.nonEmpty) { + endpoints.update(updateRows) + } + + val updateIds = updateRows.map(_.entityId) + + val results = endpointKeyQuery(ids, Nil) + + val updatedResults = results.filter(end => updateIds.contains(protoUUIDToUuid(end.getUuid))) + + updatedResults.foreach(notifier.notify(Updated, _)) + + results + } + + def frontEndConnectionKeyQuery(endpointUuids: Seq[UUID], endpointNames: Seq[String], filter: Option[EntityFilter] = None): Seq[FrontEndRegistration] = { + + val rows: Seq[(UUID, String, Option[String])] = + from(entities, endpoints, frontEndConnections)((ent, end, fep) => + where(EntityFilter.optional(filter, ent).inhibitWhen(filter.isEmpty) and + ((ent.id in endpointUuids).inhibitWhen(endpointUuids.isEmpty) or + (ent.name in endpointNames).inhibitWhen(endpointNames.isEmpty)) and + (end.entityId === ent.id) and + (fep.endpointId === end.entityId)) + select (ent.id, fep.inputAddress, fep.commandAddress)).toSeq + + rows.map { + case (uuid, inputAddr, optCmdAddr) => + val b = FrontEndRegistration.newBuilder() + .setEndpointUuid(uuid) + .setInputAddress(inputAddr) + + optCmdAddr.foreach(b.setCommandAddress) + b.build() + } + } + + def putFrontEndConnections(notifier: ModelNotifier, templates: Seq[FrontEndConnectionTemplate], filter: Option[EntityFilter] = None): Seq[FrontEndRegistration] = { + val ids = templates.map(_.endpointId) + filter.foreach { filt => + if (filt.anyOutsideSet(ids)) { + throw new ModelPermissionException("Tried to register for restricted endpoint") + } + } + + val current: Map[UUID, Option[FrontEndConnectionRow]] = + join(endpoints, frontEndConnections.leftOuter)((end, fep) => + where(end.entityId in ids) + select (end.entityId, fep) + on (Some(end.entityId) === fep.map(_.endpointId))).toSeq.toMap + + if (ids.exists(id => !current.contains(id))) { + throw new ModelInputException("Endpoint for front end connection does not exist") + } + + val existing: Seq[(UUID, FrontEndConnectionRow)] = ids.flatMap(id => current(id).map(row => (id, row))) + + val createIds = ids.filter(current(_).isEmpty) + + val templateMap: Map[UUID, FrontEndConnectionTemplate] = templates.map(temp => (temp.endpointId, temp)).toMap + + val updateRows = existing.map { + case (id, oldRow) => + val template = templateMap(id) + oldRow.copy(inputAddress = template.inputAddress, + commandAddress = template.commandAddress) + } + + val insertRows = createIds.map { id => + val template = templateMap(id) + FrontEndConnectionRow(0, id, template.inputAddress, template.commandAddress) + } + + frontEndConnections.update(updateRows) + frontEndConnections.insert(insertRows) + + val results = frontEndConnectionKeyQuery(ids, Nil) + + val updateSet = existing.map(_._1).toSet + val insertSet = createIds.toSet + + results.filter(r => insertSet.contains(r.getEndpointUuid)).foreach(notifier.notify(Created, _)) + results.filter(r => updateSet.contains(r.getEndpointUuid)).foreach(notifier.notify(Updated, _)) + + results + } + + def addressForCommand(command: UUID): Option[(Option[String], String)] = { + + val edgeForCommand = + from(edges)(e => + where(e.childId === command and + e.relationship === "source" and + e.distance === 1) + select (e)) + + from(commands, endpoints, frontEndConnections, entities)((cmd, end, fep, ent) => + where(cmd.entityId === command and + (ent.id === cmd.entityId) and + (end.entityId in from(edgeForCommand)(e => select(e.parentId))) and + fep.endpointId === end.entityId) + select ((fep.commandAddress, ent.name))).headOption + } + + private def connectionStatusQuery(uuids: Seq[UUID], names: Seq[String], filter: Option[EntityFilter] = None): Query[(UUID, String, FrontEndCommStatusRow)] = { + from(endpoints, entities, frontEndCommStatuses)((end, ent, stat) => + where( + EntityFilter.optional(filter, ent).inhibitWhen(filter.isEmpty) and + (ent.id in uuids).inhibitWhen(uuids.isEmpty) and + (ent.name in names).inhibitWhen(names.isEmpty) and + end.entityId === ent.id and + stat.endpointId === end.entityId) + select (ent.id, ent.name, stat)) + } + + def getFrontEndConnectionStatuses(uuids: Seq[UUID], names: Seq[String], filter: Option[EntityFilter] = None): Seq[FrontEndConnectionStatus] = { + + val results = connectionStatusQuery(uuids, names, filter).toSeq + + results.map { + case (uuid, name, statusRow) => + FrontEndConnectionStatus.newBuilder() + .setEndpointUuid(uuid) + .setEndpointName(name) + .setState(FrontEndConnectionStatus.Status.valueOf(statusRow.status)) + .setUpdateTime(statusRow.updateTime) + .build() + }.toList + } + + def putFrontEndConnectionStatuses(notifier: ModelNotifier, updates: Seq[(UUID, FrontEndConnectionStatus.Status)], filter: Option[EntityFilter] = None): Seq[FrontEndConnectionStatus] = { + val uuids = updates.map(_._1) + filter.foreach { filt => + if (filt.anyOutsideSet(uuids)) { + throw new ModelPermissionException("Tried to register for restricted endpoint") + } + } + + val existing: List[(UUID, String, FrontEndCommStatusRow)] = connectionStatusQuery(uuids, Nil).toList + val existingUuids = existing.map(_._1).toSet + + val missingUuids = uuids.filterNot(existingUuids.contains) + + val endpointSet: Set[UUID] = + from(endpoints, entities)((end, ent) => + where((ent.id in missingUuids) and end.entityId === ent.id) + select (ent.id)).toList.toSet + + if (missingUuids.filterNot(endpointSet.contains).nonEmpty) { + throw new ModelInputException("Tried to update connection statuses for non-existent endpoints") + } + + val uuidToStatusMap = updates.toMap + val now = System.currentTimeMillis() + + val missingRecords = missingUuids.flatMap { uuid => uuidToStatusMap.get(uuid).map(status => FrontEndCommStatusRow(0, uuid, status.getNumber, now)) } + + val updateRecords = existing.flatMap { + case (uuid, name, row) => uuidToStatusMap.get(uuid).map(status => row.copy(status = status.getNumber, updateTime = now)) + } + + frontEndCommStatuses.insert(missingRecords) + frontEndCommStatuses.update(updateRecords) + + val results = getFrontEndConnectionStatuses(uuids, Nil) + + results.foreach(notifier.notify(Updated, _)) + + results + } +} diff --git a/services/src/main/scala/io/greenbus/services/model/ModelHelpers.scala b/services/src/main/scala/io/greenbus/services/model/ModelHelpers.scala new file mode 100644 index 0000000..349587b --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/model/ModelHelpers.scala @@ -0,0 +1,62 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.model + +import scala.collection.mutable +import org.squeryl.dsl.ast.{ BinaryOperatorNodeLogicalBoolean, LogicalBoolean } + +object ModelHelpers { + + def groupSortedAndPreserveOrder[A, B](input: Seq[(A, B)]): Seq[(A, Seq[B])] = { + val results = mutable.ListBuffer.empty[(A, Seq[B])] + var current = Option.empty[A] + var currentAccum = mutable.ListBuffer.empty[B] + val itr = input.iterator + while (itr.hasNext) { + val (a, b) = itr.next() + current match { + case None => + current = Some(a) + currentAccum += b + case Some(currentA) => { + if (currentA != a) { + results += ((currentA, currentAccum)) + current = Some(a) + currentAccum = mutable.ListBuffer.empty[B] + currentAccum += b + } else { + currentAccum += b + } + } + } + } + + current.foreach { curr => + results += ((curr, currentAccum)) + } + results + } + + def logicalCombine(op: String)(a: LogicalBoolean, b: LogicalBoolean) = { + new BinaryOperatorNodeLogicalBoolean(a, b, op) + } + + def logicalAnd = logicalCombine("and") _ + def logicalOr = logicalCombine("or") _ +} diff --git a/services/src/main/scala/io/greenbus/services/model/ModelInputException.scala b/services/src/main/scala/io/greenbus/services/model/ModelInputException.scala new file mode 100644 index 0000000..096a434 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/model/ModelInputException.scala @@ -0,0 +1,28 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.model + +// State of the database has revealed an error +class ModelInputException(msg: String) extends Exception(msg) + +// Auth forbidden +class ModelPermissionException(msg: String) extends ModelInputException(msg) + +// Breaks data invariant, likely internal service error +class ModelAssertionException(msg: String) extends ModelInputException(msg) diff --git a/services/src/main/scala/io/greenbus/services/model/ProcessingModel.scala b/services/src/main/scala/io/greenbus/services/model/ProcessingModel.scala new file mode 100644 index 0000000..fd824be --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/model/ProcessingModel.scala @@ -0,0 +1,208 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.model + +import java.util.UUID + +import com.google.protobuf.GeneratedMessage +import org.squeryl.PrimitiveTypeMode._ +import io.greenbus.client.service.proto.Model.ModelUUID +import io.greenbus.client.service.proto.Processing.MeasOverride +import io.greenbus.services.authz.EntityFilter +import io.greenbus.services.core.OverrideWithEndpoint +import io.greenbus.services.data.ServicesSchema._ +import io.greenbus.services.framework._ +import io.greenbus.services.model.UUIDHelpers._ + +object ProcessingModel { + val overrideKey = "meas_override" + val triggerSetKey = "trigger_set" + +} +trait ProcessingModel { + + def overrideKeyQuery(uuids: Seq[UUID], names: Seq[String], filter: Option[EntityFilter] = None): Seq[MeasOverride] + def putOverrides(notifier: ModelNotifier, overrides: Seq[(UUID, MeasOverride)], filter: Option[EntityFilter]): Seq[MeasOverride] + def deleteOverrides(notifier: ModelNotifier, ids: Seq[UUID], filter: Option[EntityFilter] = None): Seq[MeasOverride] + +} + +object SquerylProcessingModel extends ProcessingModel { + import io.greenbus.services.model.ProcessingModel._ + + def overrideKeyQuery(uuids: Seq[UUID], names: Seq[String], filter: Option[EntityFilter]): Seq[MeasOverride] = { + + val allIds = allPointIdsForKeyQuery(uuids, names, filter) + + val results = SquerylEntityModel.getKeyValues(allIds, overrideKey) + + results.map { case (_, bytes) => MeasOverride.parseFrom(bytes) } + } + + def putOverrides(notifier: ModelNotifier, overrides: Seq[(UUID, MeasOverride)], filter: Option[EntityFilter]): Seq[MeasOverride] = { + + def toNote(obj: MeasOverride, end: Option[ModelUUID]) = OverrideWithEndpoint(obj, end) + + putPointBasedKeyValues(notifier, overrides, overrideKey, MeasOverride.parseFrom, toNote, filter) + } + + def deleteOverrides(notifier: ModelNotifier, ids: Seq[UUID], filter: Option[EntityFilter]): Seq[MeasOverride] = { + def keyValueToResult(pointUuid: UUID, obj: Array[Byte], end: Option[UUID]) = MeasOverride.parseFrom(obj) + def keyValueToNotification(pointUuid: UUID, obj: MeasOverride, end: Option[UUID]) = OverrideWithEndpoint(obj, end.map(uuidToProtoUUID)) + + SquerylProcessingModel.deleteKeyValuesWithEndpoint(notifier, ids, overrideKey, keyValueToResult, keyValueToNotification, filter) + } + + def allPointIdsForKeyQuery(uuids: Seq[UUID], names: Seq[String], filter: Option[EntityFilter]): Seq[UUID] = { + + val idsForNames: Seq[UUID] = + from(entities, points)((ent, pt) => + where(EntityFilter.optional(filter, ent).inhibitWhen(filter.isEmpty) and + ent.id === pt.entityId and + (ent.name in names)) + select (ent.id)).toSeq + + uuids ++ idsForNames + } + + def putPointBasedKeyValuesWithEndpoint[PayloadType <: GeneratedMessage, ResultType, NoteType](notifier: ModelNotifier, + values: Seq[(UUID, PayloadType)], + key: String, + toResult: (UUID, Array[Byte], Option[UUID]) => ResultType, + toNotification: (UUID, ResultType, Option[UUID]) => NoteType, + filter: Option[EntityFilter]): Seq[ResultType] = { + + val ids = values.map(_._1) + filter.foreach { filt => + if (filt.anyOutsideSet(ids)) { + throw new ModelPermissionException("Tried to touch restricted entities") + } + } + + val existingPoints = from(entities, points)((ent, pt) => + where((ent.id in ids) and pt.entityId === ent.id) + select (ent.id)).toSeq + + val existingPointsSet = existingPoints.toSet + if (ids.exists(id => !existingPointsSet.contains(id))) { + throw new ModelInputException("Tried to put value to non-existent point") + } + + val kvs = values.map { case (id, proto) => (id, proto.toByteArray) } + + val (results, creates, updates) = SquerylEntityModel.putKeyValues(kvs, key) + + val endpointMap = SquerylFrontEndModel.sourceParentMap(ids) + + val parsed = results.map { case (id, bytes) => (id, toResult(id, bytes, endpointMap.get(id))) } + + val parsedMap = parsed.toMap + + creates.foreach { uuid => + parsedMap.get(uuid).foreach(obj => notifier.notify(Created, toNotification(uuid, obj, endpointMap.get(uuid)))) + } + updates.foreach { uuid => + parsedMap.get(uuid).foreach(obj => notifier.notify(Updated, toNotification(uuid, obj, endpointMap.get(uuid)))) + } + + parsed.map(_._2) + } + + def putPointBasedKeyValues[A <: GeneratedMessage, NoteType](notifier: ModelNotifier, + values: Seq[(UUID, A)], + key: String, + parse: Array[Byte] => A, + noteFunc: (A, Option[ModelUUID]) => NoteType, + filter: Option[EntityFilter]): Seq[A] = { + + val ids = values.map(_._1) + filter.foreach { filt => + if (filt.anyOutsideSet(ids)) { + throw new ModelPermissionException("Tried to touch restricted entities") + } + } + + val existingPoints = from(entities, points)((ent, pt) => + where((ent.id in ids) and pt.entityId === ent.id) + select (ent.id)).toSeq + + val existingPointsSet = existingPoints.toSet + if (ids.exists(id => !existingPointsSet.contains(id))) { + throw new ModelInputException("Tried to put value to non-existent point") + } + + val kvs = values.map { case (id, proto) => (id, proto.toByteArray) } + + val (results, creates, updates) = SquerylEntityModel.putKeyValues(kvs, key) + + val parsed = results.map { case (id, bytes) => (id, parse(bytes)) } + + val parsedMap = parsed.toMap + + val endpointMap = SquerylFrontEndModel.sourceParentMap(ids) + + creates.foreach { uuid => + parsedMap.get(uuid).foreach(obj => notifier.notify(Created, noteFunc(obj, endpointMap.get(uuid).map(uuidToProtoUUID)))) + } + updates.foreach { uuid => + parsedMap.get(uuid).foreach(obj => notifier.notify(Updated, noteFunc(obj, endpointMap.get(uuid).map(uuidToProtoUUID)))) + } + + parsed.map(_._2) + } + + def deleteKeyValues[A](notifier: ModelNotifier, ids: Seq[UUID], key: String, parse: Array[Byte] => A, filter: Option[EntityFilter]): Seq[A] = { + val results = SquerylEntityModel.deleteKeyValues(ids, key, filter) + val parsed = results.map { case (id, bytes) => (id, parse(bytes)) } + + val values = parsed.map(_._2) + values.foreach(notifier.notify(Deleted, _)) + values + } + + def deleteKeyValuesWithEndpoint[PayloadType <: GeneratedMessage, ResultType, NoteType]( + notifier: ModelNotifier, + ids: Seq[UUID], + key: String, + toResult: (UUID, Array[Byte], Option[UUID]) => ResultType, + toNotification: (UUID, ResultType, Option[UUID]) => NoteType, + filter: Option[EntityFilter]): Seq[ResultType] = { + + filter.foreach { filt => + if (filt.anyOutsideSet(ids)) { + throw new ModelPermissionException("Tried to delete restricted entity key values") + } + } + + val endpointMap = SquerylFrontEndModel.sourceParentMap(ids) + + val results = SquerylEntityModel.deleteKeyValues(ids, key, None) + val resultParts: Seq[(UUID, ResultType, Option[UUID])] = results.map { + case (id, bytes) => + val endpointUuid = endpointMap.get(id) + (id, toResult(id, bytes, endpointUuid), endpointUuid) + } + + resultParts.foreach { + case (id, result, endOpt) => notifier.notify(Deleted, toNotification(id, result, endOpt)) + } + + resultParts.map(_._2) + } +} diff --git a/services/src/main/scala/io/greenbus/services/model/UUIDHelpers.scala b/services/src/main/scala/io/greenbus/services/model/UUIDHelpers.scala new file mode 100644 index 0000000..90eb781 --- /dev/null +++ b/services/src/main/scala/io/greenbus/services/model/UUIDHelpers.scala @@ -0,0 +1,30 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.model + +import java.util.UUID +import io.greenbus.client.service.proto.Model.{ ModelID, ModelUUID } + +object UUIDHelpers { + implicit def uuidToProtoUUID(uuid: UUID): ModelUUID = ModelUUID.newBuilder().setValue(uuid.toString).build() + implicit def protoUUIDToUuid(uuid: ModelUUID): UUID = UUID.fromString(uuid.getValue) + + implicit def longToProtoId(id: Long): ModelID = ModelID.newBuilder().setValue(id.toString).build() + implicit def protoIdToLong(id: ModelID): Long = id.getValue.toLong +} diff --git a/services/src/test/scala/io/greenbus/services/DbBenchmarking.scala b/services/src/test/scala/io/greenbus/services/DbBenchmarking.scala new file mode 100644 index 0000000..ff3adbe --- /dev/null +++ b/services/src/test/scala/io/greenbus/services/DbBenchmarking.scala @@ -0,0 +1,88 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services + +import io.greenbus.sql.{ DbConnector, SqlSettings, DbConnection } +import io.greenbus.services.model.SquerylEntityModel +import io.greenbus.util.Timing +import io.greenbus.services.data.ServicesSchema +import io.greenbus.services.framework.NullModelNotifier + +object DbBenchmarking { + + def connectSql(): DbConnection = { + val config = SqlSettings.load("../io.greenbus.sql.cfg") + DbConnector.connect(config) + } + + def buildEntities(count: Int): Seq[(String, Seq[String])] = { + Range(0, count).map(i => (s"ent$i", Seq("typeA", "typeB", "typeC"))) + } + + def trial(sql: DbConnection, entCount: Int, record: Boolean) { + val ents = buildEntities(entCount) + + sql.transaction { + ServicesSchema.reset() + } + + val sepTrans = Timing.benchmark { + ents.foreach { ent => + sql.transaction { + SquerylEntityModel.putEntities(NullModelNotifier, Nil, Seq((ent._1, ent._2.toSet))) + } + } + } + + val sepTransSingle = Timing.benchmark { + ents.foreach { ent => + sql.transaction { + SquerylEntityModel.putEntity(NullModelNotifier, ent._1, ent._2, false, false, false) + } + } + } + + val oneTransSeparate = Timing.benchmark { + sql.transaction { + ents.foreach(ent => SquerylEntityModel.putEntity(NullModelNotifier, ent._1, ent._2, false, false, false)) + } + } + + val batched = Timing.benchmark { + sql.transaction { + SquerylEntityModel.putEntities(NullModelNotifier, Nil, ents.map(ent => (ent._1, ent._2.toSet))) + } + } + + if (record) { + println(Seq(entCount, sepTrans, sepTransSingle, oneTransSeparate, batched).mkString("\t")) + } + } + + def main(args: Array[String]) { + + val sql = connectSql() + + // warmup + trial(sql, 5, false) + + Range(0, 12).foreach(n => trial(sql, Math.pow(2, n).toInt, true)) + + } +} diff --git a/services/src/test/scala/io/greenbus/services/authz/EntityFilterTest.scala b/services/src/test/scala/io/greenbus/services/authz/EntityFilterTest.scala new file mode 100644 index 0000000..587dd55 --- /dev/null +++ b/services/src/test/scala/io/greenbus/services/authz/EntityFilterTest.scala @@ -0,0 +1,89 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.authz + +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner +import io.greenbus.services.model.{ ServiceTestBase, ModelTestHelpers } +import org.squeryl.PrimitiveTypeMode._ +import io.greenbus.services.data.ServicesSchema._ + +@RunWith(classOf[JUnitRunner]) +class EntityFilterTest extends ServiceTestBase { + import ModelTestHelpers._ + + def check(results: Set[String], denies: Seq[ResourceSelector], allows: Seq[ResourceSelector]) { + val filter = ResourceSelector.buildFilter(denies, allows) + val sql = from(entities)(ent => where(filter(ent.id)) select (ent.name)) + //println(sql.statement) + val names = sql.toSeq + names.size should equal(results.size) + names.toSet should equal(results) + } + + /* + 5 1 2 6 + 3 4 + */ + test("Set filtering") { + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeB")) + val ent03 = createEntity("ent03", Seq("typeX", "typeY")) + val ent04 = createEntity("ent04", Seq("typeY", "typeZ")) + val ent05 = createEntity("ent05", Seq("typeB")) + val ent06 = createEntity("ent06", Seq("typeA")) + createEdge(ent01.id, ent03.id, "owns", 1) + createEdge(ent01.id, ent04.id, "owns", 1) + createEdge(ent02.id, ent04.id, "owns", 1) + createEdge(ent02.id, ent06.id, "unrelated", 1) + + val all = Set("ent01", "ent02", "ent03", "ent04", "ent05", "ent06") + + { + val denies = Seq(TypeSelector(Seq("typeA", "typeB"))) + val allows = Nil + check(Set("ent03", "ent04"), denies, allows) + } + + { + val denies = Nil + val allows = Seq(TypeSelector(Seq("typeY"))) + check(Set("ent03", "ent04"), denies, allows) + } + + { + val denies = Seq(TypeSelector(Seq("typeZ"))) + val allows = Seq(TypeSelector(Seq("typeY"))) + check(Set("ent03"), denies, allows) + } + + { + val denies = Nil + val allows = Seq(ParentSelector(Seq("ent01"))) + check(Set("ent03", "ent04"), denies, allows) + } + + { + val denies = Seq(ParentSelector(Seq("ent02"))) + val allows = Seq(ParentSelector(Seq("ent01"))) + check(Set("ent03"), denies, allows) + } + + } +} diff --git a/services/src/test/scala/io/greenbus/services/core/LoginTests.scala b/services/src/test/scala/io/greenbus/services/core/LoginTests.scala new file mode 100644 index 0000000..235d2f1 --- /dev/null +++ b/services/src/test/scala/io/greenbus/services/core/LoginTests.scala @@ -0,0 +1,306 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.core + +import java.util.UUID +import java.util.concurrent.TimeoutException + +import io.greenbus.client.proto.Envelope +import io.greenbus.client.service.proto.Events.Event +import io.greenbus.client.service.proto.LoginRequests.{ LoginRequest, PostLoginRequest, PostLoginResponse } +import io.greenbus.jmx.MetricsManager +import io.greenbus.msg.{ SubscriptionBinding, Subscription } +import io.greenbus.msg.amqp.{ AmqpAddressedMessage, AmqpServiceOperations } +import io.greenbus.msg.service.{ ServiceHandler, ServiceHandlerSubscription } +import io.greenbus.services.authz.{ AuthLookup, DefaultAuthLookup } +import io.greenbus.services.data.ServicesSchema +import io.greenbus.services.framework._ +import io.greenbus.services.model.ModelTestHelpers.TestModelNotifier +import io.greenbus.services.model.{ ModelTestHelpers, EventSeeding, SquerylAuthModel, SquerylEventAlarmModel } +import io.greenbus.services.{ AsyncAuthenticationModule, AuthenticationModule, SqlAuthenticationModule } +import io.greenbus.sql.test.DatabaseUsingTestBaseNoTransaction +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner +import org.squeryl.PrimitiveTypeMode._ + +import scala.concurrent.duration._ +import scala.concurrent.{ Await, Future, promise } + +@RunWith(classOf[JUnitRunner]) +class LoginTests extends DatabaseUsingTestBaseNoTransaction { + def schemas = List(ServicesSchema) + + override protected def resetDbAfterTestSuite: Boolean = true + + override def beforeAll(): Unit = super.beforeAll() + + class TestSyncAuthModule extends SqlAuthenticationModule { + + var result: (Boolean, Option[UUID]) = (false, None) + var attemptOpt = Option.empty[(String, String)] + + def authenticate(name: String, password: String): (Boolean, Option[UUID]) = { + attemptOpt = Some((name, password)) + result + } + } + + class TestAsyncAuthModule extends AsyncAuthenticationModule { + var attemptOpt = Option.empty[(String, String)] + var result: Future[Boolean] = Future.failed(new TimeoutException("timed out")) + def authenticate(name: String, password: String): Future[Boolean] = { + attemptOpt = Some((name, password)) + result + } + } + + def request(name: String = "name01", pass: String = "pass01"): PostLoginRequest = { + PostLoginRequest.newBuilder() + .setRequest( + LoginRequest.newBuilder() + .setName(name) + .setPassword(pass) + .setClientVersion("vers01") + .setExpirationTime(System.currentTimeMillis() + 30000) + .setLoginLocation("loc01") + .build()) + .build() + } + + val fakeServiceOps = new AmqpServiceOperations { + override def publishEvent(exchange: String, msg: Array[Byte], key: String): Unit = ??? + + override def bindRoutedService(handler: ServiceHandler): SubscriptionBinding = ??? + + override def competingServiceBinding(exchange: String): ServiceHandlerSubscription = ??? + + override def declareExchange(exchange: String): Unit = ??? + + override def bindQueue(queue: String, exchange: String, key: String): Unit = ??? + + override def bindCompetingService(handler: ServiceHandler, exchange: String): SubscriptionBinding = ??? + + override def simpleSubscription(): Subscription[Array[Byte]] = ??? + + override def publishBatch(messages: Seq[AmqpAddressedMessage]): Unit = ??? + + override def routedServiceBinding(): ServiceHandlerSubscription = ??? + } + + class TestRig(val testAuthModule: AuthenticationModule) { + + val authLookup: AuthLookup = DefaultAuthLookup + val eventMapper = new ModelEventMapper + val metricsMgr = MetricsManager("io.greenbus.services") + + val registry = new FullServiceRegistry(dbConnection, fakeServiceOps, authLookup, eventMapper, metricsMgr) + val notifier = new TestModelNotifier + + val services = new LoginServices(registry, dbConnection, testAuthModule, SquerylAuthModel, SquerylEventAlarmModel, notifier) + + //var prom = promise[Response[PostLoginResponse]] + + var respOpt = Option.empty[Response[PostLoginResponse]] + def callback(resp: Response[PostLoginResponse]): Unit = { + respOpt = Some(resp) + } + } + + class AsyncTestRig { + val testAuthModule = new TestAsyncAuthModule + val authLookup: AuthLookup = DefaultAuthLookup + val eventMapper = new ModelEventMapper + val metricsMgr = MetricsManager("io.greenbus.services") + + val registry = new FullServiceRegistry(dbConnection, fakeServiceOps, authLookup, eventMapper, metricsMgr) + val notifier = new TestModelNotifier + + val services = new LoginServices(registry, dbConnection, testAuthModule, SquerylAuthModel, SquerylEventAlarmModel, notifier) + + val prom = promise[Response[PostLoginResponse]] + + def callback(resp: Response[PostLoginResponse]): Unit = { + prom.complete(scala.util.Success(resp)) + } + } + + test("sync failure") { + val req = request() + val testAuthModule = new TestSyncAuthModule + val r = new TestRig(testAuthModule) + import r._ + + services.loginAsync(req, Map(), callback) + + testAuthModule.attemptOpt should equal(Some(("name01", "pass01"))) + + respOpt match { + case Some(Failure(Envelope.Status.UNAUTHORIZED, _)) => + case _ => fail() + } + + val events = notifier.getQueue(classOf[Event]) + events.size should equal(1) + events.head._2.getEventType should equal(EventSeeding.System.userLoginFailure.eventType) + } + + test("sync success") { + val req = request() + val testAuthModule = new TestSyncAuthModule + val r = new TestRig(testAuthModule) + import r._ + + val uuid = UUID.randomUUID() + testAuthModule.result = (true, Some(uuid)) + + services.loginAsync(req, Map(), callback) + + testAuthModule.attemptOpt should equal(Some(("name01", "pass01"))) + + respOpt match { + case Some(Success(Envelope.Status.OK, resp)) => + resp.getResponse.hasToken should equal(true) + case _ => fail() + } + + val all = dbConnection.transaction { + ServicesSchema.authTokens.where(t => true === true).toVector + } + all.size should equal(1) + all.head.agentId should equal(uuid) + all.head.loginLocation should equal(req.getRequest.getLoginLocation) + + val events = notifier.getQueue(classOf[Event]) + events.size should equal(1) + events.head._2.getEventType should equal(EventSeeding.System.userLogin.eventType) + } + + test("async failure timeout") { + val req = request() + val r = new AsyncTestRig + import r._ + + services.loginAsync(req, Map(), callback) + + testAuthModule.attemptOpt should equal(Some(("name01", "pass01"))) + val fut = prom.future + + Await.result(fut, 5000.milliseconds) match { + case Failure(Envelope.Status.RESPONSE_TIMEOUT, _) => + case _ => fail() + } + + val events = notifier.getQueue(classOf[Event]) + events.size should equal(1) + events.head._2.getEventType should equal(EventSeeding.System.userLoginFailure.eventType) + } + + test("async rando error") { + val req = request() + val r = new AsyncTestRig + import r._ + + testAuthModule.result = Future.failed(new Exception("some random exception")) + + services.loginAsync(req, Map(), callback) + + testAuthModule.attemptOpt should equal(Some(("name01", "pass01"))) + val fut = prom.future + + Await.result(fut, 5000.milliseconds) match { + case Failure(Envelope.Status.UNAUTHORIZED, _) => + case _ => fail() + } + + val events = notifier.getQueue(classOf[Event]) + events.size should equal(1) + events.head._2.getEventType should equal(EventSeeding.System.userLoginFailure.eventType) + } + + test("async negative response") { + val req = request() + val r = new AsyncTestRig + import r._ + + testAuthModule.result = Future.successful(false) + + services.loginAsync(req, Map(), callback) + + testAuthModule.attemptOpt should equal(Some(("name01", "pass01"))) + val fut = prom.future + + Await.result(fut, 5000.milliseconds) match { + case Failure(Envelope.Status.UNAUTHORIZED, _) => + case _ => fail() + } + + val events = notifier.getQueue(classOf[Event]) + events.size should equal(1) + events.head._2.getEventType should equal(EventSeeding.System.userLoginFailure.eventType) + } + + test("async positive response, no agent") { + val req = request() + val r = new AsyncTestRig + import r._ + + testAuthModule.result = Future.successful(true) + + services.loginAsync(req, Map(), callback) + + testAuthModule.attemptOpt should equal(Some(("name01", "pass01"))) + val fut = prom.future + + Await.result(fut, 5000.milliseconds) match { + case Failure(Envelope.Status.UNAUTHORIZED, _) => + case _ => fail() + } + + val events = notifier.getQueue(classOf[Event]) + events.size should equal(1) + events.head._2.getEventType should equal(EventSeeding.System.userLoginFailure.eventType) + } + + test("async positive response, agent exists") { + val req = request(name = "existingAgent", pass = "pass02") + val r = new AsyncTestRig + import r._ + + dbConnection.transaction { + ModelTestHelpers.createAgent("existingAgent", "pass02") + } + + testAuthModule.result = Future.successful(true) + + services.loginAsync(req, Map(), callback) + + testAuthModule.attemptOpt should equal(Some(("existingAgent", "pass02"))) + val fut = prom.future + + Await.result(fut, 5000.milliseconds) match { + case Success(Envelope.Status.OK, resp) => + resp.getResponse.hasToken should equal(true) + case _ => fail() + } + + val events = notifier.getQueue(classOf[Event]) + events.size should equal(1) + events.head._2.getEventType should equal(EventSeeding.System.userLogin.eventType) + } +} diff --git a/services/src/test/scala/io/greenbus/services/framework/ModelNotifierTest.scala b/services/src/test/scala/io/greenbus/services/framework/ModelNotifierTest.scala new file mode 100644 index 0000000..d9f0b69 --- /dev/null +++ b/services/src/test/scala/io/greenbus/services/framework/ModelNotifierTest.scala @@ -0,0 +1,63 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.framework + +import io.greenbus.msg.amqp.{ AmqpMessage, AmqpAddressedMessage } +import org.scalatest.FunSuite +import org.scalatest.matchers.ShouldMatchers +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner +import scala.collection.mutable.Queue + +@RunWith(classOf[JUnitRunner]) +class ModelNotifierTest extends FunSuite with ShouldMatchers { + + case class First(str: String) + case class Second(str: String) + + test("Mapper") { + val queue1 = Queue.empty[(ModelEvent, First)] + val queue2 = Queue.empty[(ModelEvent, Second)] + + val trans1 = new ModelEventTransformer[First] { + def toMessage(typ: ModelEvent, event: First): AmqpAddressedMessage = { + queue1 += ((typ, event)) + AmqpAddressedMessage("exch1", "key1", AmqpMessage("str1".getBytes(), None, None)) + } + } + + val trans2 = new ModelEventTransformer[Second] { + def toMessage(typ: ModelEvent, event: Second): AmqpAddressedMessage = { + queue2 += ((typ, event)) + AmqpAddressedMessage("exch2", "key2", AmqpMessage("str2".getBytes(), None, None)) + } + } + + val mapper = new ModelEventMapper + mapper.register(classOf[First], trans1) + mapper.register(classOf[Second], trans2) + + mapper.toMessage(Created, First("one")) + mapper.toMessage(Deleted, Second("one")) + + queue1.toSeq should equal(Seq((Created, First("one")))) + queue2.toSeq should equal(Seq((Deleted, Second("one")))) + } + +} diff --git a/services/src/test/scala/io/greenbus/services/model/AuthModelTest.scala b/services/src/test/scala/io/greenbus/services/model/AuthModelTest.scala new file mode 100644 index 0000000..998d05f --- /dev/null +++ b/services/src/test/scala/io/greenbus/services/model/AuthModelTest.scala @@ -0,0 +1,427 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.model + +import java.util.UUID + +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner +import org.squeryl.PrimitiveTypeMode._ +import io.greenbus.services.data.ServicesSchema +import io.greenbus.services.framework._ +import io.greenbus.services.model.AuthModel.{ PermissionSetInfo, AgentInfo, CoreUntypedTemplate } + +@RunWith(classOf[JUnitRunner]) +class AuthModelTest extends ServiceTestBase { + import io.greenbus.services.model.ModelTestHelpers._ + + def checkPermissions(id: UUID, names: Set[String]) { + import io.greenbus.services.data.ServicesSchema._ + val agentId = from(agents)(a => where(a.id === id) select (a.id)).single + val setNames = from(agentSetJoins, permissionSets)((j, ps) => + where(j.agentId === agentId and ps.id === j.permissionSetId) + select (ps.name)).toVector + + setNames.size should equal(names.size) + setNames.toSet should equal(names) + } + def checkPermissions(agentName: String, names: Set[String]) { + import io.greenbus.services.data.ServicesSchema._ + + val agentId = from(agents)(a => + where(a.name === agentName) + select (a.id)).single + + val permNames = from(agentSetJoins, permissionSets)((j, ps) => + where(j.agentId === agentId and ps.id === j.permissionSetId) + select (ps.name)).toVector + + permNames.size should equal(names.size) + permNames.toSet should equal(names) + } + + class AuthFixture { + val perm1 = createPermissionSet("perm01") + val perm2 = createPermissionSet("perm02") + val perm3 = createPermissionSet("perm03") + val agent01 = createAgent("agent01", "pass01") + val agent02 = createAgent("agent02") + val agent03 = createAgent("agent03") + addPermissionToAgent(perm1.id, agent01.id) + addPermissionToAgent(perm2.id, agent01.id) + addPermissionToAgent(perm1.id, agent02.id) + addPermissionToAgent(perm3.id, agent02.id) + addPermissionToAgent(perm2.id, agent03.id) + addPermissionToAgent(perm3.id, agent03.id) + } + + test("Simple login") { + val f = new AuthFixture + import f._ + + val now = System.currentTimeMillis() + val result = SquerylAuthModel.simpleLogin("agent01", "pass01", now + 5000, "ver01", "loc01") + result.isRight should equal(true) + val token = result.right.get._1 + + val rows = ServicesSchema.authTokens.where(t => t.token === token).toList + rows.size should equal(1) + val row = rows.head + + row.agentId should equal(agent01.id) + row.clientVersion should equal("ver01") + row.loginLocation should equal("loc01") + row.expirationTime should equal(now + 5000) + row.revoked should equal(false) + + val joins = ServicesSchema.tokenSetJoins.where(t => t.authTokenId === row.id).toList + joins.size should equal(2) + joins.map(_.permissionSetId).toSet should equal(Set(perm1.id, perm2.id)) + } + + test("Login validation") { + val f = new AuthFixture + + val now = System.currentTimeMillis() + val result = SquerylAuthModel.simpleLogin("agent01", "pass01", now + 5000, "ver01", "loc01") + result.isRight should equal(true) + val token = result.right.get._1 + + val validateResult = SquerylAuthModel.authValidate(token) + validateResult should equal(true) + } + + test("Login validation fail") { + val f = new AuthFixture + + val now = System.currentTimeMillis() + val result = SquerylAuthModel.simpleLogin("agent01", "pass01", now + 5000, "ver01", "loc01") + result.isRight should equal(true) + val token = result.right.get._1 + + val validateResult = SquerylAuthModel.authValidate("crap") + validateResult should equal(false) + } + + test("Logout") { + val f = new AuthFixture + import f._ + + val now = System.currentTimeMillis() + val result = SquerylAuthModel.simpleLogin("agent01", "pass01", now + 5000, "ver01", "loc01") + result.isRight should equal(true) + val token = result.right.get._1 + + SquerylAuthModel.simpleLogout(token) + + val rows = ServicesSchema.authTokens.where(t => t.token === token).toList + rows.size should equal(1) + val row = rows.head + + row.agentId should equal(agent01.id) + row.clientVersion should equal("ver01") + row.loginLocation should equal("loc01") + row.expirationTime should equal(now + 5000) + row.revoked should equal(true) + + val joins = ServicesSchema.tokenSetJoins.where(t => t.authTokenId === row.id).toList + joins.size should equal(2) + joins.map(_.permissionSetId).toSet should equal(Set(perm1.id, perm2.id)) + } + + test("Validate logged out") { + val f = new AuthFixture + + val now = System.currentTimeMillis() + val result = SquerylAuthModel.simpleLogin("agent01", "pass01", now + 5000, "ver01", "loc01") + result.isRight should equal(true) + val token = result.right.get._1 + + SquerylAuthModel.simpleLogout(token) + + val validateResult = SquerylAuthModel.authValidate(token) + validateResult should equal(false) + } + + def createAgentAndEntity(setNameToId: Map[String, Long], name: String, types: Set[String], permissionSets: Set[String]) = { + val agent01 = createAgent(name) + permissionSets.foreach { setName => + val permId = setNameToId(setName) + addPermissionToAgent(permId, agent01.id) + } + } + + test("Agent query") { + val f = new AuthFixture + + val agentF = createAgent("agentF", "pass01") + val agentE = createAgent("agentE") + val agentD = createAgent("agentD") + val perm4 = createPermissionSet("perm04") + addPermissionToAgent(perm4.id, agentE.id) + addPermissionToAgent(perm4.id, agentD.id) + addPermissionToAgent(perm4.id, f.agent02.id) + + val p1 = ("agent01", Set("perm01", "perm02")) + val p2 = ("agent02", Set("perm01", "perm03", "perm04")) + val p3 = ("agent03", Set("perm02", "perm03")) + val p4 = ("agentF", Set.empty[String]) + val p5 = ("agentE", Set("perm04")) + val p6 = ("agentD", Set("perm04")) + + check(Set(p1, p2)) { + SquerylAuthModel.agentQuery(None, Seq("perm01"), None, None, 100) + } + + check(Set(p1, p2, p3)) { + SquerylAuthModel.agentQuery(None, Seq("perm01", "perm03"), None, None, 100) + } + + check(Set(p1, p2)) { + SquerylAuthModel.agentQuery(None, Seq(), None, None, 2, pageByName = true) + } + check(Set(p3, p6)) { + SquerylAuthModel.agentQuery(None, Seq(), None, lastName = Some("agent02"), 2, pageByName = true) + } + check(Set(p5, p4)) { + SquerylAuthModel.agentQuery(None, Seq(), None, lastName = Some("agentD"), 2, pageByName = true) + } + + check(Set(p2, p6)) { + SquerylAuthModel.agentQuery(None, Seq("perm04"), None, lastName = None, 2, pageByName = true) + } + check(Set(p5)) { + SquerylAuthModel.agentQuery(None, Seq("perm04"), None, lastName = Some("agentD"), 2, pageByName = true) + } + } + + test("Agent put") { + val notifier = new TestModelNotifier + createPermissionSet("perm01") + createPermissionSet("perm02") + + val perms = Set("perm01", "perm02") + + val puts = Seq(CoreUntypedTemplate(Option.empty[UUID], "agent01", AgentInfo(Some("secret"), Seq("perm01", "perm02")))) + + val r1 = ("agent01", Set("perm01", "perm02")) + val all = Set(r1) + + check(all) { + SquerylAuthModel.putAgents(notifier, puts) + } + + checkPermissions("agent01", perms) + + notifier.checkAgents(Set((Created, ("agent01", Set("perm01", "perm02"))))) + } + + test("Agent put swaps permissions") { + val notifier = new TestModelNotifier + createPermissionSet("perm01") + createPermissionSet("perm02") + + { + val puts = Seq(CoreUntypedTemplate(Option.empty[UUID], "agent01", AgentInfo(Some("secret"), Seq("perm01")))) + + val r1 = ("agent01", Set("perm01")) + val all = Set(r1) + + check(all) { + SquerylAuthModel.putAgents(notifier, puts) + } + } + + { + val puts = Seq(CoreUntypedTemplate(Option.empty[UUID], "agent01", AgentInfo(Some("secret"), Seq("perm02")))) + + val r1 = ("agent01", Set("perm02")) + val all = Set(r1) + + check(all) { + SquerylAuthModel.putAgents(notifier, puts) + } + } + + notifier.checkAgents(Set( + (Created, ("agent01", Set("perm01"))), + (Updated, ("agent01", Set("perm02"))))) + } + + test("Agent put with non-existent permissions") { + val notifier = new TestModelNotifier + createPermissionSet("perm01") + + intercept[ModelInputException] { + SquerylAuthModel.putAgents(notifier, Seq(CoreUntypedTemplate(Option.empty[UUID], "agent01", AgentInfo(Some("secret"), Seq("perm01", "nope"))))) + } + } + + test("Agent put battery") { + + val notifier = new TestModelNotifier + + val perm1 = createPermissionSet("perm01") + val perm2 = createPermissionSet("perm02") + val perm3 = createPermissionSet("perm03") + val agent01 = createAgent("agent01", "pass01") + val agent02 = createAgent("agent02") + addPermissionToAgent(perm1.id, agent01.id) + addPermissionToAgent(perm2.id, agent01.id) + addPermissionToAgent(perm1.id, agent02.id) + addPermissionToAgent(perm3.id, agent02.id) + + val baseName = "agent" + val uuid04 = UUID.randomUUID() + + val puts = Seq( + CoreUntypedTemplate[UUID, AgentInfo](Some(agent01.id), s"${baseName}11", AgentInfo(Some("pass01"), Seq("perm01"))), + CoreUntypedTemplate[UUID, AgentInfo](None, s"${baseName}02", AgentInfo(None, Seq("perm02", "perm03"))), + CoreUntypedTemplate[UUID, AgentInfo](None, s"${baseName}03", AgentInfo(Some("pass02"), Seq("perm01", "perm03"))), + CoreUntypedTemplate[UUID, AgentInfo](Some(uuid04), s"${baseName}04", AgentInfo(Some("pass44"), Seq("perm02")))) + + val p1 = (s"${baseName}11", Set("perm01")) + val p2 = (s"${baseName}02", Set("perm02", "perm03")) + val p3 = (s"${baseName}03", Set("perm01", "perm03")) + val p4 = (s"${baseName}04", Set("perm02")) + val all = Set(p1, p2, p3, p4) + + check(all) { + SquerylAuthModel.putAgents(notifier, puts) + } + + notifier.checkAgents( + Set( + (Updated, (s"${baseName}11", Set("perm01"))), + (Updated, (s"${baseName}02", Set("perm02", "perm03"))), + (Created, (s"${baseName}03", Set("perm01", "perm03"))), + (Created, (s"${baseName}04", Set("perm02"))))) + } + + test("Agent delete") { + val notifier = new TestModelNotifier + val f = new AuthFixture + import f._ + + val toBeDeleted = Set( + ("agent01", Set("perm01", "perm02")), + ("agent02", Set("perm01", "perm03"))) + + check(toBeDeleted) { + SquerylAuthModel.deleteAgents(notifier, Seq(agent01.id, agent02.id)) + } + + val agents = ServicesSchema.agents.where(t => true === true).toSeq + agents.size should equal(1) + agents.head.id should equal(agent03.id) + + val setsFor03 = ServicesSchema.agentSetJoins.where(j => true === true).map(j => (j.agentId, j.permissionSetId)) + setsFor03.size should equal(2) + setsFor03.toSet should equal(Set((agent03.id, perm2.id), (agent03.id, perm3.id))) + + notifier.checkAgents(Set( + (Deleted, ("agent01", Set("perm01", "perm02"))), + (Deleted, ("agent02", Set("perm01", "perm03"))))) + } + + test("Permission set query") { + + val perm1 = createPermissionSet("perm01") + val perm2 = createPermissionSet("perm02") + val perm3 = createPermissionSet("perm03") + val perm4 = createPermissionSet("perm04") + val perm5 = createPermissionSet("perm05") + + val emptyPerms = Set.empty[(Set[String], Set[String])] + + val p1 = ("perm01", emptyPerms) + val p2 = ("perm02", emptyPerms) + val p3 = ("perm03", emptyPerms) + val p4 = ("perm04", emptyPerms) + val p5 = ("perm05", emptyPerms) + val all = Seq(p1, p2, p3, p4, p5) + + checkOrder(all) { + SquerylAuthModel.permissionSetQuery(None, 100) + } + + checkOrder(Seq(p1, p2)) { + SquerylAuthModel.permissionSetQuery(None, 2) + } + checkOrder(Seq(p3, p4)) { + SquerylAuthModel.permissionSetQuery(Some(perm2.id), 2) + } + checkOrder(Seq(p5)) { + SquerylAuthModel.permissionSetQuery(Some(perm4.id), 2) + } + + } + + test("Permission set put battery") { + val notifier = new TestModelNotifier + val baseName = "permissionSet" + + val perm3 = createPermissionSet(s"${baseName}03", Seq(createPermission("res03", "get"))) + val perm4 = createPermissionSet(s"${baseName}04", Seq(createPermission("res04", "get"))) + + val puts = Seq( + CoreUntypedTemplate[Long, PermissionSetInfo](None, s"${baseName}01", PermissionSetInfo(Seq(createPermission("res01", "get")))), + CoreUntypedTemplate[Long, PermissionSetInfo](None, s"${baseName}02", PermissionSetInfo(Seq(createPermission("res02", "get")))), + CoreUntypedTemplate[Long, PermissionSetInfo](None, s"${baseName}03", PermissionSetInfo(Seq(createPermission("res33", "get")))), + CoreUntypedTemplate[Long, PermissionSetInfo](Some(perm4.id), s"${baseName}44", PermissionSetInfo(Seq(createPermission("res04", "delete"))))) + + def simp(name: String, res: String, act: String) = (name, Set((Set(res), Set(act)))) + + val p1 = simp(s"${baseName}01", "res01", "get") + val p2 = simp(s"${baseName}02", "res02", "get") + val p3 = simp(s"${baseName}03", "res33", "get") + val p4 = simp(s"${baseName}44", "res04", "delete") + val all = Set(p1, p2, p3, p4) + + check(all) { + SquerylAuthModel.putPermissionSets(notifier, puts) + } + + notifier.checkPermissionSets( + Set( + (Created, p1), + (Created, p2), + (Updated, p3), + (Updated, p4))) + } + + test("Permission set delete") { + val notifier = new TestModelNotifier + val f = new AuthFixture + import f._ + + val results = SquerylAuthModel.deletePermissionSets(notifier, Seq(perm1.id, perm2.id)) + results.size should equal(2) + results.map(_.getName).toSet should equal(Set("perm01", "perm02")) + + val setsFor03 = ServicesSchema.agentSetJoins.where(j => true === true).map(j => (j.agentId, j.permissionSetId)) + setsFor03.size should equal(2) + setsFor03.toSet should equal(Set((agent02.id, perm3.id), (agent03.id, perm3.id))) + + notifier.checkPermissionSetNames(Set( + (Deleted, "perm01"), + (Deleted, "perm02"))) + } + +} diff --git a/services/src/test/scala/io/greenbus/services/model/CommandModelTest.scala b/services/src/test/scala/io/greenbus/services/model/CommandModelTest.scala new file mode 100644 index 0000000..c84124d --- /dev/null +++ b/services/src/test/scala/io/greenbus/services/model/CommandModelTest.scala @@ -0,0 +1,255 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.model + +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner +import io.greenbus.client.service.proto.Model.CommandCategory +import java.util.UUID +import io.greenbus.services.framework.{ Deleted, Created } +import io.greenbus.client.service.proto.Commands.CommandLock + +@RunWith(classOf[JUnitRunner]) +class CommandModelTest extends ServiceTestBase { + import ModelTestHelpers._ + + class Fixture { + val notifier = new TestModelNotifier + val ent01 = createEntity("command01", Seq("Command")) + val ent02 = createEntity("command02", Seq("Command")) + val ent03 = createEntity("command03", Seq("Command")) + val cmd01 = createCommand(ent01.id, "displayCommand01", CommandCategory.CONTROL) + val cmd02 = createCommand(ent02.id, "displayCommand02", CommandCategory.CONTROL) + val cmd03 = createCommand(ent03.id, "displayCommand03", CommandCategory.CONTROL) + val agent01 = createAgent("agent01") + val agent02 = createAgent("agent02") + + val allAgentIds = Seq(agent01, agent02).map(_.id) + val allCmdIds = Seq(cmd01, cmd02, cmd03).map(_.entityId) + + def hasSelect(cmd: UUID, agent: UUID, shouldBe: Boolean) { + SquerylCommandModel.agentHasSelect(cmd, agent) should equal(shouldBe) + } + def canIssue(cmd: UUID, agent: UUID, shouldBe: Boolean): Unit = { + SquerylCommandModel.agentCanIssueCommand(cmd, agent) should equal(shouldBe) + } + + def permutations[A](f: (UUID, UUID) => A) = { + allAgentIds.foreach(a => allCmdIds.foreach(c => f(a, c))) + } + } + + test("Select") { + val f = new Fixture + import f._ + + def allUnselected() { + permutations((a, c) => hasSelect(c, a, false)) + permutations((a, c) => canIssue(c, a, true)) + } + + allUnselected() + + val select = SquerylCommandModel.selectCommands(notifier, Seq(cmd01.entityId, cmd02.entityId), agent01.id, Long.MaxValue) + + hasSelect(cmd01.entityId, agent01.id, true) + hasSelect(cmd02.entityId, agent01.id, true) + hasSelect(cmd03.entityId, agent01.id, false) + hasSelect(cmd01.entityId, agent02.id, false) + hasSelect(cmd02.entityId, agent02.id, false) + hasSelect(cmd03.entityId, agent02.id, false) + + allCmdIds.foreach(c => canIssue(c, agent01.id, true)) + Seq(cmd01, cmd02).map(_.entityId).foreach(c => canIssue(c, agent02.id, false)) + canIssue(cmd03.entityId, agent02.id, true) + + val deleted = SquerylCommandModel.deleteLocks(notifier, Seq(select.getId.getValue.toLong)) + + allUnselected() + + notifier.checkCommandLocks(Set( + (Created, (CommandLock.AccessMode.ALLOWED, Set(cmd01.entityId, cmd02.entityId), agent01.id, Some(Long.MaxValue))), + (Deleted, (CommandLock.AccessMode.ALLOWED, Set(cmd01.entityId, cmd02.entityId), agent01.id, Some(Long.MaxValue))))) + } + + test("Select bars later selects") { + val f = new Fixture + import f._ + + SquerylCommandModel.selectCommands(notifier, Seq(cmd01.entityId, cmd02.entityId), agent01.id, Long.MaxValue) + + intercept[CommandLockException] { + SquerylCommandModel.selectCommands(notifier, Seq(cmd02.entityId, cmd03.entityId), agent02.id, Long.MaxValue) + } + + } + + test("Block bars later select") { + val f = new Fixture + import f._ + + val block = SquerylCommandModel.blockCommands(notifier, Seq(cmd01.entityId, cmd02.entityId), agent01.id) + + intercept[CommandLockException] { + SquerylCommandModel.selectCommands(notifier, Seq(cmd02.entityId, cmd03.entityId), agent01.id, Long.MaxValue) + } + + SquerylCommandModel.deleteLocks(notifier, Seq(block.getId.getValue.toLong)) + + SquerylCommandModel.selectCommands(notifier, Seq(cmd02.entityId, cmd03.entityId), agent01.id, Long.MaxValue) + + hasSelect(cmd01.entityId, agent01.id, false) + hasSelect(cmd02.entityId, agent01.id, true) + hasSelect(cmd03.entityId, agent01.id, true) + + canIssue(cmd01.entityId, agent01.id, true) + canIssue(cmd02.entityId, agent01.id, true) + canIssue(cmd03.entityId, agent01.id, true) + + canIssue(cmd01.entityId, agent02.id, true) + canIssue(cmd02.entityId, agent02.id, false) + canIssue(cmd03.entityId, agent02.id, false) + } + + test("Block occludes earlier select") { + val f = new Fixture + import f._ + + SquerylCommandModel.selectCommands(notifier, Seq(cmd01.entityId, cmd02.entityId), agent01.id, Long.MaxValue) + + hasSelect(cmd01.entityId, agent01.id, true) + hasSelect(cmd02.entityId, agent01.id, true) + hasSelect(cmd03.entityId, agent01.id, false) + + canIssue(cmd01.entityId, agent01.id, true) + canIssue(cmd02.entityId, agent01.id, true) + canIssue(cmd03.entityId, agent01.id, true) + + canIssue(cmd01.entityId, agent02.id, false) + canIssue(cmd02.entityId, agent02.id, false) + canIssue(cmd03.entityId, agent02.id, true) + + SquerylCommandModel.blockCommands(notifier, Seq(cmd02.entityId, cmd03.entityId), agent01.id) + + hasSelect(cmd01.entityId, agent01.id, true) + hasSelect(cmd02.entityId, agent01.id, false) + hasSelect(cmd03.entityId, agent01.id, false) + + canIssue(cmd01.entityId, agent01.id, true) + canIssue(cmd02.entityId, agent01.id, false) + canIssue(cmd03.entityId, agent01.id, false) + + canIssue(cmd01.entityId, agent02.id, false) + canIssue(cmd02.entityId, agent02.id, false) + canIssue(cmd03.entityId, agent02.id, false) + + notifier.checkCommandLocks(Set( + (Created, (CommandLock.AccessMode.ALLOWED, Set(cmd01.entityId, cmd02.entityId), agent01.id, Some(Long.MaxValue))), + (Created, (CommandLock.AccessMode.BLOCKED, Set(cmd02.entityId, cmd03.entityId), agent01.id, None)))) + } + + test("Lock query") { + val f = new Fixture + import f._ + + val select = SquerylCommandModel.selectCommands(notifier, Seq(cmd01.entityId, cmd02.entityId), agent01.id, Long.MaxValue) + val block = SquerylCommandModel.blockCommands(notifier, Seq(cmd03.entityId), agent01.id) + + val lk1: (CommandLock.AccessMode, Set[UUID], UUID, Option[Long]) = (CommandLock.AccessMode.ALLOWED, Set(cmd01.entityId, cmd02.entityId), agent01.id, Some(Long.MaxValue)) + val lk2: (CommandLock.AccessMode, Set[UUID], UUID, Option[Long]) = (CommandLock.AccessMode.BLOCKED, Set(cmd03.entityId), agent01.id, None) + + check(Set(lk1)) { + SquerylCommandModel.lockQuery(Seq(cmd01.entityId), Nil, None, None, 300) + } + check(Set(lk2)) { + SquerylCommandModel.lockQuery(Seq(cmd03.entityId), Nil, None, None, 300) + } + } + + test("Lock query overlap") { + val f = new Fixture + import f._ + + val select = SquerylCommandModel.selectCommands(notifier, Seq(cmd01.entityId, cmd02.entityId), agent01.id, Long.MaxValue) + val block = SquerylCommandModel.blockCommands(notifier, Seq(cmd02.entityId, cmd03.entityId), agent01.id) + + val lk1: (CommandLock.AccessMode, Set[UUID], UUID, Option[Long]) = (CommandLock.AccessMode.ALLOWED, Set(cmd01.entityId, cmd02.entityId), agent01.id, Some(Long.MaxValue)) + val lk2: (CommandLock.AccessMode, Set[UUID], UUID, Option[Long]) = (CommandLock.AccessMode.BLOCKED, Set(cmd02.entityId, cmd03.entityId), agent01.id, None) + + check(Set(lk1, lk2)) { + SquerylCommandModel.lockQuery(Seq(cmd02.entityId), Nil, None, None, 300) + } + check(Set(lk2)) { + SquerylCommandModel.lockQuery(Seq(cmd02.entityId), Nil, Some(CommandLock.AccessMode.BLOCKED), None, 300) + } + } + + test("Lock query agents") { + val f = new Fixture + import f._ + + val select = SquerylCommandModel.selectCommands(notifier, Seq(cmd01.entityId, cmd02.entityId), agent01.id, Long.MaxValue) + val block = SquerylCommandModel.blockCommands(notifier, Seq(cmd02.entityId, cmd03.entityId), agent02.id) + + val lk1: (CommandLock.AccessMode, Set[UUID], UUID, Option[Long]) = (CommandLock.AccessMode.ALLOWED, Set(cmd01.entityId, cmd02.entityId), agent01.id, Some(Long.MaxValue)) + val lk2: (CommandLock.AccessMode, Set[UUID], UUID, Option[Long]) = (CommandLock.AccessMode.BLOCKED, Set(cmd02.entityId, cmd03.entityId), agent02.id, None) + + check(Set(lk1, lk2)) { + SquerylCommandModel.lockQuery(Nil, Seq(agent01.id, agent02.id), None, None, 300) + } + check(Set(lk2)) { + SquerylCommandModel.lockQuery(Nil, Seq(agent01.id, agent02.id), Some(CommandLock.AccessMode.BLOCKED), None, 300) + } + check(Set(lk1)) { + SquerylCommandModel.lockQuery(Nil, Seq(agent01.id), None, None, 300) + } + } + + import UUIDHelpers._ + + test("Lock query paging") { + val f = new Fixture + import f._ + + val select1 = SquerylCommandModel.selectCommands(notifier, Seq(cmd01.entityId), agent01.id, Long.MaxValue) + val select2 = SquerylCommandModel.selectCommands(notifier, Seq(cmd02.entityId), agent01.id, Long.MaxValue) + val select3 = SquerylCommandModel.selectCommands(notifier, Seq(cmd03.entityId), agent01.id, Long.MaxValue) + val block1 = SquerylCommandModel.blockCommands(notifier, Seq(cmd01.entityId), agent02.id) + val block2 = SquerylCommandModel.blockCommands(notifier, Seq(cmd02.entityId), agent02.id) + val block3 = SquerylCommandModel.blockCommands(notifier, Seq(cmd03.entityId), agent02.id) + + val lk1: (CommandLock.AccessMode, Set[UUID], UUID, Option[Long]) = (CommandLock.AccessMode.ALLOWED, Set(cmd01.entityId), agent01.id, Some(Long.MaxValue)) + val lk2: (CommandLock.AccessMode, Set[UUID], UUID, Option[Long]) = (CommandLock.AccessMode.ALLOWED, Set(cmd02.entityId), agent01.id, Some(Long.MaxValue)) + val lk3: (CommandLock.AccessMode, Set[UUID], UUID, Option[Long]) = (CommandLock.AccessMode.ALLOWED, Set(cmd03.entityId), agent01.id, Some(Long.MaxValue)) + val lk4: (CommandLock.AccessMode, Set[UUID], UUID, Option[Long]) = (CommandLock.AccessMode.BLOCKED, Set(cmd01.entityId), agent02.id, None) + val lk5: (CommandLock.AccessMode, Set[UUID], UUID, Option[Long]) = (CommandLock.AccessMode.BLOCKED, Set(cmd02.entityId), agent02.id, None) + val lk6: (CommandLock.AccessMode, Set[UUID], UUID, Option[Long]) = (CommandLock.AccessMode.BLOCKED, Set(cmd03.entityId), agent02.id, None) + + checkOrder(Seq(lk1, lk2)) { + SquerylCommandModel.lockQuery(Nil, Nil, None, None, 2) + } + checkOrder(Seq(lk3, lk4)) { + SquerylCommandModel.lockQuery(Nil, Nil, None, Some(protoIdToLong(select2.getId)), 2) + } + checkOrder(Seq(lk5, lk6)) { + SquerylCommandModel.lockQuery(Nil, Nil, None, Some(protoIdToLong(block1.getId)), 2) + } + } + +} diff --git a/services/src/test/scala/io/greenbus/services/model/EntityModelTest.scala b/services/src/test/scala/io/greenbus/services/model/EntityModelTest.scala new file mode 100644 index 0000000..916e6e9 --- /dev/null +++ b/services/src/test/scala/io/greenbus/services/model/EntityModelTest.scala @@ -0,0 +1,1397 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.model + +import java.util.UUID + +import io.greenbus.client.service.proto.Model.{ Entity, EntityEdge, EntityKeyValue, StoredValue } +import io.greenbus.services.authz.EntityFilter +import io.greenbus.services.data.{ EntityEdgeRow, EntityRow, EntityTypeRow, ServicesSchema } +import io.greenbus.services.framework._ +import io.greenbus.services.model.EntityModel._ +import io.greenbus.services.model.UUIDHelpers._ +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner +import org.squeryl.PrimitiveTypeMode._ +import org.squeryl.Query + +import scala.collection.JavaConversions._ + +object EntityModelTest { + import ModelTestHelpers._ + + def bannedTypeTest(banned: String) { + + val ent01 = createEntity("ent02", Seq("typeA")) + + val ent03 = createEntity("ent03", Seq("typeC", banned)) + + intercept[ModelInputException] { + SquerylEntityModel.putEntities(NullModelNotifier, Nil, Seq(("ent01", Set("typeB", banned)))) + } + + intercept[ModelInputException] { + SquerylEntityModel.putEntities(NullModelNotifier, Nil, Seq(("ent02", Set("typeA", banned)))) + } + intercept[ModelInputException] { + SquerylEntityModel.putEntities(NullModelNotifier, Seq((ent01.id, "ent02", Set("typeA", banned))), Nil) + } + + intercept[ModelInputException] { + SquerylEntityModel.deleteEntities(NullModelNotifier, Seq(ent03.id)) + } + } +} + +@RunWith(classOf[JUnitRunner]) +class EntityModelTest extends ServiceTestBase { + import ModelTestHelpers._ + + def checkEdges(correct: Set[(UUID, UUID, String, Int)])(query: => Seq[EntityEdge]) { + val simplified = query.map(simplify) + + def debugName(uuid: UUID): String = ServicesSchema.entities.where(t => t.id === uuid).single.name + + if (simplified.toSet != correct) { + println("Correct: " + correct.map(tup => (debugName(tup._1), debugName(tup._2), tup._3)).mkString("(\n\t", "\n\t", "\n)")) + println("Result: " + simplified.map(tup => (debugName(tup._1), debugName(tup._2), tup._3)).toSet.mkString("(\n\t", "\n\t", "\n)")) + } + simplified.toSet should equal(correct) + simplified.size should equal(correct.size) + } + + test("Key queries") { + val ent01 = createEntity("ent01", Seq("typeA", "typeB")) + val ent02 = createEntity("ent02", Seq("typeB", "typeC")) + val ent03 = createEntity("ent03", Seq("typeD")) + val ent04 = createEntity("ent04", Seq()) + + check(Set(("ent01", Set("typeA", "typeB")))) { + SquerylEntityModel.keyQuery(Nil, Seq("ent01")) + } + check(Set(("ent01", Set("typeA", "typeB")), ("ent04", Set()))) { + SquerylEntityModel.keyQuery(Nil, Seq("ent01", "ent04")) + } + + check(Set(("ent03", Set("typeD")))) { + SquerylEntityModel.keyQuery(Seq(ent03.id), Nil) + } + check(Set(("ent02", Set("typeB", "typeC")), ("ent03", Set("typeD")))) { + SquerylEntityModel.keyQuery(Seq(ent02.id, ent03.id), Nil) + } + + check(Set(("ent02", Set("typeB", "typeC")), ("ent03", Set("typeD")))) { + SquerylEntityModel.keyQuery(Seq(ent02.id), Seq("ent03")) + } + } + + test("Full queries") { + val ent01 = createEntity("ent01", Seq("typeA", "typeB")) + val ent02 = createEntity("ent02", Seq("typeB", "typeC")) + val ent03 = createEntity("ent03", Seq("typeD")) + val ent04 = createEntity("ent04", Seq()) + + val e1 = ("ent01", Set("typeA", "typeB")) + val e2 = ("ent02", Set("typeB", "typeC")) + val e3 = ("ent03", Set("typeD")) + val e4 = ("ent04", Set.empty[String]) + + val all = Set(e1, e2, e3, e4) + + check(all) { + SquerylEntityModel.fullQuery(TypeParams(Nil, Nil, Nil), None, None, 100, false, None) + } + + check(Set(e1, e2)) { + SquerylEntityModel.fullQuery(TypeParams(Nil, Seq("typeB"), Nil), None, None, 100, false, None) + } + check(Set(e2)) { + SquerylEntityModel.fullQuery(TypeParams(Nil, Seq("typeB", "typeC"), Nil), None, None, 100, false, None) + } + check(Set()) { + SquerylEntityModel.fullQuery(TypeParams(Nil, Seq("typeA", "typeC"), Nil), None, None, 100, false, None) + } + + check(Set(e3, e4)) { + SquerylEntityModel.fullQuery(TypeParams(Nil, Nil, Seq("typeB")), None, None, 100, false, None) + } + check(Set(e2, e4)) { + SquerylEntityModel.fullQuery(TypeParams(Nil, Nil, Seq("typeA", "typeD")), None, None, 100, false, None) + } + + check(Set(e2)) { + SquerylEntityModel.fullQuery(TypeParams(Nil, Seq("typeB"), Seq("typeA")), None, None, 100, false, None) + } + + check(Set(e1, e2)) { + SquerylEntityModel.fullQuery(TypeParams(Seq("typeB"), Nil, Nil), None, None, 100, false, None) + } + check(Set(e1, e3)) { + SquerylEntityModel.fullQuery(TypeParams(Seq("typeA", "typeD"), Nil, Nil), None, None, 100, false, None) + } + check(Set(e1)) { + SquerylEntityModel.fullQuery(TypeParams(Seq("typeB"), Nil, Seq("typeC")), None, None, 100, false, None) + } + check(Set(e2)) { + SquerylEntityModel.fullQuery(TypeParams(Seq("typeB"), Seq("typeC"), Nil), None, None, 100, false, None) + } + + } + + test("All paging") { + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA", "typeD")) + val ent03 = createEntity("ent03", Seq("typeA")) + val ent04 = createEntity("ent04", Seq("typeA", "typeD")) + val ent05 = createEntity("ent05", Seq("typeA")) + val ent06 = createEntity("ent06", Seq("typeA", "typeD")) + val ent07 = createEntity("ent07", Seq("typeA")) + + def uuidOf(ent: Entity): UUID = ent.getUuid + + val uuidFull = SquerylEntityModel.fullQuery(TypeParams(Nil, Nil, Nil), None, None, 100, false).map(uuidOf) + val nameFull = SquerylEntityModel.fullQuery(TypeParams(Nil, Nil, Nil), None, None, 100, true).map(uuidOf) + + { + val page1 = SquerylEntityModel.fullQuery(TypeParams(Nil, Nil, Nil), None, None, 2, false) + val page2 = SquerylEntityModel.fullQuery(TypeParams(Nil, Nil, Nil), Some(page1.last.getUuid), None, 3, false) + val page3 = SquerylEntityModel.fullQuery(TypeParams(Nil, Nil, Nil), Some(page2.last.getUuid), None, 2, false) + + (page1 ++ page2 ++ page3).map(uuidOf) should equal(uuidFull) + } + { + val page1 = SquerylEntityModel.fullQuery(TypeParams(Nil, Nil, Nil), None, None, 2, false) + val page2 = SquerylEntityModel.fullQuery(TypeParams(Nil, Nil, Nil), None, Some(page1.last.getName), 3, false) + val page3 = SquerylEntityModel.fullQuery(TypeParams(Nil, Nil, Nil), None, Some(page2.last.getName), 2, false) + + (page1 ++ page2 ++ page3).map(uuidOf) should equal(uuidFull) + } + + { + val page1 = SquerylEntityModel.fullQuery(TypeParams(Nil, Nil, Nil), None, None, 2, true) + val page2 = SquerylEntityModel.fullQuery(TypeParams(Nil, Nil, Nil), Some(page1.last.getUuid), None, 3, true) + val page3 = SquerylEntityModel.fullQuery(TypeParams(Nil, Nil, Nil), Some(page2.last.getUuid), None, 2, true) + + (page1 ++ page2 ++ page3).map(uuidOf) should equal(nameFull) + } + { + val page1 = SquerylEntityModel.fullQuery(TypeParams(Nil, Nil, Nil), None, None, 2, true) + val page2 = SquerylEntityModel.fullQuery(TypeParams(Nil, Nil, Nil), None, Some(page1.last.getName), 3, true) + val page3 = SquerylEntityModel.fullQuery(TypeParams(Nil, Nil, Nil), None, Some(page2.last.getName), 2, true) + + (page1 ++ page2 ++ page3).map(uuidOf) should equal(nameFull) + } + + } + + test("Types paging") { + val ent01 = createEntity("ent01", Seq("typeA", "typeD")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + val ent04 = createEntity("ent04", Seq("typeA", "typeD")) + val ent05 = createEntity("ent05", Seq("typeA")) + val ent06 = createEntity("ent06", Seq("typeA")) + val ent07 = createEntity("ent07", Seq("typeA", "typeD")) + val other01 = createEntity("other01", Seq("typeB")) + val other02 = createEntity("other02", Seq("typeB")) + + def uuidOf(ent: Entity): UUID = ent.getUuid + + val full = SquerylEntityModel.fullQuery(TypeParams(Seq("typeA"), Nil, Nil), None, None, 100).map(uuidOf) + + val page1 = SquerylEntityModel.fullQuery(TypeParams(Seq("typeA"), Nil, Nil), None, None, 2).map(uuidOf) + val page2 = SquerylEntityModel.fullQuery(TypeParams(Seq("typeA"), Nil, Nil), Some(page1.last), None, 3).map(uuidOf) + val page3 = SquerylEntityModel.fullQuery(TypeParams(Seq("typeA"), Nil, Nil), Some(page2.last), None, 2).map(uuidOf) + + (page1 ++ page2 ++ page3) should equal(full) + + } + + test("Put one") { + val notifier = new TestModelNotifier + + check(Set(("ent01", Set("typeA")))) { + SquerylEntityModel.putEntities(notifier, Nil, Seq(("ent01", Set("typeA")))) + } + + val entRows = ServicesSchema.entities.where(t => true === true).toSeq + entRows.size should equal(1) + entRows.map(_.name).toSet should equal(Set("ent01")) + val uuid = entRows.head.id + + val typeJoins = ServicesSchema.entityTypes.where(t => true === true) + typeJoins.size should equal(1) + typeJoins.toSet should equal(Set(EntityTypeRow(uuid, "typeA"))) + + val notes = notifier.getQueue(classOf[Entity]).toSeq.map(tup => (tup._1, simplify(tup._2))) + notes.size should equal(1) + notes.toSet should equal(Set((Created, ("ent01", Set("typeA"))))) + } + + test("Put one with multiple types") { + val notifier = new TestModelNotifier + + check(Set(("ent01", Set("typeA", "typeB")))) { + SquerylEntityModel.putEntities(notifier, Nil, Seq(("ent01", Set("typeA", "typeB")))) + } + + val entRows = ServicesSchema.entities.where(t => true === true).toSeq + entRows.size should equal(1) + entRows.map(_.name).toSet should equal(Set("ent01")) + val uuid = entRows.head.id + + val typeJoins = ServicesSchema.entityTypes.where(t => true === true) + typeJoins.size should equal(2) + typeJoins.toSet should equal(Set(EntityTypeRow(uuid, "typeA"), EntityTypeRow(uuid, "typeB"))) + + val notes = notifier.getQueue(classOf[Entity]).toSeq.map(tup => (tup._1, simplify(tup._2))) + notes.size should equal(1) + notes.toSet should equal(Set((Created, ("ent01", Set("typeA", "typeB"))))) + } + + test("Put multiple") { + val notifier = new TestModelNotifier + + check(Set(("ent01", Set("typeA", "typeB")), ("ent02", Set("typeB", "typeC")))) { + SquerylEntityModel.putEntities(notifier, Nil, Seq(("ent01", Set("typeA", "typeB")), ("ent02", Set("typeB", "typeC")))) + } + + val entRows = ServicesSchema.entities.where(t => true === true).toSeq + entRows.size should equal(2) + entRows.map(_.name).toSet should equal(Set("ent01", "ent02")) + val entId01 = entRows.find(_.name == "ent01").get.id + val entId02 = entRows.find(_.name == "ent02").get.id + + val typeJoins = ServicesSchema.entityTypes.where(t => true === true) + typeJoins.size should equal(4) + typeJoins.toSet should equal( + Set(EntityTypeRow(entId01, "typeA"), EntityTypeRow(entId01, "typeB"), + EntityTypeRow(entId02, "typeB"), EntityTypeRow(entId02, "typeC"))) + + val notifications = Set( + (Created, ("ent01", Set("typeA", "typeB"))), + (Created, ("ent02", Set("typeB", "typeC")))) + + val notes = notifier.getQueue(classOf[Entity]).toSeq.map(tup => (tup._1, simplify(tup._2))) + notes.size should equal(2) + notes.toSet should equal(notifications) + } + + test("Put multiple, add and delete types") { + val notifier = new TestModelNotifier + + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA", "typeB")) + val ent03 = createEntity("ent03", Seq("typeB")) + + val resulting = Set( + ("ent01", Set("typeA", "typeB")), + ("ent02", Set("typeB")), + ("ent03", Set("typeA")), + ("ent04", Set("typeC"))) + + val puts = Seq( + ("ent01", Set("typeA", "typeB")), + ("ent02", Set("typeB")), + ("ent03", Set("typeA")), + ("ent04", Set("typeC"))) + + check(resulting) { + SquerylEntityModel.putEntities(notifier, Nil, puts) + } + + val names = Set("ent01", "ent02", "ent03", "ent04") + val entRows = ServicesSchema.entities.where(t => true === true).toSeq + entRows.size should equal(4) + entRows.map(_.name).toSet should equal(names) + + val nameToId = entRows.map(row => (row.name, row.id)).toMap + + nameToId("ent01") should equal(ent01.id) + nameToId("ent02") should equal(ent02.id) + nameToId("ent03") should equal(ent03.id) + + val typeRows = Set( + EntityTypeRow(nameToId("ent01"), "typeA"), + EntityTypeRow(nameToId("ent01"), "typeB"), + EntityTypeRow(nameToId("ent02"), "typeB"), + EntityTypeRow(nameToId("ent03"), "typeA"), + EntityTypeRow(nameToId("ent04"), "typeC")) + + val typeJoins = ServicesSchema.entityTypes.where(t => true === true).toSeq + typeJoins.size should equal(typeRows.size) + typeJoins.toSet should equal(typeRows) + + val notifications = Set( + (Updated, ("ent01", Set("typeA", "typeB"))), + (Updated, ("ent02", Set("typeB"))), + (Updated, ("ent03", Set("typeA"))), + (Created, ("ent04", Set("typeC")))) + + val notes = notifier.getQueue(classOf[Entity]).toSeq.map(tup => (tup._1, simplify(tup._2))) + notes.size should equal(4) + notes.toSet should equal(notifications) + } + + test("Put one with predefined id") { + val notifier = new TestModelNotifier + + val id = UUID.randomUUID() + + check(Set(("ent01", Set("typeA")))) { + SquerylEntityModel.putEntities(notifier, Seq((id, "ent01", Set("typeA"))), Nil) + } + + val entRows = ServicesSchema.entities.where(t => true === true).toSeq + entRows.size should equal(1) + entRows.map(_.name).toSet should equal(Set("ent01")) + val uuid = entRows.head.id + + uuid should equal(id) + + val typeJoins = ServicesSchema.entityTypes.where(t => true === true) + typeJoins.size should equal(1) + typeJoins.toSet should equal(Set(EntityTypeRow(uuid, "typeA"))) + + val notes = notifier.getQueue(classOf[Entity]).toSeq.map(tup => (tup._1, simplify(tup._2))) + notes.size should equal(1) + notes.toSet should equal(Set((Created, ("ent01", Set("typeA"))))) + } + + test("Put, rename, add and remove types") { + val notifier = new TestModelNotifier + + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA", "typeB")) + val ent03 = createEntity("ent03", Seq("typeB")) + + val ent04id = UUID.randomUUID() + + val resulting = Set( + ("ent07", Set("typeA", "typeB")), + ("ent02", Set("typeB")), + ("ent03", Set("typeA")), + ("ent04", Set("typeC"))) + + val puts = Seq( + (ent01.id, "ent07", Set("typeA", "typeB")), + (ent02.id, "ent02", Set("typeB")), + (ent03.id, "ent03", Set("typeA")), + (ent04id, "ent04", Set("typeC"))) + + check(resulting) { + SquerylEntityModel.putEntities(notifier, puts, Nil) + } + + val names = Set("ent07", "ent02", "ent03", "ent04") + val entRows = ServicesSchema.entities.where(t => true === true).toSeq + entRows.size should equal(4) + entRows.map(_.name).toSet should equal(names) + + val nameToId = entRows.map(row => (row.name, row.id)).toMap + + nameToId("ent07") should equal(ent01.id) + nameToId("ent02") should equal(ent02.id) + nameToId("ent03") should equal(ent03.id) + nameToId("ent04") should equal(ent04id) + + val typeRows = Set( + EntityTypeRow(nameToId("ent07"), "typeA"), + EntityTypeRow(nameToId("ent07"), "typeB"), + EntityTypeRow(nameToId("ent02"), "typeB"), + EntityTypeRow(nameToId("ent03"), "typeA"), + EntityTypeRow(nameToId("ent04"), "typeC")) + + val typeJoins = ServicesSchema.entityTypes.where(t => true === true).toSeq + typeJoins.size should equal(typeRows.size) + typeJoins.toSet should equal(typeRows) + + val notifications = Set( + (Updated, (ent01.id, "ent07", Set("typeA", "typeB"))), + (Updated, (ent02.id, "ent02", Set("typeB"))), + (Updated, (ent03.id, "ent03", Set("typeA"))), + (Created, (ent04id, "ent04", Set("typeC")))) + + val notes = notifier.getQueue(classOf[Entity]).toSeq.map(tup => (tup._1, (protoUUIDToUuid(tup._2.getUuid), tup._2.getName, tup._2.getTypesList.toSet))) + notes.size should equal(4) + notes.toSet should equal(notifications) + } + + test("Put filtering") { + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA", "typeB")) + + val filter = new EntityFilter { + protected def filterQuery: Query[EntityRow] = { + from(ServicesSchema.entities)(ent => where(ent.name === "ent01") select (ent)) + } + } + + intercept[ModelInputException] { + SquerylEntityModel.putEntities(NullModelNotifier, Nil, Seq(("ent03", Set("typeC"))), false, Some(filter)) + } + intercept[ModelInputException] { + SquerylEntityModel.putEntities(NullModelNotifier, Nil, Seq(("ent01", Set("typeC")), ("ent03", Set("typeC"))), false, Some(filter)) + } + intercept[ModelInputException] { + SquerylEntityModel.putEntities(NullModelNotifier, Nil, Seq(("ent02", Set("typeC"))), true, Some(filter)) + } + intercept[ModelInputException] { + SquerylEntityModel.putEntities(NullModelNotifier, Nil, Seq(("ent01", Set("typeC")), ("ent02", Set("typeC"))), true, Some(filter)) + } + + check(Set(("ent01", Set("typeC")))) { + SquerylEntityModel.putEntities(NullModelNotifier, Nil, Seq(("ent01", Set("typeC"))), true, Some(filter)) + } + } + + test("Delete entity") { + val notifier = new TestModelNotifier + + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA", "typeB")) + val ent04 = createEntity("ent04", Seq("typeA")) + val ent05 = createEntity("ent05", Seq("typeA")) + val edge1To2 = createEdge(ent01.id, ent02.id, "relatesTo", 1) + createEdge(ent01.id, ent03.id, "relatesTo", 2) + createEdge(ent01.id, ent04.id, "relatesTo", 3) + createEdge(ent01.id, ent05.id, "relatesTo", 4) + createEdge(ent02.id, ent03.id, "relatesTo", 1) + createEdge(ent02.id, ent04.id, "relatesTo", 2) + createEdge(ent02.id, ent05.id, "relatesTo", 3) + createEdge(ent03.id, ent04.id, "relatesTo", 1) + createEdge(ent03.id, ent05.id, "relatesTo", 2) + val edge4To5 = createEdge(ent04.id, ent05.id, "relatesTo", 1) + createEdge(ent01.id, ent03.id, "another", 1) + createEdge(ent03.id, ent05.id, "extraneous", 1) + + val kv01 = createEntityKeyValue(ent03.id, "keyA", strToStored("value01").toByteArray) + val kv02 = createEntityKeyValue(ent05.id, "keyA", strToStored("value02").toByteArray) + + val result = Set((ent03.name, Set("typeA", "typeB"))) + + check(result) { + SquerylEntityModel.deleteEntities(notifier, Seq(ent03.id)) + } + + val allEntities = ServicesSchema.entities.where(t => true === true).map(_.name) + allEntities.size should equal(4) + allEntities.toSet should equal(Set("ent01", "ent02", "ent04", "ent05")) + + ServicesSchema.entityTypes.where(t => t.entityId === ent03.id).size should equal(0) + + val allEdges = ServicesSchema.edges.where(t => true === true).toSeq + allEdges.size should equal(2) + allEdges.map(_.id).toSet should equal(Set(edge1To2.id, edge4To5.id)) + + val notifications = Set( + (Deleted, ("ent03", Set("typeA", "typeB")))) + + val notes = notifier.getQueue(classOf[Entity]).toSeq.map(tup => (tup._1, simplify(tup._2))) + notes.size should equal(1) + notes.toSet should equal(notifications) + + notifier.checkEntityEdges(Set( + (Deleted, (ent01.id, ent03.id, "relatesTo", 2)), + (Deleted, (ent01.id, ent04.id, "relatesTo", 3)), + (Deleted, (ent01.id, ent05.id, "relatesTo", 4)), + (Deleted, (ent02.id, ent03.id, "relatesTo", 1)), + (Deleted, (ent02.id, ent04.id, "relatesTo", 2)), + (Deleted, (ent02.id, ent05.id, "relatesTo", 3)), + (Deleted, (ent03.id, ent04.id, "relatesTo", 1)), + (Deleted, (ent03.id, ent05.id, "relatesTo", 2)), + (Deleted, (ent01.id, ent03.id, "another", 1)), + (Deleted, (ent03.id, ent05.id, "extraneous", 1)))) + + val v1 = (ent03.id, "keyA", "value01") + + notifier.checkEntityKeyValues(Set((Deleted, v1))) + } + + /* + 1 2 + 3 4 + */ + + test("Delete entity, only correct edges") { + val notifier = new TestModelNotifier + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + val ent04 = createEntity("ent04", Seq("typeA")) + createEdge(ent01.id, ent03.id, "relatesTo", 1) + createEdge(ent02.id, ent04.id, "relatesTo", 1) + val edge2To3 = createEdge(ent02.id, ent03.id, "relatesTo", 1) + + val result = Set(("ent01", Set("typeA")), ("ent04", Set("typeA"))) + + check(result) { + SquerylEntityModel.deleteEntities(notifier, Seq(ent01.id, ent04.id)) + } + + val allEntities = ServicesSchema.entities.where(t => true === true).map(_.name) + allEntities.size should equal(2) + allEntities.toSet should equal(Set("ent02", "ent03")) + + val allEdges = ServicesSchema.edges.where(t => true === true).toSeq + allEdges.size should equal(1) + allEdges.map(_.id).toSet should equal(Set(edge2To3.id)) + + notifier.checkEntities(Set( + (Deleted, ("ent01", Set("typeA"))), + (Deleted, ("ent04", Set("typeA"))))) + + notifier.checkEntityEdges(Set( + (Deleted, (ent01.id, ent03.id, "relatesTo", 1)), + (Deleted, (ent02.id, ent04.id, "relatesTo", 1)))) + } + + test("Put edge, simple") { + val notifier = new TestModelNotifier + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + + val resultEdges = Set( + (ent01.id, ent02.id, "relatesTo", 1), + (ent01.id, ent03.id, "relatesTo", 1)) + + checkEdges(resultEdges) { + SquerylEntityModel.putEdges(notifier, Seq((ent01.id, ent02.id), (ent01.id, ent03.id)), "relatesTo") + } + + val allEdges = ServicesSchema.edges.where(t => true === true).toSeq + + allEdges.size should equal(2) + allEdges.map(row => (row.parentId, row.childId, row.relationship, row.distance)).toSet should equal(resultEdges) + + notifier.checkEntityEdges(resultEdges.map(e => (Created, e))) + } + + test("Put edge, simple, with existing") { + val notifier = new TestModelNotifier + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + val edge01 = createEdge(ent01.id, ent03.id, "relatesTo", 1) + + val resultEdges = Set( + (ent01.id, ent02.id, "relatesTo", 1), + (ent01.id, ent03.id, "relatesTo", 1)) + + checkEdges(resultEdges) { + SquerylEntityModel.putEdges(notifier, Seq((ent01.id, ent02.id), (ent01.id, ent03.id)), "relatesTo") + } + + val allEdges = ServicesSchema.edges.where(t => true === true).toSeq + allEdges.size should equal(2) + val toMatch = allEdges.map(row => (row.parentId, row.childId, row.relationship, row.distance)).toSet + toMatch should equal(resultEdges) + + notifier.checkEntityEdges(Set( + (Created, (ent01.id, ent02.id, "relatesTo", 1)))) + } + + test("Put edge, simple, not confused by different edge type") { + val notifier = new TestModelNotifier + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + val edge01 = createEdge(ent01.id, ent03.id, "totallyUnrelated", 1) + + val resultEdges = Set( + (ent01.id, ent02.id, "relatesTo", 1), + (ent01.id, ent03.id, "relatesTo", 1)) + + checkEdges(resultEdges) { + SquerylEntityModel.putEdges(notifier, Seq((ent01.id, ent02.id), (ent01.id, ent03.id)), "relatesTo") + } + + val allEdges = ServicesSchema.edges.where(t => t.relationship === "relatesTo").toSeq + allEdges.size should equal(resultEdges.size) + val toMatch = allEdges.map(row => (row.parentId, row.childId, row.relationship, row.distance)).toSet + toMatch should equal(resultEdges) + + notifier.checkEntityEdges(resultEdges.map(e => (Created, e))) + } + + test("Put edges with derived edges") { + val notifier = new TestModelNotifier + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + val ent04 = createEntity("ent04", Seq("typeA")) + val edge01To02 = createEdge(ent01.id, ent02.id, "relatesTo", 1) + val edge03To04 = createEdge(ent03.id, ent04.id, "relatesTo", 1) + + val resultEdges = Set( + (ent02.id, ent03.id, "relatesTo", 1), + (ent02.id, ent04.id, "relatesTo", 2), + (ent01.id, ent03.id, "relatesTo", 2), + (ent01.id, ent04.id, "relatesTo", 3)) + + checkEdges(resultEdges) { + SquerylEntityModel.putEdges(notifier, Seq((ent02.id, ent03.id)), "relatesTo") + } + + val allEdges = ServicesSchema.edges.where(t => true === true).toSeq + allEdges.size should equal(6) + val toMatch = allEdges + .filterNot(a => Seq(edge01To02, edge03To04).exists(_ == a)) + .map(row => (row.parentId, row.childId, row.relationship, row.distance)) + .toSet + + toMatch should equal(resultEdges) + + notifier.checkEntityEdges(resultEdges.map(e => (Created, e))) + } + + test("Put edge, batch derived") { + val notifier = new TestModelNotifier + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + + val resultEdges = Set( + (ent01.id, ent02.id, "relatesTo", 1), + (ent02.id, ent03.id, "relatesTo", 1), + (ent01.id, ent03.id, "relatesTo", 2)) + + checkEdges(resultEdges) { + SquerylEntityModel.putEdges(notifier, Seq((ent01.id, ent02.id), (ent02.id, ent03.id)), "relatesTo") + } + + val allEdges = ServicesSchema.edges.where(t => true === true).toSeq + + allEdges.size should equal(resultEdges.size) + allEdges.map(row => (row.parentId, row.childId, row.relationship, row.distance)).toSet should equal(resultEdges) + + notifier.checkEntityEdges(resultEdges.map(e => (Created, e))) + } + + test("Put edge, batch derived extended") { + /* 1 - 2 [] 3 [] 4 - 5 - 6 */ + val notifier = new TestModelNotifier + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + val ent04 = createEntity("ent04", Seq("typeA")) + val ent05 = createEntity("ent05", Seq("typeA")) + val ent06 = createEntity("ent06", Seq("typeA")) + + val extant = List( + (ent01.id, ent02.id, "relatesTo", 1), + (ent04.id, ent05.id, "relatesTo", 1), + (ent04.id, ent06.id, "relatesTo", 2), + (ent05.id, ent06.id, "relatesTo", 1)) + + extant.foreach { case (parent, child, relation, int) => createEdge(parent, child, relation, int) } + + val allTups = Set( + (ent01.id, ent02.id, "relatesTo", 1), + (ent01.id, ent03.id, "relatesTo", 2), + (ent01.id, ent04.id, "relatesTo", 3), + (ent01.id, ent05.id, "relatesTo", 4), + (ent01.id, ent06.id, "relatesTo", 5), + (ent02.id, ent03.id, "relatesTo", 1), + (ent02.id, ent04.id, "relatesTo", 2), + (ent02.id, ent05.id, "relatesTo", 3), + (ent02.id, ent06.id, "relatesTo", 4), + (ent03.id, ent04.id, "relatesTo", 1), + (ent03.id, ent05.id, "relatesTo", 2), + (ent03.id, ent06.id, "relatesTo", 3), + (ent04.id, ent05.id, "relatesTo", 1), + (ent04.id, ent06.id, "relatesTo", 2), + (ent05.id, ent06.id, "relatesTo", 1)) + + val resultEdges = allTups &~ extant.toSet + + checkEdges(resultEdges) { + SquerylEntityModel.putEdges(notifier, Seq((ent02.id, ent03.id), (ent03.id, ent04.id)), "relatesTo") + } + + val allEdges = ServicesSchema.edges.where(t => true === true).toSeq + + allEdges.size should equal(allTups.size) + allEdges.map(row => (row.parentId, row.childId, row.relationship, row.distance)).toSet should equal(allTups) + + notifier.checkEntityEdges(resultEdges.map(e => (Created, e))) + } + + test("Put edge, batch derived two holes") { + /* 1 - 2 [] 3 - 4 [] 5 - 6 */ + val notifier = new TestModelNotifier + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + val ent04 = createEntity("ent04", Seq("typeA")) + val ent05 = createEntity("ent05", Seq("typeA")) + val ent06 = createEntity("ent06", Seq("typeA")) + + val extant = List( + (ent01.id, ent02.id, "relatesTo", 1), + (ent03.id, ent04.id, "relatesTo", 1), + (ent05.id, ent06.id, "relatesTo", 1)) + + extant.foreach { case (parent, child, relation, int) => createEdge(parent, child, relation, int) } + + val allTups = Set( + (ent01.id, ent02.id, "relatesTo", 1), + (ent01.id, ent03.id, "relatesTo", 2), + (ent01.id, ent04.id, "relatesTo", 3), + (ent01.id, ent05.id, "relatesTo", 4), + (ent01.id, ent06.id, "relatesTo", 5), + (ent02.id, ent03.id, "relatesTo", 1), + (ent02.id, ent04.id, "relatesTo", 2), + (ent02.id, ent05.id, "relatesTo", 3), + (ent02.id, ent06.id, "relatesTo", 4), + (ent03.id, ent04.id, "relatesTo", 1), + (ent03.id, ent05.id, "relatesTo", 2), + (ent03.id, ent06.id, "relatesTo", 3), + (ent04.id, ent05.id, "relatesTo", 1), + (ent04.id, ent06.id, "relatesTo", 2), + (ent05.id, ent06.id, "relatesTo", 1)) + + val resultEdges = allTups &~ extant.toSet + + checkEdges(resultEdges) { + SquerylEntityModel.putEdges(notifier, Seq((ent02.id, ent03.id), (ent04.id, ent05.id)), "relatesTo") + } + + val allEdges = ServicesSchema.edges.where(t => true === true).toSeq + + allEdges.size should equal(allTups.size) + allEdges.map(row => (row.parentId, row.childId, row.relationship, row.distance)).toSet should equal(allTups) + + notifier.checkEntityEdges(resultEdges.map(e => (Created, e))) + } + + /* + 1 + 2 3 + 4 + */ + test("Diamond, from above") { + val notifier = new TestModelNotifier + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + val ent04 = createEntity("ent04", Seq("typeA")) + createEdge(ent01.id, ent02.id, "relatesTo", 1) + createEdge(ent01.id, ent03.id, "relatesTo", 1) + createEdge(ent02.id, ent04.id, "relatesTo", 1) + createEdge(ent01.id, ent04.id, "relatesTo", 2) + + intercept[DiamondException] { + SquerylEntityModel.putEdges(notifier, Seq((ent03.id, ent04.id)), "relatesTo") + } + } + test("Diamond, from below") { + val notifier = new TestModelNotifier + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + val ent04 = createEntity("ent04", Seq("typeA")) + createEdge(ent01.id, ent03.id, "relatesTo", 1) + createEdge(ent02.id, ent04.id, "relatesTo", 1) + createEdge(ent03.id, ent04.id, "relatesTo", 1) + createEdge(ent01.id, ent04.id, "relatesTo", 2) + + intercept[DiamondException] { + SquerylEntityModel.putEdges(notifier, Seq((ent01.id, ent02.id)), "relatesTo") + } + } + test("Diamond, from above, different relation ok") { + val notifier = new TestModelNotifier + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + val ent04 = createEntity("ent04", Seq("typeA")) + createEdge(ent01.id, ent02.id, "relatesTo", 1) + createEdge(ent01.id, ent03.id, "relatesTo", 1) + createEdge(ent02.id, ent04.id, "relatesTo", 1) + createEdge(ent01.id, ent04.id, "relatesTo", 2) + + val results = SquerylEntityModel.putEdges(notifier, Seq((ent03.id, ent04.id)), "totallyUnrelated") + results.toSeq.size > 0 should equal(true) + } + + test("Delete edges") { + val notifier = new TestModelNotifier + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + val ent04 = createEntity("ent04", Seq("typeA")) + val edge01To02 = createEdge(ent01.id, ent02.id, "relatesTo", 1) + val edge01To03 = createEdge(ent01.id, ent03.id, "relatesTo", 1) + val edge01To04 = createEdge(ent01.id, ent04.id, "relatesTo", 1) + + val resultEdges = Set( + (ent01.id, ent02.id, "relatesTo", 1), + (ent01.id, ent03.id, "relatesTo", 1)) + + checkEdges(resultEdges) { + SquerylEntityModel.deleteEdges(notifier, Seq((ent01.id, ent02.id), (ent01.id, ent03.id)), "relatesTo") + } + + val allEdges = ServicesSchema.edges.where(t => true === true).toSeq + allEdges.size should equal(1) + allEdges.head.id should equal(edge01To04.id) + + notifier.checkEntityEdges(resultEdges.map(e => (Deleted, e))) + } + + /* + 5 1 2 + 3 4 6 + */ + test("Relation flat query") { + val ent01 = createEntity("ent01", Seq("typeA", "top")) + val ent02 = createEntity("ent02", Seq("typeA", "top")) + val ent03 = createEntity("ent03", Seq("typeA")) + val ent04 = createEntity("ent04", Seq("typeA", "special")) + val ent05 = createEntity("ent05", Seq("typeA")) + val ent06 = createEntity("ent06", Seq("typeA")) + createEdge(ent01.id, ent03.id, "relatesTo", 1) + createEdge(ent01.id, ent04.id, "relatesTo", 1) + createEdge(ent02.id, ent04.id, "relatesTo", 1) + createEdge(ent02.id, ent06.id, "unrelated", 1) + + val results = Set( + ("ent03", Set("typeA")), + ("ent04", Set("typeA", "special"))) + + check(results) { + SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id, ent02.id), "relatesTo", descendantOf = true, Seq(), None, None, None, 100, false, filter = None) + } + check(results) { + SquerylEntityModel.namesRelationFlatQuery(Seq("ent01", "ent02"), "relatesTo", descendantOf = true, Seq(), None, None, None, 100, false, filter = None) + } + check(results) { + SquerylEntityModel.typesRelationFlatQuery(Seq("top"), "relatesTo", descendantOf = true, Seq(), None, None, None, 100, false, filter = None) + } + + check(Set(("ent04", Set("typeA", "special")))) { + SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id, ent02.id), "relatesTo", descendantOf = true, Seq("special"), None, None, None, 100, false, filter = None) + } + + val reverse = Set( + ("ent01", Set("typeA", "top")), + ("ent02", Set("typeA", "top"))) + + check(reverse) { + SquerylEntityModel.idsRelationFlatQuery(Seq(ent03.id, ent04.id), "relatesTo", descendantOf = false, Seq(), None, None, None, 100, false, filter = None) + } + } + + test("Relation flat query, depth limit") { + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA", "typeB")) + createEdge(ent01.id, ent02.id, "relatesTo", 1) + createEdge(ent01.id, ent03.id, "relatesTo", 2) + createEdge(ent02.id, ent03.id, "relatesTo", 1) + + val m1 = ("ent02", Set("typeA")) + val m2 = ("ent03", Set("typeA", "typeB")) + + val all = Set(m1, m2) + + val results = Set(m1) + + check(all) { + SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq(), Some(100), None, None, 100, false, filter = None) + } + check(all) { + SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq(), None, None, None, 100, false, filter = None) + } + check(all) { + SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq("typeA"), Some(2), None, None, 100, false, filter = None) + } + check(all) { + SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq(), Some(Int.MaxValue), None, None, 100, false, filter = None) + } + + check(Set(m2)) { + SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq("typeB"), None, None, None, 100, false, filter = None) + } + check(Set(m2)) { + SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq("typeB"), Some(2), None, None, 100, false, filter = None) + } + + check(results) { + SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq(), Some(1), None, None, 100, false, filter = None) + } + + check(Set()) { + SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq("typeB"), Some(1), None, None, 100, false, filter = None) + } + check(Set()) { + SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq("typeC"), None, None, None, 100, false, filter = None) + } + } + + test("Relation flat query, paging") { + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + val ent04 = createEntity("ent04", Seq("typeA")) + createEdge(ent01.id, ent02.id, "relatesTo", 1) + createEdge(ent01.id, ent03.id, "relatesTo", 1) + createEdge(ent01.id, ent04.id, "relatesTo", 1) + + { + val first = SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq(), None, None, None, 1, false, filter = None) + val second = SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq(), None, Some(first.head.getUuid), None, 1, false, filter = None) + val third = SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq(), None, Some(second.head.getUuid), None, 1, false, filter = None) + + val all = (first ++ second ++ third).map(_.getName) + all.size should equal(3) + all.toSet should equal(Set("ent02", "ent03", "ent04")) + } + + { + val first = SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq(), None, None, None, 1, false, filter = None) + val second = SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq(), None, None, Some(first.last.getName), 1, false, filter = None) + val third = SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq(), None, None, Some(second.last.getName), 1, false, filter = None) + + val all = (first ++ second ++ third).map(_.getName) + all.size should equal(3) + all.toSet should equal(Set("ent02", "ent03", "ent04")) + } + + { + val first = SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq(), None, None, None, 1, true, filter = None) + val second = SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq(), None, Some(first.head.getUuid), None, 1, true, filter = None) + val third = SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq(), None, Some(second.head.getUuid), None, 1, true, filter = None) + + val all = (first ++ second ++ third).map(_.getName) + all should equal(Seq("ent02", "ent03", "ent04")) + } + + { + val first = SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq(), None, None, None, 1, true, filter = None) + val second = SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq(), None, None, Some(first.last.getName), 1, true, filter = None) + val third = SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id), "relatesTo", descendantOf = true, Seq(), None, None, Some(second.last.getName), 1, true, filter = None) + + val all = (first ++ second ++ third).map(_.getName) + all should equal(Seq("ent02", "ent03", "ent04")) + } + } + + /* + 1x 2 + 5 3x 4 + */ + test("Relation flat query filtering") { + val ent01 = createEntity("ent01", Seq("top", "banned")) + val ent02 = createEntity("ent02", Seq("top")) + val ent03 = createEntity("ent03", Seq("banned")) + val ent04 = createEntity("ent04", Seq("typeA")) + val ent05 = createEntity("ent05", Seq("typeA")) + createEdge(ent01.id, ent03.id, "relatesTo", 1) + createEdge(ent01.id, ent05.id, "relatesTo", 1) + createEdge(ent02.id, ent03.id, "relatesTo", 1) + createEdge(ent02.id, ent04.id, "relatesTo", 1) + + val entFilter = new EntityFilter { + protected def filterQuery: Query[EntityRow] = { + from(ServicesSchema.entities)(ent => + where(notExists( + from(ServicesSchema.entityTypes)(t => + where(t.entType === "banned" and t.entityId === ent.id) + select (t)))) + select (ent)) + } + } + + val results = Set( + ("ent04", Set("typeA"))) + + check(results) { + SquerylEntityModel.idsRelationFlatQuery(Seq(ent01.id, ent02.id), "relatesTo", descendantOf = true, Seq(), None, None, None, 100, false, Some(entFilter)) + } + check(results) { + SquerylEntityModel.namesRelationFlatQuery(Seq("ent01", "ent02"), "relatesTo", descendantOf = true, Seq(), None, None, None, 100, false, Some(entFilter)) + } + check(results) { + SquerylEntityModel.typesRelationFlatQuery(Seq("top"), "relatesTo", descendantOf = true, Seq(), None, None, None, 100, false, Some(entFilter)) + } + } + + test("Edge query") { + val ent01 = createEntity("ent01", Seq("typeA", "top")) + val ent02 = createEntity("ent02", Seq("typeA", "banned")) + val ent03 = createEntity("ent03", Seq("typeA")) + val ent04 = createEntity("ent04", Seq("typeA", "banned")) + val ent05 = createEntity("ent05", Seq("typeA")) + val ent06 = createEntity("ent06", Seq("typeA")) + + val eg1 = (ent01.id, ent03.id, "relatesTo", 1) + val eg2 = (ent01.id, ent04.id, "relatesTo", 1) + val eg3 = (ent02.id, ent04.id, "relatesTo", 1) + val eg4 = (ent02.id, ent06.id, "unrelated", 1) + val eg5 = (ent03.id, ent05.id, "rules", 1) + val eg6 = (ent05.id, ent06.id, "rules", 1) + val eg7 = (ent03.id, ent06.id, "rules", 2) + + val all = Set(eg1, eg2, eg3, eg4, eg5, eg6, eg7) + + all.foreach(eg => createEdge(eg._1, eg._2, eg._3, eg._4)) + + checkEdges(all) { + SquerylEntityModel.edgeQuery(Nil, Nil, Nil, None, None, 100, None) + } + checkEdges(Set(eg3)) { + SquerylEntityModel.edgeQuery(Seq(ent02.id), Seq("relatesTo"), Nil, None, None, 100, None) + } + checkEdges(Set(eg3, eg4)) { + SquerylEntityModel.edgeQuery(Seq(ent02.id), Seq("relatesTo", "unrelated"), Nil, None, None, 100, None) + } + checkEdges(Set(eg1, eg2, eg3)) { + SquerylEntityModel.edgeQuery(Seq(ent01.id, ent02.id), Seq("relatesTo"), Nil, None, None, 100, None) + } + checkEdges(Set(eg1)) { + SquerylEntityModel.edgeQuery(Seq(ent01.id, ent02.id), Seq("relatesTo"), Seq(ent03.id), None, None, 100, None) + } + checkEdges(Set(eg1)) { + SquerylEntityModel.edgeQuery(Nil, Seq("relatesTo"), Seq(ent03.id), None, None, 100, None) + } + checkEdges(Set(eg1)) { + SquerylEntityModel.edgeQuery(Nil, Nil, Seq(ent03.id), None, None, 100, None) + } + checkEdges(Set(eg1, eg5)) { + SquerylEntityModel.edgeQuery(Nil, Nil, Seq(ent03.id, ent05.id), None, None, 100, None) + } + + checkEdges(Set(eg5, eg7)) { + SquerylEntityModel.edgeQuery(Seq(ent03.id), Seq("rules"), Nil, None, None, 100, None) + } + checkEdges(Set(eg5)) { + SquerylEntityModel.edgeQuery(Seq(ent03.id), Seq("rules"), Nil, Some(1), None, 100, None) + } + + val first = SquerylEntityModel.edgeQuery(Nil, Nil, Nil, None, None, 3, None) + val second = SquerylEntityModel.edgeQuery(Nil, Nil, Nil, None, Some(first.last.getId.getValue.toLong), 2, None) + val third = SquerylEntityModel.edgeQuery(Nil, Nil, Nil, None, Some(second.last.getId.getValue.toLong), 2, None) + + val paged = first ++ second ++ third + paged.size should equal(all.size) + val pageSimple = paged.map(proto => (protoUUIDToUuid(proto.getParent), protoUUIDToUuid(proto.getChild), proto.getRelationship, proto.getDistance)) + pageSimple.toSet should equal(all) + + val entFilter = new EntityFilter { + protected def filterQuery: Query[EntityRow] = { + from(ServicesSchema.entities)(ent => + where(notExists( + from(ServicesSchema.entityTypes)(t => + where(t.entType === "banned" and t.entityId === ent.id) + select (t)))) + select (ent)) + } + } + + checkEdges(Set(eg1, eg5, eg6, eg7)) { + SquerylEntityModel.edgeQuery(Nil, Nil, Nil, None, None, 100, Some(entFilter)) + } + checkEdges(Set(eg1)) { + SquerylEntityModel.edgeQuery(Seq(ent01.id), Seq("relatesTo"), Nil, None, None, 100, Some(entFilter)) + } + checkEdges(Set(eg6, eg7)) { + SquerylEntityModel.edgeQuery(Nil, Nil, Seq(ent06.id), None, None, 100, Some(entFilter)) + } + + } + + test("Edge query paging") { + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + val ent04 = createEntity("ent04", Seq("typeA")) + val ent05 = createEntity("ent05", Seq("typeA")) + val ent06 = createEntity("ent06", Seq("typeA")) + val ent07 = createEntity("ent07", Seq("typeA")) + val ent08 = createEntity("ent08", Seq("typeA")) + + val relation = "relatesTo" + val eg1 = (ent01.id, ent02.id, relation, 1) + val eg2 = (ent01.id, ent03.id, relation, 1) + val eg3 = (ent01.id, ent04.id, relation, 1) + val eg4 = (ent01.id, ent05.id, relation, 1) + val eg5 = (ent01.id, ent06.id, relation, 1) + val eg6 = (ent01.id, ent07.id, relation, 1) + val eg7 = (ent01.id, ent08.id, relation, 1) + + val all = Set(eg1, eg2, eg3, eg4, eg5, eg6, eg7) + + ServicesSchema.edges.insert(EntityEdgeRow(3, ent01.id, ent02.id, relation, 1)) + ServicesSchema.edges.insert(EntityEdgeRow(4, ent01.id, ent03.id, relation, 1)) + ServicesSchema.edges.insert(EntityEdgeRow(6, ent01.id, ent04.id, relation, 1)) + ServicesSchema.edges.insert(EntityEdgeRow(2, ent01.id, ent05.id, relation, 1)) + ServicesSchema.edges.insert(EntityEdgeRow(7, ent01.id, ent06.id, relation, 1)) + ServicesSchema.edges.insert(EntityEdgeRow(5, ent01.id, ent07.id, relation, 1)) + ServicesSchema.edges.insert(EntityEdgeRow(1, ent01.id, ent08.id, relation, 1)) + + checkEdges(all) { + SquerylEntityModel.edgeQuery(Nil, Nil, Nil, None, None, 100, None) + } + + val r1 = SquerylEntityModel.edgeQuery(Seq(ent01.id), Nil, Nil, None, None, 2, None).toVector + r1.size should equal(2) + val r2 = SquerylEntityModel.edgeQuery(Seq(ent01.id), Nil, Nil, None, Some(r1.last.getId), 2, None).toVector + r2.size should equal(2) + val r3 = SquerylEntityModel.edgeQuery(Seq(ent01.id), Nil, Nil, None, Some(r2.last.getId), 2, None).toVector + r3.size should equal(2) + val r4 = SquerylEntityModel.edgeQuery(Seq(ent01.id), Nil, Nil, None, Some(r3.last.getId), 2, None).toVector + r4.size should equal(1) + + val concatResults = r1 ++ r2 ++ r3 ++ r4 + concatResults.size should equal(all.size) + + checkEdges(all) { r1 ++ r2 ++ r3 ++ r4 } + } + + def checkKeyValues(correct: Set[(UUID, String)])(query: => Seq[(UUID, Array[Byte])]) { + val results = query + results.size should equal(correct.size) + results.map(tup => (tup._1, new String(tup._2, "UTF-8"))).toSet should equal(correct) + } + + test("Entity key store get") { + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + val ent04 = createEntity("ent04", Seq("typeA")) + + val kv01 = createEntityKeyValue(ent01.id, "keyA", "value01".getBytes("UTF-8")) + val kv02 = createEntityKeyValue(ent02.id, "keyA", "value02".getBytes("UTF-8")) + val kv03 = createEntityKeyValue(ent03.id, "keyB", "value03".getBytes("UTF-8")) + val kv04 = createEntityKeyValue(ent02.id, "keyB", "value04".getBytes("UTF-8")) + + val v1 = (ent01.id, "value01") + val v2 = (ent02.id, "value02") + val v3 = (ent03.id, "value03") + val v4 = (ent02.id, "value04") + + checkKeyValues(Set(v1)) { + SquerylEntityModel.getKeyValues(Seq(ent01.id), "keyA") + } + + checkKeyValues(Set(v1, v2)) { + SquerylEntityModel.getKeyValues(Seq(ent01.id, ent02.id), "keyA") + } + + checkKeyValues(Set(v1, v2)) { + SquerylEntityModel.getKeyValues(Seq(ent01.id, ent02.id, ent03.id, ent04.id), "keyA") + } + + checkKeyValues(Set(v3, v4)) { + SquerylEntityModel.getKeyValues(Seq(ent01.id, ent02.id, ent03.id, ent04.id), "keyB") + } + } + + def checkPutKeyValues(correct: Set[(UUID, String)], correctCreates: Set[UUID], correctUpdates: Set[UUID])(query: => (Seq[(UUID, Array[Byte])], Seq[UUID], Seq[UUID])) { + val (results, created, updated) = query + results.size should equal(correct.size) + results.map(tup => (tup._1, new String(tup._2, "UTF-8"))).toSet should equal(correct) + + created.size should equal(correctCreates.size) + created.toSet should equal(correctCreates) + + updated.size should equal(correctUpdates.size) + updated.toSet should equal(correctUpdates) + } + + test("Entity key store put") { + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + + val kv02 = createEntityKeyValue(ent02.id, "keyA", "value55".getBytes("UTF-8")) + val kv03 = createEntityKeyValue(ent03.id, "keyB", "value03".getBytes("UTF-8")) + + val v1 = (ent01.id, "value01") + val v2 = (ent02.id, "value02") + val v3 = (ent03.id, "value03") + + val putsKeyA = Seq( + (ent01.id, "value01".getBytes), + (ent02.id, "value02".getBytes)) + + checkPutKeyValues(Set(v1, v2), Set(ent01.id), Set(ent02.id)) { + SquerylEntityModel.putKeyValues(putsKeyA, "keyA") + } + + checkPutKeyValues(Set(v3), Set.empty[UUID], Set.empty[UUID]) { + SquerylEntityModel.putKeyValues(Seq((ent03.id, "value03".getBytes)), "keyB") + } + } + + test("Entity key store delete") { + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + val ent04 = createEntity("ent04", Seq("typeA")) + + val kv01 = createEntityKeyValue(ent01.id, "keyA", "value01".getBytes("UTF-8")) + val kv02 = createEntityKeyValue(ent02.id, "keyA", "value02".getBytes("UTF-8")) + val kv03 = createEntityKeyValue(ent03.id, "keyB", "value03".getBytes("UTF-8")) + val kv04 = createEntityKeyValue(ent02.id, "keyB", "value04".getBytes("UTF-8")) + + val v1 = (ent01.id, "value01") + val v2 = (ent02.id, "value02") + val v3 = (ent03.id, "value03") + val v4 = (ent02.id, "value04") + + checkKeyValues(Set(v1)) { + SquerylEntityModel.deleteKeyValues(Seq(ent01.id), "keyA") + } + checkKeyValues(Set(v2)) { + SquerylEntityModel.deleteKeyValues(Seq(ent02.id, ent03.id), "keyA") + } + checkKeyValues(Set(v3, v4)) { + SquerylEntityModel.deleteKeyValues(Seq(ent02.id, ent03.id), "keyB") + } + + checkKeyValues(Set()) { + SquerylEntityModel.deleteKeyValues(Seq(ent04.id), "keyB") + } + checkKeyValues(Set()) { + SquerylEntityModel.deleteKeyValues(Seq(UUID.randomUUID()), "keyB") + } + } + + def checkKeyValues2(correct: Set[(UUID, String, String)])(query: => Seq[EntityKeyValue]) { + val results = query + results.size should equal(correct.size) + results.map(row => (protoUUIDToUuid(row.getUuid), row.getKey, row.getValue.getStringValue)).toSet should equal(correct) + } + + def strToStored(str: String): StoredValue = { + StoredValue.newBuilder() + .setStringValue(str) + .build() + } + + test("Entity key store get 2") { + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + val ent04 = createEntity("ent04", Seq("typeA")) + + val kv01 = createEntityKeyValue(ent01.id, "keyA", strToStored("value01").toByteArray) + val kv02 = createEntityKeyValue(ent02.id, "keyA", strToStored("value02").toByteArray) + val kv03 = createEntityKeyValue(ent03.id, "keyB", strToStored("value03").toByteArray) + val kv04 = createEntityKeyValue(ent02.id, "keyB", strToStored("value04").toByteArray) + + val v1 = (ent01.id, "keyA", "value01") + val v2 = (ent02.id, "keyA", "value02") + val v3 = (ent03.id, "keyB", "value03") + val v4 = (ent02.id, "keyB", "value04") + + check(Set(v1)) { + SquerylEntityModel.getKeyValues2(Seq((ent01.id, "keyA"))) + } + + check(Set(v1, v2)) { + SquerylEntityModel.getKeyValues2(Seq((ent01.id, "keyA"), (ent02.id, "keyA"))) + } + + check(Set(v1, v2)) { + SquerylEntityModel.getKeyValues2(Seq((ent01.id, "keyA"), (ent02.id, "keyA"), (ent03.id, "keyA"), (ent04.id, "keyA"))) + } + + check(Set(v3, v4)) { + SquerylEntityModel.getKeyValues2(Seq((ent01.id, "keyB"), (ent02.id, "keyB"), (ent03.id, "keyB"), (ent04.id, "keyB"))) + } + } + + test("Entity key store put 2") { + val notifier = new TestModelNotifier + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + + val kv02 = createEntityKeyValue(ent02.id, "keyA", strToStored("value55").toByteArray) + val kv03 = createEntityKeyValue(ent03.id, "keyB", strToStored("value03").toByteArray) + + val v1 = (ent01.id, "keyA", "value01") + val v2 = (ent02.id, "keyA", "value02") + val v3 = (ent03.id, "keyB", "value03") + + val putsKeyA = Seq( + (ent01.id, "keyA", strToStored("value01")), + (ent02.id, "keyA", strToStored("value02"))) + + check(Set(v1, v2)) { + SquerylEntityModel.putKeyValues2(notifier, putsKeyA) + } + + check(Set(v3)) { + SquerylEntityModel.putKeyValues2(notifier, Seq((ent03.id, "keyB", strToStored("value03")))) + } + + notifier.checkEntityKeyValues(Set( + (Created, v1), + (Updated, v2))) + } + + test("Entity key store delete 2") { + val notifier = new TestModelNotifier + val ent01 = createEntity("ent01", Seq("typeA")) + val ent02 = createEntity("ent02", Seq("typeA")) + val ent03 = createEntity("ent03", Seq("typeA")) + val ent04 = createEntity("ent04", Seq("typeA")) + + val kv01 = createEntityKeyValue(ent01.id, "keyA", strToStored("value01").toByteArray) + val kv02 = createEntityKeyValue(ent02.id, "keyA", strToStored("value02").toByteArray) + val kv03 = createEntityKeyValue(ent03.id, "keyB", strToStored("value03").toByteArray) + val kv04 = createEntityKeyValue(ent02.id, "keyB", strToStored("value04").toByteArray) + + val v1 = (ent01.id, "keyA", "value01") + val v2 = (ent02.id, "keyA", "value02") + val v3 = (ent03.id, "keyB", "value03") + val v4 = (ent02.id, "keyB", "value04") + + check(Set(v1)) { + SquerylEntityModel.deleteKeyValues2(notifier, Seq((ent01.id, "keyA"))) + } + check(Set(v2)) { + SquerylEntityModel.deleteKeyValues2(notifier, Seq((ent02.id, "keyA"), (ent03.id, "keyA"))) + } + check(Set(v3, v4)) { + SquerylEntityModel.deleteKeyValues2(notifier, Seq((ent02.id, "keyB"), (ent03.id, "keyB"))) + } + + check(Set()) { + SquerylEntityModel.deleteKeyValues2(notifier, Seq((ent04.id, "keyB"))) + } + check(Set()) { + SquerylEntityModel.deleteKeyValues2(notifier, Seq((UUID.randomUUID(), "keyB"))) + } + + notifier.checkEntityKeyValues(Set( + (Deleted, v1), + (Deleted, v2), + (Deleted, v3), + (Deleted, v4))) + } + +} diff --git a/services/src/test/scala/io/greenbus/services/model/EventModelTest.scala b/services/src/test/scala/io/greenbus/services/model/EventModelTest.scala new file mode 100644 index 0000000..cb52a25 --- /dev/null +++ b/services/src/test/scala/io/greenbus/services/model/EventModelTest.scala @@ -0,0 +1,688 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.model + +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner +import io.greenbus.services.framework.{ Deleted, Updated, Created } +import io.greenbus.client.service.proto.Events.{ Alarm, EventConfig, Attribute } +import io.greenbus.services.model.EventAlarmModel.{ EventQueryParams, SysEventTemplate, EventConfigTemp } +import java.util.UUID +import io.greenbus.services.authz.EntityFilter +import org.squeryl.Query +import io.greenbus.services.data.{ EventRow, ServicesSchema, EntityRow } + +@RunWith(classOf[JUnitRunner]) +class EventModelTest extends ServiceTestBase { + import ModelTestHelpers._ + + test("EventConfig query") { + val ec01 = createEventConfig("type01", 9, EventConfig.Designation.EVENT, Alarm.State.UNACK_SILENT, "res01", false) + val ec02 = createEventConfig("type02", 8, EventConfig.Designation.ALARM, Alarm.State.ACKNOWLEDGED, "res01", false) + val ec03 = createEventConfig("type03", 7, EventConfig.Designation.LOG, Alarm.State.UNACK_AUDIBLE, "res01", false) + val ec04 = createEventConfig("type04", 6, EventConfig.Designation.EVENT, Alarm.State.UNACK_SILENT, "res01", false) + val ec05 = createEventConfig("type05", 5, EventConfig.Designation.ALARM, Alarm.State.ACKNOWLEDGED, "res01", false) + val ec06 = createEventConfig("type06", 4, EventConfig.Designation.LOG, Alarm.State.UNACK_AUDIBLE, "res01", false) + + val r1 = ("type01", 9, EventConfig.Designation.EVENT, Alarm.State.UNACK_SILENT, "res01", false) + val r2 = ("type02", 8, EventConfig.Designation.ALARM, Alarm.State.ACKNOWLEDGED, "res01", false) + val r3 = ("type03", 7, EventConfig.Designation.LOG, Alarm.State.UNACK_AUDIBLE, "res01", false) + val r4 = ("type04", 6, EventConfig.Designation.EVENT, Alarm.State.UNACK_SILENT, "res01", false) + val r5 = ("type05", 5, EventConfig.Designation.ALARM, Alarm.State.ACKNOWLEDGED, "res01", false) + val r6 = ("type06", 4, EventConfig.Designation.LOG, Alarm.State.UNACK_AUDIBLE, "res01", false) + val all = Set(r1, r2, r3, r4, r5, r6) + + check(Set(r1, r3, r5)) { + SquerylEventAlarmModel.eventConfigQuery(List(9, 7, 5), None, Nil, Nil, None, 100) + } + check(Set(r4, r5, r6)) { + SquerylEventAlarmModel.eventConfigQuery(Nil, Some(6), Nil, Nil, None, 100) + } + check(Set(r2, r3, r5, r6)) { + SquerylEventAlarmModel.eventConfigQuery(Nil, None, List(EventConfig.Designation.ALARM, EventConfig.Designation.LOG), Nil, None, 100) + } + check(Set(r1, r2, r4, r5)) { + SquerylEventAlarmModel.eventConfigQuery(Nil, None, Nil, List(Alarm.State.UNACK_SILENT, Alarm.State.ACKNOWLEDGED), None, 100) + } + + check(Set(r1, r2)) { + SquerylEventAlarmModel.eventConfigQuery(Nil, None, Nil, Nil, None, 2) + } + check(Set(r3, r4)) { + SquerylEventAlarmModel.eventConfigQuery(Nil, None, Nil, Nil, Some("type02"), 2) + } + check(Set(r5, r6)) { + SquerylEventAlarmModel.eventConfigQuery(Nil, None, Nil, Nil, Some("type04"), 2) + } + } + + test("EventConfig get") { + val ec01 = createEventConfig("type01", 5, EventConfig.Designation.EVENT, Alarm.State.UNACK_SILENT, "res01", true) + val ec02 = createEventConfig("type02", 20, EventConfig.Designation.ALARM, Alarm.State.ACKNOWLEDGED, "res02", false) + + val r1 = ("type01", 5, EventConfig.Designation.EVENT, Alarm.State.UNACK_SILENT, "res01", true) + val r2 = ("type02", 20, EventConfig.Designation.ALARM, Alarm.State.ACKNOWLEDGED, "res02", false) + val all = Set(r1, r2) + + check(all) { + SquerylEventAlarmModel.getEventConfigs(Seq("type01", "type02")) + } + + check(Set(r1)) { + SquerylEventAlarmModel.getEventConfigs(Seq("type01")) + } + check(Set(r2)) { + SquerylEventAlarmModel.getEventConfigs(Seq("type02")) + } + + } + + test("EventConfig put") { + val notifier = new TestModelNotifier + val ec01 = createEventConfig("type01", 1, EventConfig.Designation.LOG, Alarm.State.UNACK_AUDIBLE, "resA", false) + + val r1 = ("type01", 5, EventConfig.Designation.EVENT, Alarm.State.UNACK_SILENT, "res01", false) + val r2 = ("type02", 20, EventConfig.Designation.ALARM, Alarm.State.ACKNOWLEDGED, "res02", false) + val all = Set(r1, r2) + + val puts = Seq( + EventConfigTemp("type01", 5, EventConfig.Designation.EVENT, Alarm.State.UNACK_SILENT, "res01"), + EventConfigTemp("type02", 20, EventConfig.Designation.ALARM, Alarm.State.ACKNOWLEDGED, "res02")) + + check(all) { + SquerylEventAlarmModel.putEventConfigs(notifier, puts) + } + + notifier.checkEventConfigs(Set( + (Updated, r1), + (Created, r2))) + } + + test("EventConfig delete") { + val notifier = new TestModelNotifier + val ec01 = createEventConfig("type01", 5, EventConfig.Designation.EVENT, Alarm.State.UNACK_SILENT, "res01", true) + val ec02 = createEventConfig("type02", 20, EventConfig.Designation.ALARM, Alarm.State.ACKNOWLEDGED, "res02", false) + + val r1 = ("type01", 5, EventConfig.Designation.EVENT, Alarm.State.UNACK_SILENT, "res01", true) + val r2 = ("type02", 20, EventConfig.Designation.ALARM, Alarm.State.ACKNOWLEDGED, "res02", false) + val all = Set(r1, r2) + + check(all) { + SquerylEventAlarmModel.deleteEventConfigs(notifier, Seq("type01", "type02")) + } + + notifier.checkEventConfigs(Set( + (Deleted, r1), + (Deleted, r2))) + } + + test("Event post simple") { + val notifier = new TestModelNotifier + val ec01 = createEventConfig("type01", 9, EventConfig.Designation.EVENT, Alarm.State.UNACK_SILENT, "res01", false) + val ec02 = createEventConfig("type02", 3, EventConfig.Designation.EVENT, Alarm.State.UNACK_SILENT, "res02", false) + + val e1: (String, Int, String, Boolean, String, String, Option[UUID], Option[String], Option[Long]) = ("type01", 9, "user01", false, "sub01", "res01", Option.empty[UUID], None, Some(3L)) + val e2 = ("type02", 3, "user02", false, "sub01", "res02", None, None, Some(5L)) + + val puts = Seq( + SysEventTemplate("user01", "type01", Some("sub01"), Some(3), None, None, Seq()), + SysEventTemplate("user02", "type02", Some("sub01"), Some(5), None, None, Seq())) + + check(Set(e1, e2)) { + SquerylEventAlarmModel.postEvents(notifier, puts) + } + + notifier.checkEvents(Set( + (Created, e1), + (Created, e2))) + } + + test("Event post all fields") { + val notifier = new TestModelNotifier + val ec01 = createEventConfig("type01", 9, EventConfig.Designation.EVENT, Alarm.State.UNACK_SILENT, "res01", false) + val ec02 = createEventConfig("type02", 3, EventConfig.Designation.EVENT, Alarm.State.UNACK_SILENT, "res02", false) + + val uuid1 = UUID.randomUUID() + val uuid2 = UUID.randomUUID() + + val e1: (String, Int, String, Boolean, String, String, Option[UUID], Option[String], Option[Long]) = ("type01", 9, "user01", false, "sub01", "res01", Some(uuid1), Some("group01"), Some(3L)) + val e2 = ("type02", 3, "user02", false, "sub01", "res02", Some(uuid2), Some("group02"), Some(5L)) + + val puts = Seq( + SysEventTemplate("user01", "type01", Some("sub01"), Some(3), Some(UUIDHelpers.uuidToProtoUUID(uuid1)), Some("group01"), Seq()), + SysEventTemplate("user02", "type02", Some("sub01"), Some(5), Some(UUIDHelpers.uuidToProtoUUID(uuid2)), Some("group02"), Seq())) + + check(Set(e1, e2)) { + SquerylEventAlarmModel.postEvents(notifier, puts) + } + + notifier.checkEvents(Set( + (Created, e1), + (Created, e2))) + } + + test("Event post multiple types") { + val notifier = new TestModelNotifier + val ec01 = createEventConfig("type01", 9, EventConfig.Designation.EVENT, Alarm.State.UNACK_SILENT, "res01", false) + val ec02 = createEventConfig("type02", 20, EventConfig.Designation.ALARM, Alarm.State.UNACK_SILENT, "res02", false) + val ec03 = createEventConfig("type03", 1, EventConfig.Designation.LOG, Alarm.State.UNACK_SILENT, "res03", false) + + val e1: (String, Int, String, Boolean, String, String, Option[UUID], Option[String], Option[Long]) = ("type01", 9, "user01", false, "sub01", "res01", None, None, Some(3L)) + val e2 = ("type02", 20, "user02", true, "sub01", "res02", None, None, Some(5L)) + val e3 = ("type03", 1, "user03", false, "sub01", "res03", None, None, Some(8L)) + + val a1 = (Alarm.State.UNACK_SILENT, "type02", 20, "user02", true, "sub01", "res02", None, Some(5L)) + + val puts = Seq( + SysEventTemplate("user01", "type01", Some("sub01"), Some(3), None, None, Seq()), + SysEventTemplate("user02", "type02", Some("sub01"), Some(5), None, None, Seq()), + SysEventTemplate("user03", "type03", Some("sub01"), Some(8), None, None, Seq())) + + check(Set(e1, e2, e3)) { + SquerylEventAlarmModel.postEvents(notifier, puts) + } + + notifier.checkEvents(Set( + (Created, e1), + (Created, e2), + (Created, e3))) + + notifier.checkAlarms(Set( + (Created, a1))) + } + + test("Event post alarm initial") { + val notifier = new TestModelNotifier + val ec01 = createEventConfig("type01", 9, EventConfig.Designation.ALARM, Alarm.State.UNACK_SILENT, "res01", false) + val ec02 = createEventConfig("type02", 3, EventConfig.Designation.ALARM, Alarm.State.UNACK_AUDIBLE, "res02", false) + val ec03 = createEventConfig("type03", 5, EventConfig.Designation.ALARM, Alarm.State.ACKNOWLEDGED, "res03", false) + + val e1: (String, Int, String, Boolean, String, String, Option[UUID], Option[String], Option[Long]) = + ("type01", 9, "user01", true, "sub01", "res01", Option.empty[UUID], None, Some(3L)) + val e2 = ("type02", 3, "user02", true, "sub01", "res02", None, None, Some(5L)) + val e3 = ("type03", 5, "user03", true, "sub01", "res03", None, None, Some(8L)) + + val a1: (Alarm.State, String, Int, String, Boolean, String, String, Option[UUID], Option[Long]) = + (Alarm.State.UNACK_SILENT, "type01", 9, "user01", true, "sub01", "res01", Option.empty[UUID], Some(3L)) + val a2 = (Alarm.State.UNACK_AUDIBLE, "type02", 3, "user02", true, "sub01", "res02", None, Some(5L)) + val a3 = (Alarm.State.ACKNOWLEDGED, "type03", 5, "user03", true, "sub01", "res03", None, Some(8L)) + + val allEvents = Set(e1, e2, e3) + val allAlarms = Set(a1, a2, a3) + + val puts = Seq( + SysEventTemplate("user01", "type01", Some("sub01"), Some(3), None, None, Seq()), + SysEventTemplate("user02", "type02", Some("sub01"), Some(5), None, None, Seq()), + SysEventTemplate("user03", "type03", Some("sub01"), Some(8), None, None, Seq())) + + check(allEvents) { + SquerylEventAlarmModel.postEvents(notifier, puts) + } + + notifier.checkEvents(allEvents.map(e => (Created, e))) + notifier.checkAlarms(allAlarms.map(a => (Created, a))) + } + + test("Event post without config is alarm") { + val notifier = new TestModelNotifier + val ec02 = createEventConfig("type02", 3, EventConfig.Designation.EVENT, Alarm.State.UNACK_SILENT, "res02", false) + + val e1: (String, Int, String, Boolean, String, String, Option[UUID], Option[String], Option[Long]) = + ("NON_EXISTENT_TYPE", EventAlarmModel.unconfiguredEventSeverity, "user01", true, "sub01", EventAlarmModel.unconfiguredEventMessage, Option.empty[UUID], None, Some(3L)) + val e2 = ("type02", 3, "user02", false, "sub01", "res02", None, None, Some(5L)) + + val a1: (Alarm.State, String, Int, String, Boolean, String, String, Option[UUID], Option[Long]) = + (Alarm.State.UNACK_SILENT, "NON_EXISTENT_TYPE", EventAlarmModel.unconfiguredEventSeverity, "user01", true, "sub01", EventAlarmModel.unconfiguredEventMessage, Option.empty[UUID], Some(3L)) + + val puts = Seq( + SysEventTemplate("user01", "NON_EXISTENT_TYPE", Some("sub01"), Some(3), None, None, Seq()), + SysEventTemplate("user02", "type02", Some("sub01"), Some(5), None, None, Seq())) + + check(Set(e1, e2)) { + SquerylEventAlarmModel.postEvents(notifier, puts) + } + + notifier.checkEvents(Set( + (Created, e1), + (Created, e2))) + + notifier.checkAlarms(Set( + (Created, a1))) + } + + test("Event post message") { + val notifier = new TestModelNotifier + val ec01 = createEventConfig("type01", 9, EventConfig.Designation.EVENT, Alarm.State.UNACK_SILENT, "test resource {key01} {key02}", false) + + val alist = Seq(Attribute.newBuilder() + .setName("key01") + .setValueString("v01") + .build(), + Attribute.newBuilder() + .setName("key02") + .setValueString("v02") + .build()) + + val e1: (String, Int, String, Boolean, String, String, Option[UUID], Option[String], Option[Long]) = + ("type01", 9, "user01", false, "sub01", "test resource v01 v02", Option.empty[UUID], None, Some(3L)) + + val puts = Seq( + SysEventTemplate("user01", "type01", Some("sub01"), Some(3), None, None, alist)) + + check(Set(e1)) { + SquerylEventAlarmModel.postEvents(notifier, puts) + } + + notifier.checkEvents(Set( + (Created, e1))) + } + + test("Event post create filter") { + import UUIDHelpers._ + val notifier = new TestModelNotifier + val ent01 = createEntity("point01", Seq("Point")) + val ent02 = createEntity("point02", Seq("Point")) + val ec01 = createEventConfig("type01", 9, EventConfig.Designation.EVENT, Alarm.State.UNACK_SILENT, "res01", false) + + val filter = new EntityFilter { + import org.squeryl.PrimitiveTypeMode._ + protected def filterQuery: Query[EntityRow] = { + ServicesSchema.entities.where(ent => ent.id === ent01.id) + } + } + + val e1: (String, Int, String, Boolean, String, String, Option[UUID], Option[String], Option[Long]) = ("type01", 9, "user01", false, "sub01", "res01", Some(ent01.id), None, Some(3L)) + + check(Set(e1)) { + SquerylEventAlarmModel.postEvents(notifier, Seq(SysEventTemplate("user01", "type01", Some("sub01"), Some(3), Some(uuidToProtoUUID(ent01.id)), None, Seq())), Some(filter)) + } + + intercept[ModelPermissionException] { + SquerylEventAlarmModel.postEvents(notifier, Seq(SysEventTemplate("user01", "type01", Some("sub01"), Some(3), Some(uuidToProtoUUID(ent02.id)), None, Seq())), Some(filter)) + } + intercept[ModelPermissionException] { + SquerylEventAlarmModel.postEvents(notifier, Seq(SysEventTemplate("user01", "type01", Some("sub01"), Some(3), None, None, Seq())), Some(filter)) + } + + } + + class EventQueryFixture { + val ent01 = createEntity("point01", Seq("Point")) + val ent02 = createEntity("point02", Seq("Point")) + val ev01 = createEvent("type01", false, 1, None, 2, "sub01", "agent01", None, None, Array.empty[Byte], "message01") + val ev02 = createEvent("type02", true, 2, None, 5, "sub02", "agent01", Some(ent01.id), Some("group01"), Array.empty[Byte], "message02") + val ev03 = createEvent("type03", false, 3, None, 5, "sub01", "agent02", Some(ent02.id), Some("group02"), Array.empty[Byte], "message03") + val ev04 = createEvent("type01", false, 4, None, 7, "sub01", "agent02", Some(ent01.id), Some("group01"), Array.empty[Byte], "message04") + val ev05 = createEvent("type02", true, 5, None, 8, "sub03", "agent03", Some(ent02.id), Some("group02"), Array.empty[Byte], "message05") + val ev06 = createEvent("type03", false, 6, None, 8, "sub01", "agent03", None, None, Array.empty[Byte], "message06") + + val e1: (String, Int, Long, String, Boolean, String, String, Option[UUID], Option[String], Option[Long]) = ("type01", 2, 1, "agent01", false, "sub01", "message01", None, None, None) + val e2: (String, Int, Long, String, Boolean, String, String, Option[UUID], Option[String], Option[Long]) = ("type02", 5, 2, "agent01", true, "sub02", "message02", Some(ent01.id), Some("group01"), None) + val e3: (String, Int, Long, String, Boolean, String, String, Option[UUID], Option[String], Option[Long]) = ("type03", 5, 3, "agent02", false, "sub01", "message03", Some(ent02.id), Some("group02"), None) + val e4: (String, Int, Long, String, Boolean, String, String, Option[UUID], Option[String], Option[Long]) = ("type01", 7, 4, "agent02", false, "sub01", "message04", Some(ent01.id), Some("group01"), None) + val e5: (String, Int, Long, String, Boolean, String, String, Option[UUID], Option[String], Option[Long]) = ("type02", 8, 5, "agent03", true, "sub03", "message05", Some(ent02.id), Some("group02"), None) + val e6: (String, Int, Long, String, Boolean, String, String, Option[UUID], Option[String], Option[Long]) = ("type03", 8, 6, "agent03", false, "sub01", "message06", None, None, None) + } + + test("Event query") { + val f = new EventQueryFixture + import f._ + + val all = Seq(e1, e2, e3, e4, e5, e6) + + checkOrder(all) { + SquerylEventAlarmModel.eventQuery(EventQueryParams()) + } + + checkOrder(Seq(e1, e2, e4, e5)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(eventTypes = Seq("type01", "type02"))) + } + checkOrder(Seq(e1, e5, e6)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(severities = List(2, 8))) + } + checkOrder(Seq(e1, e2, e3)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(severityOrHigher = Some(5))) + } + checkOrder(Seq(e2, e5)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(subsystems = List("sub02", "sub03"))) + } + checkOrder(Seq(e1, e2, e5, e6)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(agents = List("agent01", "agent03"))) + } + + checkOrder(Seq(e2, e4)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(entityUuids = List(ent01.id))) + } + checkOrder(Seq(e3, e5)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(entityNames = List("point02"))) + } + + checkOrder(Seq(e2, e4)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(modelGroups = Seq("group01"))) + } + checkOrder(Seq(e2, e3, e4, e5)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(modelGroups = Seq("group01", "group02"))) + } + + checkOrder(Seq(e2, e5)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(isAlarm = Some(true))) + } + checkOrder(Seq(e1, e3, e4, e6)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(isAlarm = Some(false))) + } + + checkOrder(Seq(e4, e5, e6)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(timeFrom = Some(4))) + } + checkOrder(Seq(e1, e2, e3)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(timeTo = Some(3))) + } + checkOrder(Seq(e3, e4, e5)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(timeFrom = Some(3), timeTo = Some(5))) + } + + checkOrder(Seq(e5, e6)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(pageSize = 2)) + } + checkOrder(Seq(e1, e2)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(pageSize = 2, latest = false)) + } + } + + test("Event read filtering") { + val f = new EventQueryFixture + import f._ + + val filter = new EntityFilter { + import org.squeryl.PrimitiveTypeMode._ + protected def filterQuery: Query[EntityRow] = { + ServicesSchema.entities.where(ent => ent.id === ent01.id) + } + } + + checkOrder(Seq(e2, e4)) { + SquerylEventAlarmModel.getEvents(Seq(ev01.id, ev02.id, ev03.id, ev04.id, ev05.id, ev06.id), filter = Some(filter)) + } + + checkOrder(Seq(e2, e4)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(), filter = Some(filter)) + } + + checkOrder(Seq(e4)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(eventTypes = Seq("type01")), filter = Some(filter)) + } + + } + + test("Event sub-milli paging") { + + def createSimpleEvent(id: Int, time: Long) = createEvent("type" + id, false, time, None, 2, "sub01", "agent01", None, None, Array.empty[Byte], "message01") + + def simpleEventTuple(id: Int, time: Long): (String, Int, Long, String, Boolean, String, String, Option[UUID], Option[String], Option[Long]) = ("type" + id, 2, time, "agent01", false, "sub01", "message01", None, None, None) + + val ev01 = createSimpleEvent(1, 1) + val ev02 = createSimpleEvent(2, 2) + val ev03 = createSimpleEvent(3, 2) + val ev04 = createSimpleEvent(4, 3) + val ev05 = createSimpleEvent(5, 3) + val ev06 = createSimpleEvent(6, 3) + + val e1 = simpleEventTuple(1, 1) + val e2 = simpleEventTuple(2, 2) + val e3 = simpleEventTuple(3, 2) + val e4 = simpleEventTuple(4, 3) + val e5 = simpleEventTuple(5, 3) + val e6 = simpleEventTuple(6, 3) + + checkOrder(Seq(e1, e2)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(pageSize = 2, latest = false)) + } + checkOrder(Seq(e3, e4)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(pageSize = 2, last = Some(ev02.id), latest = false)) + } + checkOrder(Seq(e5, e6)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(pageSize = 2, last = Some(ev04.id), latest = false)) + } + + checkOrder(Seq(e5, e6)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(pageSize = 2, latest = true)) + } + checkOrder(Seq(e3, e4)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(pageSize = 2, last = Some(ev05.id), latest = true)) + } + checkOrder(Seq(e1, e2)) { + SquerylEventAlarmModel.eventQuery(EventQueryParams(pageSize = 2, last = Some(ev03.id), latest = true)) + } + } + + class AlarmFixture { + val ent01 = createEntity("point01", Seq("Point")) + val ent02 = createEntity("point02", Seq("Point")) + val ev01 = createEvent("type01", true, 1, None, 2, "sub01", "agent01", None, None, Array.empty[Byte], "message01") + val ev02 = createEvent("type02", true, 2, None, 5, "sub02", "agent01", Some(ent01.id), None, Array.empty[Byte], "message02") + val ev03 = createEvent("type03", true, 3, None, 5, "sub01", "agent02", Some(ent02.id), None, Array.empty[Byte], "message03") + val ev04 = createEvent("type01", true, 4, None, 7, "sub01", "agent02", Some(ent01.id), None, Array.empty[Byte], "message04") + val ev05 = createEvent("type02", true, 5, None, 8, "sub03", "agent03", Some(ent02.id), None, Array.empty[Byte], "message05") + val ev06 = createEvent("type03", true, 6, None, 8, "sub01", "agent03", None, None, Array.empty[Byte], "message06") + + val al01 = createAlarm(ev01.id, Alarm.State.UNACK_SILENT) + val al02 = createAlarm(ev02.id, Alarm.State.UNACK_SILENT) + val al03 = createAlarm(ev03.id, Alarm.State.UNACK_AUDIBLE) + val al04 = createAlarm(ev04.id, Alarm.State.UNACK_AUDIBLE) + val al05 = createAlarm(ev05.id, Alarm.State.ACKNOWLEDGED) + val al06 = createAlarm(ev06.id, Alarm.State.REMOVED) + + val a1 = (ev01.id, Alarm.State.UNACK_SILENT) + val a2 = (ev02.id, Alarm.State.UNACK_SILENT) + val a3 = (ev03.id, Alarm.State.UNACK_AUDIBLE) + val a4 = (ev04.id, Alarm.State.UNACK_AUDIBLE) + val a5 = (ev05.id, Alarm.State.ACKNOWLEDGED) + val a6 = (ev06.id, Alarm.State.REMOVED) + + val all = Seq(a1, a2, a3, a4, a5, a6) + } + + test("Alarm query") { + val f = new AlarmFixture + import f._ + + checkOrder(all) { + SquerylEventAlarmModel.alarmQuery(Nil, EventQueryParams()) + } + + checkOrder(Seq(a3, a4, a5)) { + SquerylEventAlarmModel.alarmQuery(List(Alarm.State.UNACK_AUDIBLE, Alarm.State.ACKNOWLEDGED), EventQueryParams()) + } + + checkOrder(Seq(a1, a2)) { + SquerylEventAlarmModel.alarmQuery(List(Alarm.State.UNACK_SILENT), EventQueryParams(severityOrHigher = Some(5))) + } + + checkOrder(Seq(a5, a6)) { + SquerylEventAlarmModel.alarmQuery(Nil, EventQueryParams(pageSize = 2)) + } + checkOrder(Seq(a1, a2)) { + SquerylEventAlarmModel.alarmQuery(Nil, EventQueryParams(pageSize = 2, latest = false)) + } + + } + + test("Alarm sub-milli paging") { + + def createSimpleEvent(id: Int, time: Long) = createEvent("type" + id, false, time, None, 2, "sub01", "agent01", None, None, Array.empty[Byte], "message01") + + val ev01 = createSimpleEvent(1, 1) + val ev02 = createSimpleEvent(2, 2) + val ev03 = createSimpleEvent(3, 2) + val ev04 = createSimpleEvent(4, 3) + val ev05 = createSimpleEvent(5, 3) + val ev06 = createSimpleEvent(6, 3) + + def createSimpleAlarm(e: EventRow) = { + createAlarm(e.id, Alarm.State.UNACK_SILENT) + } + + def simpleAlarmTuple(id: Long): (Long, Alarm.State) = (id, Alarm.State.UNACK_SILENT) + + val al01 = createSimpleAlarm(ev01) + val al02 = createSimpleAlarm(ev02) + val al03 = createSimpleAlarm(ev03) + val al04 = createSimpleAlarm(ev04) + val al05 = createSimpleAlarm(ev05) + val al06 = createSimpleAlarm(ev06) + + val e1 = simpleAlarmTuple(ev01.id) + val e2 = simpleAlarmTuple(ev02.id) + val e3 = simpleAlarmTuple(ev03.id) + val e4 = simpleAlarmTuple(ev04.id) + val e5 = simpleAlarmTuple(ev05.id) + val e6 = simpleAlarmTuple(ev06.id) + + checkOrder(Seq(e1, e2)) { + SquerylEventAlarmModel.alarmQuery(Seq(), EventQueryParams(pageSize = 2, latest = false)) + } + checkOrder(Seq(e3, e4)) { + SquerylEventAlarmModel.alarmQuery(Seq(), EventQueryParams(pageSize = 2, last = Some(al02.id), latest = false)) + } + checkOrder(Seq(e5, e6)) { + SquerylEventAlarmModel.alarmQuery(Seq(), EventQueryParams(pageSize = 2, last = Some(al04.id), latest = false)) + } + + checkOrder(Seq(e5, e6)) { + SquerylEventAlarmModel.alarmQuery(Seq(), EventQueryParams(pageSize = 2, latest = true)) + } + checkOrder(Seq(e3, e4)) { + SquerylEventAlarmModel.alarmQuery(Seq(), EventQueryParams(pageSize = 2, last = Some(al05.id), latest = true)) + } + checkOrder(Seq(e1, e2)) { + SquerylEventAlarmModel.alarmQuery(Seq(), EventQueryParams(pageSize = 2, last = Some(al03.id), latest = true)) + } + } + + test("Alarm read filtering") { + val f = new AlarmFixture + import f._ + + val filter = new EntityFilter { + import org.squeryl.PrimitiveTypeMode._ + protected def filterQuery: Query[EntityRow] = { + ServicesSchema.entities.where(ent => ent.id === ent01.id) + } + } + + checkOrder(Seq(a2, a4)) { + SquerylEventAlarmModel.alarmKeyQuery(Seq(al01.id, al02.id, al03.id, al04.id, al05.id, al06.id), filter = Some(filter)) + } + + checkOrder(Seq(a2, a4)) { + SquerylEventAlarmModel.alarmQuery(Nil, EventQueryParams(), filter = Some(filter)) + } + + checkOrder(Seq(a4)) { + SquerylEventAlarmModel.alarmQuery(Nil, EventQueryParams(eventTypes = Seq("type01")), filter = Some(filter)) + } + + } + + test("Alarm update") { + val notifier = new TestModelNotifier + val ent01 = createEntity("point01", Seq("Point")) + val ev01 = createEvent("type01", true, 1, None, 2, "sub01", "agent01", None, None, Array.empty[Byte], "message01") + val al01 = createAlarm(ev01.id, Alarm.State.UNACK_SILENT) + + intercept[ModelInputException] { + SquerylEventAlarmModel.putAlarmStates(notifier, Seq((al01.id, Alarm.State.UNACK_AUDIBLE))) + } + + check(Set((ev01.id, Alarm.State.ACKNOWLEDGED))) { + SquerylEventAlarmModel.putAlarmStates(notifier, Seq((al01.id, Alarm.State.ACKNOWLEDGED))) + } + + intercept[ModelInputException] { + SquerylEventAlarmModel.putAlarmStates(notifier, Seq((al01.id, Alarm.State.UNACK_SILENT))) + } + + intercept[ModelInputException] { + SquerylEventAlarmModel.putAlarmStates(notifier, Seq((al01.id, Alarm.State.UNACK_AUDIBLE))) + } + + check(Set((ev01.id, Alarm.State.REMOVED))) { + SquerylEventAlarmModel.putAlarmStates(notifier, Seq((al01.id, Alarm.State.REMOVED))) + } + + intercept[ModelInputException] { + SquerylEventAlarmModel.putAlarmStates(notifier, Seq((al01.id, Alarm.State.ACKNOWLEDGED))) + } + } + + class AlarmMultiFixture { + val notifier = new TestModelNotifier + val ent01 = createEntity("point01", Seq("Point")) + val ent02 = createEntity("point02", Seq("Point")) + val ev01 = createEvent("type01", true, 1, None, 2, "sub01", "agent01", None, None, Array.empty[Byte], "message01") + val ev02 = createEvent("type02", true, 2, None, 5, "sub02", "agent01", Some(ent01.id), None, Array.empty[Byte], "message02") + val ev03 = createEvent("type03", true, 3, None, 5, "sub01", "agent02", Some(ent02.id), None, Array.empty[Byte], "message03") + + val al01 = createAlarm(ev01.id, Alarm.State.UNACK_SILENT) + val al02 = createAlarm(ev02.id, Alarm.State.UNACK_SILENT) + val al03 = createAlarm(ev03.id, Alarm.State.UNACK_AUDIBLE) + } + + test("Alarm update multi") { + val f = new AlarmMultiFixture + import f._ + + val a1 = (ev01.id, Alarm.State.UNACK_SILENT) + val a2 = (ev02.id, Alarm.State.UNACK_SILENT) + val a3 = (ev03.id, Alarm.State.UNACK_AUDIBLE) + + check(Set((ev01.id, Alarm.State.ACKNOWLEDGED), (ev02.id, Alarm.State.UNACK_SILENT), (ev03.id, Alarm.State.ACKNOWLEDGED))) { + SquerylEventAlarmModel.putAlarmStates(notifier, Seq((al01.id, Alarm.State.ACKNOWLEDGED), (al02.id, Alarm.State.UNACK_SILENT), (al03.id, Alarm.State.ACKNOWLEDGED))) + } + + notifier.checkAlarmsSimple(Set( + (Updated, (ev01.id, Alarm.State.ACKNOWLEDGED)), + (Updated, (ev03.id, Alarm.State.ACKNOWLEDGED)))) + } + + test("Alarm update auth") { + val f = new AlarmMultiFixture + import f._ + + val filter = new EntityFilter { + import org.squeryl.PrimitiveTypeMode._ + protected def filterQuery: Query[EntityRow] = { + ServicesSchema.entities.where(ent => ent.id === ent01.id) + } + } + + check(Set((ev02.id, Alarm.State.UNACK_SILENT))) { + SquerylEventAlarmModel.putAlarmStates(notifier, Seq((al02.id, Alarm.State.UNACK_SILENT)), Some(filter)) + } + + intercept[ModelPermissionException] { + SquerylEventAlarmModel.putAlarmStates(notifier, Seq((al01.id, Alarm.State.ACKNOWLEDGED)), Some(filter)) + } + + intercept[ModelPermissionException] { + SquerylEventAlarmModel.putAlarmStates(notifier, Seq((al03.id, Alarm.State.ACKNOWLEDGED)), Some(filter)) + } + } + +} diff --git a/services/src/test/scala/io/greenbus/services/model/FrontEndModelTest.scala b/services/src/test/scala/io/greenbus/services/model/FrontEndModelTest.scala new file mode 100644 index 0000000..172822a --- /dev/null +++ b/services/src/test/scala/io/greenbus/services/model/FrontEndModelTest.scala @@ -0,0 +1,786 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.model + +import org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner +import io.greenbus.client.service.proto.FrontEnd.FrontEndConnectionStatus +import io.greenbus.services.data._ +import org.squeryl.PrimitiveTypeMode._ +import UUIDHelpers._ +import io.greenbus.services.framework._ +import io.greenbus.client.service.proto.Model._ +import io.greenbus.services.model.FrontEndModel._ +import java.util.UUID +import org.scalatest.matchers.ShouldMatchers +import io.greenbus.services.model.EntityModel.TypeParams +import io.greenbus.client.service.proto.Model.ModelUUID + +object EntityBasedTestHelpers extends ShouldMatchers { + import ModelTestHelpers._ + + def genericPutTest[A, TupType, InfoType, ProtoType]( + baseName: String, + typ: String, + dbCreates: Vector[UUID => A], + targetTups: Vector[(String, Set[String]) => TupType], + putInfos: Vector[InfoType], + notificationCheck: (TestModelNotifier, Set[(ModelEvent, TupType)]) => Unit)(doCheck: (ModelNotifier, Set[TupType], Seq[CoreTypeTemplate[InfoType]]) => Unit) { + + val notifier = new TestModelNotifier + + val ent03 = createEntity(s"${baseName}03", Seq(typ)) + dbCreates(0)(ent03.id) + val ent04 = createEntity(s"${baseName}04", Seq(typ)) + dbCreates(1)(ent04.id) + val ent05 = createEntity(s"${baseName}05", Seq(typ)) + dbCreates(2)(ent05.id) + val ent06 = createEntity(s"${baseName}06", Seq(typ)) + dbCreates(3)(ent06.id) + + val p1 = targetTups(0)(s"${baseName}01", Set(typ)) + val p2 = targetTups(1)(s"${baseName}02", Set(typ, "AnotherType")) + val p3 = targetTups(2)(s"${baseName}03", Set(typ)) + val p4 = targetTups(3)(s"${baseName}44", Set(typ)) + val p5 = targetTups(4)(s"${baseName}05", Set(typ, "Type05")) + val p6 = targetTups(5)(s"${baseName}06", Set(typ)) + val all = Set(p1, p2, p3, p4, p5, p6) + + val puts = Seq( + CoreTypeTemplate(None, s"${baseName}01", Set.empty[String], putInfos(0)), + CoreTypeTemplate(None, s"${baseName}02", Set("AnotherType"), putInfos(1)), + CoreTypeTemplate(None, s"${baseName}03", Set.empty[String], putInfos(2)), + CoreTypeTemplate(Some(ent04.id), s"${baseName}44", Set.empty[String], putInfos(3)), + CoreTypeTemplate(Some(ent05.id), s"${baseName}05", Set("Type05"), putInfos(4)), + CoreTypeTemplate(Some(ent06.id), s"${baseName}06", Set.empty[String], putInfos(5))) + + doCheck(notifier, all, puts) + + val correctEnts = Set((s"${baseName}01", Set(typ)), + (s"${baseName}02", Set(typ, "AnotherType")), + (s"${baseName}03", Set(typ)), + (s"${baseName}44", Set(typ)), + (s"${baseName}05", Set(typ, "Type05")), + (s"${baseName}06", Set(typ))) + val allEnts = SquerylEntityModel.allQuery(None, 100).map(simplify) + val typEnts = allEnts.filter(_._2.contains(typ)) + typEnts.size should equal(correctEnts.size) + typEnts.toSet should equal(correctEnts) + + notifier.checkEntities(Set( + (Created, (s"${baseName}01", Set(typ))), + (Created, (s"${baseName}02", Set(typ, "AnotherType"))), + (Updated, (s"${baseName}44", Set(typ))), + (Updated, (s"${baseName}05", Set(typ, "Type05"))))) + notificationCheck(notifier, Set( + (Created, p1), + (Created, p2), + (Updated, p3), + (Updated, p4), + (Updated, p5), + (Updated, p6))) + } + + class EntityBasedQueryFixture[Simplified, Proto]( + namePrefix: String, + typ: String, + vec: Vector[(String, Set[String]) => Simplified], + create: Simplified => Unit, + entQuery: (TypeParams, Option[UUID], Option[String], Int, Boolean) => Seq[Proto], + getUuid: Proto => ModelUUID, + getName: Proto => String, + simplify: Proto => Simplified) { + + val p1 = vec(0)(namePrefix + "01", Set(typ)) + val p2 = vec(1)(namePrefix + "02", Set(typ, "TypeA", "TypeB")) + val p3 = vec(2)(namePrefix + "03", Set(typ)) + val p4 = vec(3)(namePrefix + "04", Set(typ, "TypeA")) + val p5 = vec(4)(namePrefix + "05", Set(typ)) + val p6 = vec(5)(namePrefix + "06", Set(typ, "TypeA")) + val all = Set(p1, p2, p3, p4, p5, p6) + + all.foreach(create) + + def entityBasedTests() { + implicit val simp = simplify + + check(all) { + entQuery(TypeParams(Nil, Nil, Nil), None, None, 100, true) + } + + check(Set(p2, p4, p6)) { + entQuery(TypeParams(Seq("TypeA"), Nil, Nil), None, None, 100, true) + } + check(Set(p1, p3, p5)) { + entQuery(TypeParams(Nil, Nil, Seq("TypeA")), None, None, 100, true) + } + check(Set(p2, p4, p6)) { + entQuery(TypeParams(Nil, Seq(typ, "TypeA"), Nil), None, None, 100, true) + } + check(Set(p2)) { + entQuery(TypeParams(Nil, Seq(typ, "TypeA", "TypeB"), Nil), None, None, 100, true) + } + + { + val q1 = entQuery(TypeParams(Nil, Nil, Nil), None, None, 2, false) + val q2 = entQuery(TypeParams(Nil, Nil, Nil), Some(getUuid(q1.last)), None, 2, false) + val q3 = entQuery(TypeParams(Nil, Nil, Nil), Some(getUuid(q2.last)), None, 100, false) + val results = q1 ++ q2 ++ q3 + + results.size should equal(all.size) + results.map(simplify).toSet should equal(all) + } + + { + val q1 = entQuery(TypeParams(Nil, Nil, Nil), None, None, 2, false) + val q2 = entQuery(TypeParams(Nil, Nil, Nil), None, Some(getName(q1.last)), 2, false) + val q3 = entQuery(TypeParams(Nil, Nil, Nil), None, Some(getName(q2.last)), 100, false) + val results = q1 ++ q2 ++ q3 + + results.size should equal(all.size) + results.map(simplify).toSet should equal(all) + } + + { + val n1 = entQuery(TypeParams(Nil, Nil, Nil), None, None, 2, true) + val n2 = entQuery(TypeParams(Nil, Nil, Nil), None, Some(getName(n1.last)), 2, true) + val n3 = entQuery(TypeParams(Nil, Nil, Nil), None, Some(getName(n2.last)), 100, true) + + n1.map(simplify) should equal(Seq(p1, p2)) + n2.map(simplify) should equal(Seq(p3, p4)) + n3.map(simplify) should equal(Seq(p5, p6)) + } + + { + val n1 = entQuery(TypeParams(Nil, Nil, Nil), None, None, 2, true) + val n2 = entQuery(TypeParams(Nil, Nil, Nil), Some(getUuid(n1.last)), None, 2, true) + val n3 = entQuery(TypeParams(Nil, Nil, Nil), Some(getUuid(n2.last)), None, 100, true) + + n1.map(simplify) should equal(Seq(p1, p2)) + n2.map(simplify) should equal(Seq(p3, p4)) + n3.map(simplify) should equal(Seq(p5, p6)) + } + } + + } +} + +@RunWith(classOf[JUnitRunner]) +class FrontEndModelTest extends ServiceTestBase { + import ModelTestHelpers._ + import EntityBasedTestHelpers._ + + test("Point get") { + val ent01 = createEntity("point01", Seq("Point")) + val ent02 = createEntity("point02", Seq("Point")) + val ent03 = createEntity("point03", Seq("Point")) + val pt01 = createPoint(ent01.id, PointCategory.ANALOG, "unit1") + val pt02 = createPoint(ent02.id, PointCategory.COUNTER, "unit2") + val pt03 = createPoint(ent03.id, PointCategory.STATUS, "unit3") + + val p1 = ("point01", Set("Point"), PointCategory.ANALOG, "unit1") + val p2 = ("point02", Set("Point"), PointCategory.COUNTER, "unit2") + val p3 = ("point03", Set("Point"), PointCategory.STATUS, "unit3") + val all = Set(p1, p2, p3) + + check(all) { + SquerylFrontEndModel.pointKeyQuery(Seq(ent01.id, ent02.id, ent03.id), Nil) + } + + check(all) { + SquerylFrontEndModel.pointKeyQuery(Seq(ent01.id, ent02.id), Seq("point03")) + } + + check(Set(p1)) { + SquerylFrontEndModel.pointKeyQuery(Seq(ent01.id), Nil) + } + } + + def createPointAndEntity(name: String, types: Set[String], pointCategory: PointCategory, unit: String) = { + val ent01 = createEntity(name, types.toSeq) + val pt01 = createPoint(ent01.id, pointCategory, unit) + } + + test("Point query") { + + type TupleType = (String, Set[String], PointCategory, String) + + def toUuid(proto: Point): ModelUUID = proto.getUuid + def toName(proto: Point): String = proto.getName + + val data = Vector[(String, Set[String]) => TupleType]( + (_, _, PointCategory.ANALOG, "unit1"), + (_, _, PointCategory.COUNTER, "unit2"), + (_, _, PointCategory.STATUS, "unit3"), + (_, _, PointCategory.ANALOG, "unit2"), + (_, _, PointCategory.COUNTER, "unit3"), + (_, _, PointCategory.STATUS, "unit1")) + + val f = new EntityBasedQueryFixture[TupleType, Point]( + "point", + "Point", + data, + Function.tupled(createPointAndEntity _), + SquerylFrontEndModel.pointQuery(Nil, Nil, _, _, _, _, _), + toUuid, + toName, + simplify) + + import f._ + + entityBasedTests() + + check(Set(p1, p4)) { + SquerylFrontEndModel.pointQuery(Seq(PointCategory.ANALOG), Nil, TypeParams(Nil, Nil, Nil), None, None, 100) + } + check(Set(p1, p2, p4, p5)) { + SquerylFrontEndModel.pointQuery(Seq(PointCategory.ANALOG, PointCategory.COUNTER), Nil, TypeParams(Nil, Nil, Nil), None, None, 100) + } + + check(Set(p1, p6)) { + SquerylFrontEndModel.pointQuery(Nil, Seq("unit1"), TypeParams(Nil, Nil, Nil), None, None, 100) + } + check(Set(p1, p2, p4, p6)) { + SquerylFrontEndModel.pointQuery(Nil, Seq("unit1", "unit2"), TypeParams(Nil, Nil, Nil), None, None, 100) + } + } + + test("Point put") { + + val creates = Vector[UUID => PointRow]( + createPoint(_, PointCategory.ANALOG, "unit100"), + createPoint(_, PointCategory.ANALOG, "unit14"), + createPoint(_, PointCategory.ANALOG, "unit15"), + createPoint(_, PointCategory.ANALOG, "unit16")) + + val targetTups = Vector[(String, Set[String]) => (String, Set[String], PointCategory, String)]( + (_, _, PointCategory.ANALOG, "unit1"), + (_, _, PointCategory.COUNTER, "unit2"), + (_, _, PointCategory.STATUS, "unit3"), + (_, _, PointCategory.ANALOG, "unit14"), + (_, _, PointCategory.ANALOG, "unit15"), + (_, _, PointCategory.COUNTER, "unit66")) + + val putInfos = Vector( + PointInfo(PointCategory.ANALOG, "unit1"), + PointInfo(PointCategory.COUNTER, "unit2"), + PointInfo(PointCategory.STATUS, "unit3"), + PointInfo(PointCategory.ANALOG, "unit14"), + PointInfo(PointCategory.ANALOG, "unit15"), + PointInfo(PointCategory.COUNTER, "unit66")) + + def notifyCheck(notifier: TestModelNotifier, set: Set[(ModelEvent, (String, Set[String], PointCategory, String))]) { + notifier.checkPoints(set) + } + + genericPutTest( + "point", + "Point", + creates, + targetTups, + putInfos, + notifyCheck) { (notifier, all, puts) => + + check(all) { + SquerylFrontEndModel.putPoints(notifier, puts) + } + } + } + + test("Point put to non-point entity") { + val ent01 = createEntity("point01", Seq("AnotherType")) + + intercept[ModelInputException] { + SquerylFrontEndModel.putPoints(NullModelNotifier, Seq(CoreTypeTemplate(None, "point01", Set.empty[String], PointInfo(PointCategory.ANALOG, "unit1")))) + } + } + + test("Point type can't be added through entity service") { + EntityModelTest.bannedTypeTest("Point") + } + + test("Point put cannot remove point type") { + val notifier = new TestModelNotifier + val ent01 = createEntity("point01", Seq("Point", "AnotherType")) + val pt01 = createPoint(ent01.id, PointCategory.ANALOG, "unit100") + + val p1 = ("point01", Set("Point"), PointCategory.ANALOG, "unit1") + + val puts = Seq( + CoreTypeTemplate(None, "point01", Set.empty[String], PointInfo(PointCategory.ANALOG, "unit1"))) + + check(Set(p1)) { + SquerylFrontEndModel.putPoints(notifier, puts) + } + notifier.checkEntities(Set( + (Updated, ("point01", Set("Point"))))) + notifier.checkPoints(Set( + (Updated, p1))) + } + + test("Point delete") { + val notifier = new TestModelNotifier + val ent01 = createEntity("point01", Seq("Point")) + val ent02 = createEntity("point02", Seq("Point")) + val ent03 = createEntity("point03", Seq("Point")) + val pt01 = createPoint(ent01.id, PointCategory.ANALOG, "unit1") + val pt02 = createPoint(ent02.id, PointCategory.COUNTER, "unit2") + val pt03 = createPoint(ent03.id, PointCategory.STATUS, "unit3") + + val p1 = ("point01", Set("Point"), PointCategory.ANALOG, "unit1") + val p2 = ("point02", Set("Point"), PointCategory.COUNTER, "unit2") + val p3 = ("point03", Set("Point"), PointCategory.STATUS, "unit3") + val all = Set(p1, p2, p3) + + check(Set(p2, p3)) { + SquerylFrontEndModel.deletePoints(notifier, Seq(ent02.id, ent03.id)) + } + + val correctEnts = Set(("point01", Set("Point"))) + val allEnts = SquerylEntityModel.allQuery(None, 100).map(simplify) + allEnts.size should equal(correctEnts.size) + allEnts.toSet should equal(correctEnts) + + val allPoints = ServicesSchema.points.where(t => true === true).toSeq + allPoints.size should equal(1) + allPoints.head.id should equal(pt01.id) + + notifier.checkEntities(Set( + (Deleted, ("point02", Set("Point"))), + (Deleted, ("point03", Set("Point"))))) + notifier.checkPoints(Set( + (Deleted, p2), + (Deleted, p3))) + } + + test("Command get") { + val ent01 = createEntity("command01", Seq("Command")) + val ent02 = createEntity("command02", Seq("Command")) + val ent03 = createEntity("command03", Seq("Command")) + val cmd01 = createCommand(ent01.id, "displayCommand01", CommandCategory.SETPOINT_INT) + val cmd02 = createCommand(ent02.id, "displayCommand02", CommandCategory.SETPOINT_DOUBLE) + val cmd03 = createCommand(ent03.id, "displayCommand03", CommandCategory.CONTROL) + + val c1 = ("command01", Set("Command"), "displayCommand01", CommandCategory.SETPOINT_INT) + val c2 = ("command02", Set("Command"), "displayCommand02", CommandCategory.SETPOINT_DOUBLE) + val c3 = ("command03", Set("Command"), "displayCommand03", CommandCategory.CONTROL) + val all = Set(c1, c2, c3) + + check(all) { + SquerylFrontEndModel.commandKeyQuery(Seq(ent01.id, ent02.id, ent03.id), Nil) + } + + check(all) { + SquerylFrontEndModel.commandKeyQuery(Nil, Seq("command01", "command02", "command03")) + } + + check(all) { + SquerylFrontEndModel.commandKeyQuery(Seq(ent01.id, ent02.id), Seq("command03")) + } + + check(Set(c1, c3)) { + SquerylFrontEndModel.commandKeyQuery(Seq(ent01.id), Seq("command03")) + } + } + + def createCommandAndEntity(name: String, types: Set[String], displayName: String, commandCategory: CommandCategory) = { + val ent01 = createEntity(name, types.toSeq) + val pt01 = createCommand(ent01.id, displayName, commandCategory) + } + + test("Command query") { + + type TupleType = (String, Set[String], String, CommandCategory) + + def toUuid(proto: Command): ModelUUID = proto.getUuid + def toName(proto: Command): String = proto.getName + + val data = Vector[(String, Set[String]) => TupleType]( + (_, _, "diplay01", CommandCategory.CONTROL), + (_, _, "diplay02", CommandCategory.SETPOINT_DOUBLE), + (_, _, "diplay03", CommandCategory.SETPOINT_INT), + (_, _, "diplay04", CommandCategory.CONTROL), + (_, _, "diplay05", CommandCategory.CONTROL), + (_, _, "diplay06", CommandCategory.SETPOINT_INT)) + + val f = new EntityBasedQueryFixture[TupleType, Command]( + "command", + "Command", + data, + Function.tupled(createCommandAndEntity _), + SquerylFrontEndModel.commandQuery(Nil, _, _, _, _, _), + toUuid, + toName, + simplify) + + import f._ + + entityBasedTests() + + check(Set(p1, p4, p5)) { + SquerylFrontEndModel.commandQuery(Seq(CommandCategory.CONTROL), TypeParams(Nil, Nil, Nil), None, None, 100) + } + + check(Set(p2, p3, p6)) { + SquerylFrontEndModel.commandQuery(Seq(CommandCategory.SETPOINT_DOUBLE, CommandCategory.SETPOINT_INT), TypeParams(Nil, Nil, Nil), None, None, 100) + } + } + + test("Command put") { + + val creates = Vector[UUID => CommandRow]( + createCommand(_, "display03", CommandCategory.CONTROL), + createCommand(_, "display04", CommandCategory.CONTROL), + createCommand(_, "display05", CommandCategory.CONTROL), + createCommand(_, "display06", CommandCategory.CONTROL)) + + val targetTups = Vector[(String, Set[String]) => (String, Set[String], String, CommandCategory)]( + (_, _, "display01", CommandCategory.SETPOINT_INT), + (_, _, "display02", CommandCategory.SETPOINT_DOUBLE), + (_, _, "display03", CommandCategory.SETPOINT_STRING), + (_, _, "display04", CommandCategory.CONTROL), + (_, _, "display05", CommandCategory.CONTROL), + (_, _, "display66", CommandCategory.SETPOINT_DOUBLE)) + + val putInfos = Vector( + CommandInfo("display01", CommandCategory.SETPOINT_INT), + CommandInfo("display02", CommandCategory.SETPOINT_DOUBLE), + CommandInfo("display03", CommandCategory.SETPOINT_STRING), + CommandInfo("display04", CommandCategory.CONTROL), + CommandInfo("display05", CommandCategory.CONTROL), + CommandInfo("display66", CommandCategory.SETPOINT_DOUBLE)) + + def notifyCheck(notifier: TestModelNotifier, set: Set[(ModelEvent, (String, Set[String], String, CommandCategory))]) { + notifier.checkCommands(set) + } + + genericPutTest( + "command", + "Command", + creates, + targetTups, + putInfos, + notifyCheck) { (notifier, all, puts) => + + check(all) { + SquerylFrontEndModel.putCommands(notifier, puts) + } + } + } + + test("Command type can't be added through entity service") { + EntityModelTest.bannedTypeTest("Command") + } + + test("Command delete") { + val notifier = new TestModelNotifier + val ent01 = createEntity("command01", Seq("Command")) + val ent02 = createEntity("command02", Seq("Command")) + val ent03 = createEntity("command03", Seq("Command")) + val cmd01 = createCommand(ent01.id, "displayCommand01", CommandCategory.SETPOINT_INT) + val cmd02 = createCommand(ent02.id, "displayCommand02", CommandCategory.SETPOINT_DOUBLE) + val cmd03 = createCommand(ent03.id, "displayCommand03", CommandCategory.CONTROL) + + val c1 = ("command01", Set("Command"), "displayCommand01", CommandCategory.SETPOINT_INT) + val c2 = ("command02", Set("Command"), "displayCommand02", CommandCategory.SETPOINT_DOUBLE) + val c3 = ("command03", Set("Command"), "displayCommand03", CommandCategory.CONTROL) + val all = Set(c1, c2, c3) + + check(Set(c2, c3)) { + SquerylFrontEndModel.deleteCommands(notifier, Seq(ent02.id, ent03.id)) + } + + val correctEnts = Set(("command01", Set("Command"))) + val allEnts = SquerylEntityModel.allQuery(None, 100).map(simplify) + allEnts.size should equal(correctEnts.size) + allEnts.toSet should equal(correctEnts) + + val allPoints = ServicesSchema.commands.where(t => true === true).toSeq + allPoints.size should equal(1) + allPoints.head.id should equal(cmd01.id) + + notifier.checkEntities(Set( + (Deleted, ("command02", Set("Command"))), + (Deleted, ("command03", Set("Command"))))) + notifier.checkCommands(Set( + (Deleted, c2), + (Deleted, c3))) + } + + test("Endpoint get") { + val ent01 = createEntity("endpoint01", Seq("Endpoint")) + val ent02 = createEntity("endpoint02", Seq("Endpoint")) + val ent03 = createEntity("endpoint03", Seq("Endpoint")) + val end01 = createEndpoint(ent01.id, "protocol01", false) + val end02 = createEndpoint(ent02.id, "protocol02", false) + val end03 = createEndpoint(ent03.id, "protocol03", false) + + val c1 = ("endpoint01", Set("Endpoint"), "protocol01", false) + val c2 = ("endpoint02", Set("Endpoint"), "protocol02", false) + val c3 = ("endpoint03", Set("Endpoint"), "protocol03", false) + val all = Set(c1, c2, c3) + + check(all) { + SquerylFrontEndModel.endpointKeyQuery(Seq(ent01.id, ent02.id, ent03.id), Nil) + } + + check(all) { + SquerylFrontEndModel.endpointKeyQuery(Nil, Seq("endpoint01", "endpoint02", "endpoint03")) + } + + check(all) { + SquerylFrontEndModel.endpointKeyQuery(Seq(ent01.id, ent02.id), Seq("endpoint03")) + } + + check(Set(c1, c3)) { + SquerylFrontEndModel.endpointKeyQuery(Seq(ent01.id), Seq("endpoint03")) + } + } + + private def createEntAndEndpoint(name: String, protocol: String): EntityRow = { + val ent = createEntity(name, Seq("Endpoint")) + createEndpoint(ent.id, protocol, false) + ent + } + + def createEndpointAndEntity(name: String, types: Set[String], protocol: String, disabled: Boolean) = { + val ent01 = createEntity(name, types.toSeq) + val pt01 = createEndpoint(ent01.id, protocol, disabled) + } + + test("Endpoint query") { + + type TupleType = (String, Set[String], String, Boolean) + + def toUuid(proto: Endpoint): ModelUUID = proto.getUuid + def toName(proto: Endpoint): String = proto.getName + + val data = Vector[(String, Set[String]) => TupleType]( + (_, _, "protocolA", false), + (_, _, "protocolB", true), + (_, _, "protocolC", false), + (_, _, "protocolC", false), + (_, _, "protocolA", true), + (_, _, "protocolB", true)) + + val f = new EntityBasedQueryFixture[TupleType, Endpoint]( + "endpoint", + "Endpoint", + data, + Function.tupled(createEndpointAndEntity _), + SquerylFrontEndModel.endpointQuery(Nil, None, _, _, _, _, _), + toUuid, + toName, + simplify) + + import f._ + + entityBasedTests() + + check(Set(p1, p5)) { + SquerylFrontEndModel.endpointQuery(Seq("protocolA"), None, TypeParams(Nil, Nil, Nil), None, None, 100) + } + check(Set(p1, p2, p5, p6)) { + SquerylFrontEndModel.endpointQuery(Seq("protocolA", "protocolB"), None, TypeParams(Nil, Nil, Nil), None, None, 100) + } + + check(Set(p1, p3, p4)) { + SquerylFrontEndModel.endpointQuery(Nil, Some(false), TypeParams(Nil, Nil, Nil), None, None, 100) + } + check(Set(p5)) { + SquerylFrontEndModel.endpointQuery(Seq("protocolA"), Some(true), TypeParams(Nil, Nil, Nil), None, None, 100) + } + } + + test("Endpoint put") { + + val creates = Vector[UUID => EndpointRow]( + createEndpoint(_, "protocol100", false), + createEndpoint(_, "protocol04", false), + createEndpoint(_, "protocol05", false), + createEndpoint(_, "protocol06", false)) + + val targetTups = Vector[(String, Set[String]) => (String, Set[String], String, Boolean)]( + (_, _, "protocol01", false), + (_, _, "protocol02", false), + (_, _, "protocol03", false), + (_, _, "protocol04", false), + (_, _, "protocol55", false), + (_, _, "protocol66", false)) + + val putInfos = Vector( + EndpointInfo("protocol01", None), + EndpointInfo("protocol02", None), + EndpointInfo("protocol03", None), + EndpointInfo("protocol04", None), + EndpointInfo("protocol55", None), + EndpointInfo("protocol66", None)) + + def notifyCheck(notifier: TestModelNotifier, set: Set[(ModelEvent, (String, Set[String], String, Boolean))]) { + notifier.checkEndpoints(set) + } + + genericPutTest( + "endpoint", + "Endpoint", + creates, + targetTups, + putInfos, + notifyCheck) { (notifier, all, puts) => + + check(all) { + SquerylFrontEndModel.putEndpoints(notifier, puts) + } + } + } + + test("Endpoint type can't be added through entity service") { + EntityModelTest.bannedTypeTest("Endpoint") + } + + test("Endpoint delete") { + val notifier = new TestModelNotifier + val ent01 = createEntity("endpoint01", Seq("Endpoint")) + val ent02 = createEntity("endpoint02", Seq("Endpoint")) + val ent03 = createEntity("endpoint03", Seq("Endpoint")) + val cmd01 = createEndpoint(ent01.id, "protocol01", false) + val cmd02 = createEndpoint(ent02.id, "protocol02", false) + val cmd03 = createEndpoint(ent03.id, "protocol03", false) + + val c1 = ("endpoint01", Set("Endpoint"), "protocol01", false) + val c2 = ("endpoint02", Set("Endpoint"), "protocol02", false) + val c3 = ("endpoint03", Set("Endpoint"), "protocol03", false) + val all = Set(c1, c2, c3) + + check(Set(c2, c3)) { + SquerylFrontEndModel.deleteEndpoints(notifier, Seq(ent02.id, ent03.id)) + } + + val correctEnts = Set(("endpoint01", Set("Endpoint"))) + val allEnts = SquerylEntityModel.allQuery(None, 100).map(simplify) + allEnts.size should equal(correctEnts.size) + allEnts.toSet should equal(correctEnts) + + val allPoints = ServicesSchema.endpoints.where(t => true === true).toSeq + allPoints.size should equal(1) + allPoints.head.id should equal(cmd01.id) + + notifier.checkEntities(Set( + (Deleted, ("endpoint02", Set("Endpoint"))), + (Deleted, ("endpoint03", Set("Endpoint"))))) + notifier.checkEndpoints(Set( + (Deleted, c2), + (Deleted, c3))) + } + + test("Endpoint put disabled") { + val notifier = new TestModelNotifier + val ent01 = createEntity("endpoint01", Seq("Endpoint")) + val ent02 = createEntity("endpoint02", Seq("Endpoint")) + val ent03 = createEntity("endpoint03", Seq("Endpoint")) + val cmd01 = createEndpoint(ent01.id, "protocol01", false) + val cmd02 = createEndpoint(ent02.id, "protocol02", true) + val cmd03 = createEndpoint(ent03.id, "protocol03", false) + + val c1 = ("endpoint01", Set("Endpoint"), "protocol01", true) + val c2 = ("endpoint02", Set("Endpoint"), "protocol02", false) + val c3 = ("endpoint03", Set("Endpoint"), "protocol03", false) + val all = Set(c1, c2, c3) + + val puts = Seq(EndpointDisabledUpdate(ent01.id, true), EndpointDisabledUpdate(ent02.id, false), EndpointDisabledUpdate(ent03.id, false)) + + check(all) { + SquerylFrontEndModel.putEndpointsDisabled(notifier, puts) + } + + notifier.checkEndpoints(Set( + (Updated, c1), + (Updated, c2))) + } + + test("Comm status get (empty)") { + val ent01 = createEntAndEndpoint("endpoint01", "protocol01") + val ent02 = createEntAndEndpoint("endpoint02", "protocol01") + + check(Set.empty[(String, FrontEndConnectionStatus.Status)]) { + SquerylFrontEndModel.getFrontEndConnectionStatuses(List(ent01.id, ent02.id), Nil) + } + } + + test("Comm status get") { + val ent01 = createEntAndEndpoint("endpoint01", "protocol01") + val ent02 = createEntAndEndpoint("endpoint02", "protocol01") + createConnectionStatus(ent01.id, FrontEndConnectionStatus.Status.COMMS_DOWN, 1) + createConnectionStatus(ent02.id, FrontEndConnectionStatus.Status.COMMS_UP, 2) + + val m1 = ("endpoint01", FrontEndConnectionStatus.Status.COMMS_DOWN) + val m2 = ("endpoint02", FrontEndConnectionStatus.Status.COMMS_UP) + val all = Set(m1, m2) + + check(all) { + SquerylFrontEndModel.getFrontEndConnectionStatuses(List(ent01.id, ent02.id), Nil) + } + } + + test("Comm status put (empty)") { + val notifier = new TestModelNotifier + val ent01 = createEntAndEndpoint("endpoint01", "protocol01") + val ent02 = createEntAndEndpoint("endpoint02", "protocol01") + + val puts = List( + (ent01.id, FrontEndConnectionStatus.Status.COMMS_DOWN), + (ent02.id, FrontEndConnectionStatus.Status.COMMS_UP)) + + val m1 = ("endpoint01", FrontEndConnectionStatus.Status.COMMS_DOWN) + val m2 = ("endpoint02", FrontEndConnectionStatus.Status.COMMS_UP) + val all = Set(m1, m2) + + check(all) { + SquerylFrontEndModel.putFrontEndConnectionStatuses(notifier, puts) + } + + notifier.checkConnectionStatus( + Set((Updated, m1), + (Updated, m2))) + } + + test("Comm status put") { + val notifier = new TestModelNotifier + val ent01 = createEntAndEndpoint("endpoint01", "protocol01") + val ent02 = createEntAndEndpoint("endpoint02", "protocol01") + createConnectionStatus(ent01.id, FrontEndConnectionStatus.Status.COMMS_DOWN, 1) + createConnectionStatus(ent02.id, FrontEndConnectionStatus.Status.COMMS_UP, 2) + + val puts = List( + (ent01.id, FrontEndConnectionStatus.Status.COMMS_UP), + (ent02.id, FrontEndConnectionStatus.Status.ERROR)) + + val m1 = ("endpoint01", FrontEndConnectionStatus.Status.COMMS_UP) + val m2 = ("endpoint02", FrontEndConnectionStatus.Status.ERROR) + val all = Set(m1, m2) + + check(all) { + SquerylFrontEndModel.putFrontEndConnectionStatuses(notifier, puts) + } + + notifier.checkConnectionStatus( + Set((Updated, m1), + (Updated, m2))) + } +} diff --git a/services/src/test/scala/io/greenbus/services/model/ModelTestHelpers.scala b/services/src/test/scala/io/greenbus/services/model/ModelTestHelpers.scala new file mode 100644 index 0000000..336e2bc --- /dev/null +++ b/services/src/test/scala/io/greenbus/services/model/ModelTestHelpers.scala @@ -0,0 +1,331 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.model + +import io.greenbus.client.service.proto.FrontEnd.FrontEndConnectionStatus +import io.greenbus.services.core.EntityKeyValueWithEndpoint +import io.greenbus.services.data._ +import java.util.UUID +import io.greenbus.client.service.proto.Auth.{ Permission, Agent, PermissionSet } +import UUIDHelpers._ +import io.greenbus.client.service.proto.Model.{ EntityEdge, Entity } +import scala.collection.JavaConversions._ +import org.scalatest.matchers.ShouldMatchers +import io.greenbus.services.framework.{ ModelNotifier, ModelEvent } +import scala.collection.mutable +import io.greenbus.client.service.proto.Model._ +import io.greenbus.client.service.proto.Commands.CommandLock +import io.greenbus.client.service.proto.Events.{ Event, Alarm, EventConfig } + +object ModelTestHelpers extends ShouldMatchers { + + def createEntity(name: String, types: Seq[String]): EntityRow = { + val ent = ServicesSchema.entities.insert(EntityRow(UUID.randomUUID(), name)) + types.foreach { typ => + ServicesSchema.entityTypes.insert(EntityTypeRow(ent.id, typ)) + } + ent + } + + def createEdge(parentId: UUID, childId: UUID, relation: String, depth: Int): EntityEdgeRow = { + ServicesSchema.edges.insert(EntityEdgeRow(0, parentId, childId, relation, depth)) + } + + def createEntityKeyValue(entityId: UUID, key: String, value: Array[Byte]): EntityKeyStoreRow = { + ServicesSchema.entityKeyValues.insert(EntityKeyStoreRow(0, entityId, key, value)) + } + + def createPoint(entityId: UUID, typ: PointCategory, unit: String): PointRow = { + ServicesSchema.points.insert(PointRow(0, entityId, typ.getNumber, unit)) + } + + def createCommand(entityId: UUID, displayName: String, typ: CommandCategory): CommandRow = { + ServicesSchema.commands.insert(CommandRow(0, entityId, displayName, typ.getNumber, None)) + } + + def createEndpoint(entityId: UUID, protocol: String, disabled: Boolean): EndpointRow = { + ServicesSchema.endpoints.insert(EndpointRow(0, entityId, protocol, disabled)) + } + + def createAgent(name: String, password: String = "password", perms: Set[Long] = Set.empty[Long]): AgentRow = { + val a = ServicesSchema.agents.insert(AgentRow(UUID.randomUUID(), name, password)) + perms.foreach(addPermissionToAgent(_, a.id)) + a + } + + def createEventConfig(typ: String, severity: Int, designation: EventConfig.Designation, startState: Alarm.State, resource: String, builtIn: Boolean = false) = { + ServicesSchema.eventConfigs.insert(EventConfigRow(0, typ, severity, designation.getNumber, startState.getNumber, resource, builtIn)) + } + + def createEvent(eventType: String, + alarm: Boolean, + time: Long, + deviceTime: Option[Long], + severity: Int, + subsystem: String, + userId: String, + entityId: Option[UUID], + modelGroup: Option[String], + args: Array[Byte], + rendered: String) = { + + ServicesSchema.events.insert(EventRow(0, eventType, alarm, time, deviceTime, severity, subsystem, userId, entityId, modelGroup, args, rendered)) + } + + def createAlarm(eventId: Long, state: Alarm.State) = { + ServicesSchema.alarms.insert(AlarmRow(0, state.getNumber, eventId)) + } + + def createConnectionStatus(endpointId: UUID, status: FrontEndConnectionStatus.Status, time: Long) = { + ServicesSchema.frontEndCommStatuses.insert(FrontEndCommStatusRow(0, endpointId, status.getNumber, time)) + } + + def addPermissionToAgent(setId: Long, agentId: UUID) { + ServicesSchema.agentSetJoins.insert(AgentPermissionSetJoinRow(setId, agentId)) + } + + def createPermission(resource: String, action: String): Permission = { + Permission.newBuilder() + .addResources(resource) + .addActions(action) + .build() + } + + def createPermissionSet(name: String, perms: Seq[Permission] = Seq.empty[Permission]): PermissionSetRow = { + val b = PermissionSet.newBuilder + .setName(name) + + perms.foreach(b.addPermissions) + + val set = b.build() + + ServicesSchema.permissionSets.insert(PermissionSetRow(0, name, set.toByteArray)) + } + + def check[A, B](correct: Set[B])(query: => Seq[A])(implicit mapping: A => B) { + val simplified = query.map(mapping) + if (simplified.toSet != correct) { + println("Correct: " + correct.mkString("(\n\t", "\n\t", "\n)")) + println("Result: " + simplified.toSet.mkString("(\n\t", "\n\t", "\n)")) + } + simplified.toSet should equal(correct) + simplified.size should equal(correct.size) + } + + def checkOrder[A, B](correct: Seq[B])(query: => Seq[A])(implicit mapping: A => B) { + val simplified = query.map(mapping).toList + simplified.toList should equal(correct) + simplified.size should equal(correct.size) + } + + implicit def simplify(proto: Entity): (String, Set[String]) = { + (proto.getName, proto.getTypesList.toSet) + } + + implicit def simplify(proto: EntityKeyValue): (UUID, String, String) = { + (proto.getUuid, proto.getKey, proto.getValue.getStringValue) + } + + implicit def simplify(v: EntityKeyValueWithEndpoint): (UUID, String, String) = { + (v.payload.getUuid, v.payload.getKey, v.payload.getValue.getStringValue) + } + + implicit def simplify(proto: EntityEdge): (UUID, UUID, String, Int) = { + (proto.getParent, proto.getChild, proto.getRelationship, proto.getDistance) + } + + implicit def simplify(proto: Point): (String, Set[String], PointCategory, String) = { + (proto.getName, proto.getTypesList.toSet, proto.getPointCategory, proto.getUnit) + } + + implicit def simplify(proto: Command): (String, Set[String], String, CommandCategory) = { + (proto.getName, proto.getTypesList.toSet, proto.getDisplayName, proto.getCommandCategory) + } + + implicit def simplify(proto: Endpoint): (String, Set[String], String, Boolean) = { + (proto.getName, proto.getTypesList.toSet, proto.getProtocol, proto.getDisabled) + } + + implicit def simplify(proto: Agent): (String, Set[String]) = { + (proto.getName, proto.getPermissionSetsList.toSet) + } + + implicit def simplify(proto: PermissionSet): (String, Set[(Set[String], Set[String])]) = { + (proto.getName, proto.getPermissionsList.map(p => (p.getResourcesList.toSet, p.getActionsList.toSet)).toSet) + } + + implicit def simplify(proto: CommandLock): (CommandLock.AccessMode, Set[UUID], UUID, Option[Long]) = { + (proto.getAccess, proto.getCommandUuidsList.map(protoUUIDToUuid).toSet, proto.getAgentUuid, if (proto.hasExpireTime) Some(proto.getExpireTime) else None) + } + + implicit def simplify(proto: EventConfig): (String, Int, EventConfig.Designation, Alarm.State, String, Boolean) = { + (proto.getEventType, proto.getSeverity, proto.getDesignation, proto.getAlarmState, proto.getResource, proto.getBuiltIn) + } + + implicit def simplify(proto: Event): (String, Int, String, Boolean, String, String, Option[UUID], Option[String], Option[Long]) = { + (proto.getEventType, + proto.getSeverity, + proto.getAgentName, + proto.getAlarm, + proto.getSubsystem, + proto.getRendered, + if (proto.hasEntityUuid) Some(proto.getEntityUuid) else None, + if (proto.hasModelGroup) Some(proto.getModelGroup) else None, + if (proto.hasDeviceTime) Some(proto.getDeviceTime) else None) + } + + implicit def simplifyWithTime(proto: Event): (String, Int, Long, String, Boolean, String, String, Option[UUID], Option[String], Option[Long]) = { + (proto.getEventType, + proto.getSeverity, + proto.getTime, + proto.getAgentName, + proto.getAlarm, + proto.getSubsystem, + proto.getRendered, + if (proto.hasEntityUuid) Some(proto.getEntityUuid) else None, + if (proto.hasModelGroup) Some(proto.getModelGroup) else None, + if (proto.hasDeviceTime) Some(proto.getDeviceTime) else None) + } + + implicit def simplify(proto: Alarm): (Long, Alarm.State) = { + (proto.getEvent.getId.getValue.toLong, proto.getState) + } + + implicit def simplifyAlarmFull(proto: Alarm): (Alarm.State, String, Int, String, Boolean, String, String, Option[UUID], Option[Long]) = { + val eventProto = proto.getEvent + (proto.getState, + eventProto.getEventType, + eventProto.getSeverity, + eventProto.getAgentName, + eventProto.getAlarm, + eventProto.getSubsystem, + eventProto.getRendered, + if (eventProto.hasEntityUuid) Some(eventProto.getEntityUuid) else None, + if (eventProto.hasDeviceTime) Some(eventProto.getDeviceTime) else None) + } + + implicit def simplify(proto: FrontEndConnectionStatus): (String, FrontEndConnectionStatus.Status) = { + (proto.getEndpointName, proto.getState) + } + + class TestModelNotifier extends ModelNotifier with ShouldMatchers { + private val map = mutable.Map.empty[Class[_], mutable.Queue[(ModelEvent, _)]] + + def notify[A](typ: ModelEvent, event: A) { + val klass = event.getClass + map.get(klass) match { + case None => + val q = mutable.Queue.empty[(ModelEvent, _)] + q += ((typ, event)) + map.update(klass, q) + case Some(queue) => + queue += ((typ, event)) + } + } + + def getQueue[A](klass: Class[A]): mutable.Queue[(ModelEvent, A)] = { + map.get(klass).map(_.asInstanceOf[mutable.Queue[(ModelEvent, A)]]).getOrElse(mutable.Queue.empty[(ModelEvent, A)]) + } + + /*def check[A, B](klass: Class[A], correct: Set[(ModelEvent, B)])(implicit mapping: A => B) { + val results = getQueue(klass).toSeq.map(tup => (tup._1, mapping(tup._2))) + setCheck(results, correct) + }*/ + + def checkEntities(correct: Set[(ModelEvent, (String, Set[String]))]) { + val results = getQueue(classOf[Entity]).toSeq.map(tup => (tup._1, simplify(tup._2))) + setCheck(results, correct) + } + + def checkEntityEdges(correct: Set[(ModelEvent, (UUID, UUID, String, Int))]) { + val results = getQueue(classOf[EntityEdge]).toSeq.map(tup => (tup._1, simplify(tup._2))) + setCheck(results, correct) + } + + def checkEntityKeyValues(correct: Set[(ModelEvent, (UUID, String, String))]): Unit = { + val results = getQueue(classOf[EntityKeyValueWithEndpoint]).toSeq.map(tup => (tup._1, simplify(tup._2))) + setCheck(results, correct) + } + + def checkPoints(correct: Set[(ModelEvent, (String, Set[String], PointCategory, String))]) { + val results = getQueue(classOf[Point]).toSeq.map(tup => (tup._1, simplify(tup._2))) + setCheck(results, correct) + } + + def checkCommands(correct: Set[(ModelEvent, (String, Set[String], String, CommandCategory))]) { + val results = getQueue(classOf[Command]).toSeq.map(tup => (tup._1, simplify(tup._2))) + setCheck(results, correct) + } + + def checkCommandLocks(correct: Set[(ModelEvent, (CommandLock.AccessMode, Set[UUID], UUID, Option[Long]))]) { + val results = getQueue(classOf[CommandLock]).toSeq.map(tup => (tup._1, simplify(tup._2))) + setCheck(results, correct) + } + + def checkEndpoints(correct: Set[(ModelEvent, (String, Set[String], String, Boolean))]) { + val results = getQueue(classOf[Endpoint]).toSeq.map(tup => (tup._1, simplify(tup._2))) + setCheck(results, correct) + } + + def checkAgents(correct: Set[(ModelEvent, (String, Set[String]))]) { + val results = getQueue(classOf[Agent]).toSeq.map(tup => (tup._1, simplify(tup._2))) + setCheck(results, correct) + } + + def checkPermissionSetNames(correct: Set[(ModelEvent, String)]) { + val results = getQueue(classOf[PermissionSet]).toSeq.map(tup => (tup._1, tup._2.getName)) + setCheck(results, correct) + } + def checkPermissionSets(correct: Set[(ModelEvent, (String, Set[(Set[String], Set[String])]))]) { + val results = getQueue(classOf[PermissionSet]).toSeq.map(tup => (tup._1, simplify(tup._2))) + setCheck(results, correct) + } + + def checkEventConfigs(correct: Set[(ModelEvent, (String, Int, EventConfig.Designation, Alarm.State, String, Boolean))]) { + val results = getQueue(classOf[EventConfig]).toSeq.map(tup => (tup._1, simplify(tup._2))) + setCheck(results, correct) + } + + def checkEvents(correct: Set[(ModelEvent, (String, Int, String, Boolean, String, String, Option[UUID], Option[String], Option[Long]))]) { + val results = getQueue(classOf[Event]).toSeq.map(tup => (tup._1, simplify(tup._2))) + setCheck(results, correct) + } + + def checkAlarms(correct: Set[(ModelEvent, (Alarm.State, String, Int, String, Boolean, String, String, Option[UUID], Option[Long]))]) { + val results = getQueue(classOf[Alarm]).toSeq.map(tup => (tup._1, simplifyAlarmFull(tup._2))) + setCheck(results, correct) + } + + def checkAlarmsSimple(correct: Set[(ModelEvent, (Long, Alarm.State))]) { + val results = getQueue(classOf[Alarm]).toSeq.map(tup => (tup._1, simplify(tup._2))) + setCheck(results, correct) + } + + def checkConnectionStatus(correct: Set[(ModelEvent, (String, FrontEndConnectionStatus.Status))]) { + val results = getQueue(classOf[FrontEndConnectionStatus]).toSeq.map(tup => (tup._1, simplify(tup._2))) + setCheck(results, correct) + } + + private def setCheck[A](result: Seq[A], correct: Set[A]) { + result.size should equal(correct.size) + result.toSet should equal(correct) + } + } + +} diff --git a/services/src/test/scala/io/greenbus/services/model/ServiceTestBase.scala b/services/src/test/scala/io/greenbus/services/model/ServiceTestBase.scala new file mode 100644 index 0000000..d6443d3 --- /dev/null +++ b/services/src/test/scala/io/greenbus/services/model/ServiceTestBase.scala @@ -0,0 +1,26 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.services.model + +import io.greenbus.sql.test.{ RunTestsInsideTransaction, DatabaseUsingTestBase } +import io.greenbus.services.data.ServicesSchema + +trait ServiceTestBase extends DatabaseUsingTestBase with RunTestsInsideTransaction { + def schemas = List(ServicesSchema) +} diff --git a/simulator/pom.xml b/simulator/pom.xml new file mode 100755 index 0000000..e413f02 --- /dev/null +++ b/simulator/pom.xml @@ -0,0 +1,82 @@ + + 4.0.0 + + + io.greenbus + greenbus-scala-base + 3.0.0 + ../scala-base + + + greenbus-simulator + jar + + + + AGPLv3 + http://www.gnu.org/licenses/agpl-3.0.txt + + + + + + + + com.mycila.maven-license-plugin + maven-license-plugin + +
../AGPL_FILE_HEADER
+
+
+
+
+ + + + typesafe + typesafe-releases + http://repo.typesafe.com/typesafe/maven-releases + + + + + + io.greenbus + greenbus-client + 3.0.0 + + + io.greenbus + greenbus-app-framework + 3.0.0 + + + io.greenbus + greenbus-util + 3.0.0 + + + io.greenbus.msg + greenbus-msg-qpid + 1.0.0 + + + com.typesafe.play + play-json_2.10 + 2.3.2 + + + com.typesafe.akka + akka-actor_2.10 + 2.2.0 + + + com.typesafe.akka + akka-slf4j_2.10 + 2.2.0 + + + + +
diff --git a/simulator/src/main/scala/io/greenbus/sim/EndpointSimulation.scala b/simulator/src/main/scala/io/greenbus/sim/EndpointSimulation.scala new file mode 100644 index 0000000..091432d --- /dev/null +++ b/simulator/src/main/scala/io/greenbus/sim/EndpointSimulation.scala @@ -0,0 +1,67 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.sim + +import io.greenbus.client.service.proto.Measurements.{ Quality, Measurement } +import io.greenbus.sim.impl.RandomValue +import scala.annotation.tailrec + +object EndpointSimulation { + + case class MeasRecord(name: String, value: RandomValue) { + def measurement: (String, Measurement) = { + val meas = Measurement.newBuilder + .setQuality(Quality.newBuilder.build) + + value.apply(meas) + + (name, meas.build()) + } + } +} + +class EndpointSimulation(config: Seq[MeasurementSimConfig]) { + import EndpointSimulation._ + + private var currentValues: List[MeasRecord] = config.map { measConfig => + MeasRecord(measConfig.name, RandomValue(measConfig)) + }.toList + + def tick(): Seq[(String, Measurement)] = { + val (nextValues, toPublish) = updated(currentValues, Nil, Nil) + currentValues = nextValues + toPublish + } + + @tailrec + private def updated(current: List[MeasRecord], after: List[MeasRecord], results: List[(String, Measurement)]): (List[MeasRecord], List[(String, Measurement)]) = { + current match { + case Nil => (after.reverse, results.reverse) + case head :: tail => { + head.value.next() match { + case None => updated(tail, head :: after, results) + case Some(next) => + val recordAfter = head.copy(value = next) + updated(tail, recordAfter :: after, recordAfter.measurement :: results) + } + } + } + } + +} diff --git a/simulator/src/main/scala/io/greenbus/sim/SimulatedEndpoint.scala b/simulator/src/main/scala/io/greenbus/sim/SimulatedEndpoint.scala new file mode 100644 index 0000000..83d1e75 --- /dev/null +++ b/simulator/src/main/scala/io/greenbus/sim/SimulatedEndpoint.scala @@ -0,0 +1,104 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.sim + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.client.service.proto.FrontEnd.FrontEndConnectionStatus +import akka.actor.{ Actor, Props } +import play.api.libs.json.Json +import scala.concurrent.duration._ +import scala.collection.JavaConversions._ +import io.greenbus.client.service.proto.Commands.{ CommandStatus, CommandResult } +import io.greenbus.client.service.proto.Model.{ EntityKeyValue, ModelUUID, Endpoint } +import io.greenbus.app.actor.frontend.{ StackStatusUpdated, MeasurementsPublished, ProtocolConfigurer } +import scala.concurrent.Future + +object SimulatedEndpoint extends ProtocolConfigurer[SimulatorEndpointConfig] with Logging { + + def extractConfig(config: EntityKeyValue): Option[SimulatorEndpointConfig] = { + if (config.getValue.hasByteArrayValue) { + try { + val json = Json.parse(config.getValue.getByteArrayValue.toByteArray).as[SimulatorEndpointConfig] + Some(json) + } catch { + case ex: Throwable => + logger.warn("Couldn't unmarshal configuration: " + ex) + None + } + } else { + None + } + } + + def evaluate(endpoint: Endpoint, configFiles: Seq[EntityKeyValue]): Option[SimulatorEndpointConfig] = { + configFiles.find(kv => kv.getKey == "protocolConfig").flatMap(extractConfig) + } + + def equivalent(latest: SimulatorEndpointConfig, previous: SimulatorEndpointConfig): Boolean = { + latest == previous + } + + case object Tick + + def props(endpointUuid: ModelUUID, endpointName: String, protocolConfig: SimulatorEndpointConfig, publish: (MeasurementsPublished) => Unit, statusUpdate: (StackStatusUpdated) => Unit): Props = { + Props(classOf[SimulatedEndpoint], endpointUuid, endpointName, protocolConfig, publish, statusUpdate) + } + +} + +class SimulatedEndpoint(endpointUuid: ModelUUID, endpointName: String, config: SimulatorEndpointConfig, publish: (MeasurementsPublished) => Unit, statusUpdate: (StackStatusUpdated) => Unit) extends Actor with Logging { + import SimulatedEndpoint._ + import io.greenbus.app.actor.frontend.ActorProtocolManager.CommandIssued + + private val delay = config.delay.map(_.milliseconds).getOrElse(2000.milliseconds) + private val simulation = new EndpointSimulation(config.measurements) + + logger.info("Initializing simulated endpoint " + endpointName + ", point count: " + config.measurements.size + ", with period " + delay) + + import context.dispatcher + + statusUpdate(StackStatusUpdated(FrontEndConnectionStatus.Status.COMMS_UP)) + self ! Tick + + def receive = { + + case Tick => { + val measurements = simulation.tick() + if (measurements.nonEmpty) { + publish(MeasurementsPublished(System.currentTimeMillis(), Seq(), measurements)) + } + scheduleNext() + } + + case CommandIssued(cmdName, cmdReq) => { + val cmdId = cmdReq.getCommandUuid + + logger.info("Command request for " + cmdId.getValue) + + sender ! Future.successful(CommandResult.newBuilder().setStatus(CommandStatus.NOT_SUPPORTED).build()) + } + } + + private def scheduleNext() { + context.system.scheduler.scheduleOnce( + delay, + self, + Tick) + } +} \ No newline at end of file diff --git a/simulator/src/main/scala/io/greenbus/sim/Simulator.scala b/simulator/src/main/scala/io/greenbus/sim/Simulator.scala new file mode 100644 index 0000000..cf2f993 --- /dev/null +++ b/simulator/src/main/scala/io/greenbus/sim/Simulator.scala @@ -0,0 +1,53 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.sim + +import java.util.UUID + +import akka.actor.{ ActorContext, ActorSystem } +import com.typesafe.config.ConfigFactory +import io.greenbus.app.actor.{ AmqpConnectionConfig, ProtocolsEndpointStrategy } +import io.greenbus.app.actor.frontend.{ FrontendRegistrationConfig, FrontendRegistrationConfig$, ActorProtocolManager, FrontendFactory } + +object Simulator { + + def main(args: Array[String]) { + + val baseDir = Option(System.getProperty("io.greenbus.config.base")).getOrElse("") + val amqpConfigPath = Option(System.getProperty("io.greenbus.config.amqp")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.msg.amqp.cfg") + val userConfigPath = Option(System.getProperty("io.greenbus.config.user")).map(baseDir + _).getOrElse(baseDir + "io.greenbus.user.cfg") + + val rootConfig = ConfigFactory.load() + val slf4jConfig = ConfigFactory.parseString("""akka { loggers = ["akka.event.slf4j.Slf4jLogger"] }""") + val akkaConfig = slf4jConfig.withFallback(rootConfig) + val system = ActorSystem("simulator", akkaConfig) + + val endpointStrategy = new ProtocolsEndpointStrategy(Set("simulator", "benchmark")) + + def protocolFactory(context: ActorContext) = new ActorProtocolManager[SimulatorEndpointConfig](context, SimulatedEndpoint.props) + + system.actorOf(FrontendFactory.create( + AmqpConnectionConfig.default(amqpConfigPath), userConfigPath, endpointStrategy, protocolFactory, SimulatedEndpoint, Seq("protocolConfig"), + connectionRepresentsLock = false, + nodeId = UUID.randomUUID().toString, + FrontendRegistrationConfig.defaults)) + } + +} + diff --git a/simulator/src/main/scala/io/greenbus/sim/SimulatorEndpointConfig.scala b/simulator/src/main/scala/io/greenbus/sim/SimulatorEndpointConfig.scala new file mode 100644 index 0000000..e44a886 --- /dev/null +++ b/simulator/src/main/scala/io/greenbus/sim/SimulatorEndpointConfig.scala @@ -0,0 +1,52 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.sim + +object StatusSimConfig { + import play.api.libs.json._ + implicit val writer = Json.writes[StatusSimConfig] + implicit val reader = Json.reads[StatusSimConfig] +} +case class StatusSimConfig(initialValue: Boolean) + +object AnalogSimConfig { + import play.api.libs.json._ + implicit val writer = Json.writes[AnalogSimConfig] + implicit val reader = Json.reads[AnalogSimConfig] +} +case class AnalogSimConfig(initialValue: Double, min: Double, max: Double, maxDelta: Double) + +object MeasurementSimConfig { + import play.api.libs.json._ + implicit val writer = Json.writes[MeasurementSimConfig] + implicit val reader = Json.reads[MeasurementSimConfig] +} +case class MeasurementSimConfig( + name: String, + measurementType: String, + changeProbability: Option[Double], + statusConfig: Option[StatusSimConfig], + analogConfig: Option[AnalogSimConfig]) + +object SimulatorEndpointConfig { + import play.api.libs.json._ + implicit val writer = Json.writes[SimulatorEndpointConfig] + implicit val reader = Json.reads[SimulatorEndpointConfig] +} +case class SimulatorEndpointConfig(delay: Option[Long], measurements: Seq[MeasurementSimConfig]) diff --git a/simulator/src/main/scala/io/greenbus/sim/impl/RandomValues.scala b/simulator/src/main/scala/io/greenbus/sim/impl/RandomValues.scala new file mode 100644 index 0000000..40c82ab --- /dev/null +++ b/simulator/src/main/scala/io/greenbus/sim/impl/RandomValues.scala @@ -0,0 +1,76 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the GNU Affero General Public License + * Version 3.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.gnu.org/licenses/agpl.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.sim.impl + +import io.greenbus.client.service.proto.Measurements +import java.util.Random +import io.greenbus.client.service.proto.Measurements.Measurement.Type +import io.greenbus.sim.MeasurementSimConfig + +object RandomValue { + + private val rand = new Random + + def apply(config: MeasurementSimConfig): RandomValue = { + + config.measurementType match { + case "BOOL" => + config.statusConfig.map(cfg => BooleanValue(cfg.initialValue, config.changeProbability.getOrElse(0.3))).getOrElse(BooleanValue(false, 0.3)) + case "INT" => + config.analogConfig.map(cfg => IntValue(cfg.initialValue.toInt, cfg.min.toInt, cfg.max.toInt, cfg.maxDelta.toInt, config.changeProbability.getOrElse(0.3))) + .getOrElse(IntValue(0, 0, 100, 2, 0.3)) + case "DOUBLE" => + config.analogConfig.map(cfg => DoubleValue(cfg.initialValue, cfg.min, cfg.max, cfg.maxDelta, config.changeProbability.getOrElse(0.3))) + .getOrElse(DoubleValue(0, -100, 100, 2, 0.3)) + case x => throw new Exception(s"Can't generate random value for type $x") + } + } + + case class DoubleValue(value: Double, min: Double, max: Double, maxChange: Double, changeProbability: Double) extends RandomValue { + def generate() = this.copy(value = (value + maxChange * 2 * (rand.nextDouble - 0.5)).max(min).min(max)) + def apply(meas: Measurements.Measurement.Builder) = meas.setDoubleVal(value).setType(Type.DOUBLE) + def newChangeProbability(p: Double) = this.copy(changeProbability = p) + } + case class IntValue(value: Int, min: Int, max: Int, maxChange: Int, changeProbability: Double) extends RandomValue { + def generate() = this.copy(value = (value + rand.nextInt(2 * maxChange + 1) - maxChange).max(min).min(max)) + def apply(meas: Measurements.Measurement.Builder) = meas.setIntVal(value).setType(Type.INT) + def newChangeProbability(p: Double) = this.copy(changeProbability = p) + } + case class BooleanValue(value: Boolean, changeProbability: Double) extends RandomValue { + def generate() = this.copy(value = !value, changeProbability) + def apply(meas: Measurements.Measurement.Builder) = meas.setBoolVal(value) setType (Type.BOOL) + def newChangeProbability(p: Double) = this.copy(changeProbability = p) + } +} + +trait RandomValue { + + def changeProbability: Double + + def newChangeProbability(p: Double): RandomValue + + def next(): Option[RandomValue] = { + if (RandomValue.rand.nextDouble > changeProbability) None + else Some(generate) + } + + def generate(): RandomValue + + def apply(meas: Measurements.Measurement.Builder) +} \ No newline at end of file diff --git a/sql/pom.xml b/sql/pom.xml new file mode 100755 index 0000000..d2ff76b --- /dev/null +++ b/sql/pom.xml @@ -0,0 +1,82 @@ + + 4.0.0 + + + io.greenbus + greenbus-scala-base + 3.0.0 + ../scala-base + + + greenbus-sql + jar + + + + Apache 2 + http://www.apache.org/licenses/LICENSE-2.0.txt + A business-friendly OSS license + + + + + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + + test-jar + + + + + + com.mycila.maven-license-plugin + maven-license-plugin + +
../APACHE_FILE_HEADER
+
+
+
+
+ + + + io.greenbus + greenbus-util + ${greenbus.version} + + + org.squeryl + squeryl_${scala.annotation} + ${squeryl.version} + + + cglib + cglib-nodep + ${cglib.version} + compile + + + postgresql + postgresql + ${postgresql.version} + + + commons-pool + commons-pool + ${commons-pool.version} + + + commons-dbcp + commons-dbcp + ${commons-dbcp.version} + + + + +
diff --git a/sql/src/main/scala/io/greenbus/sql/DbConnection.scala b/sql/src/main/scala/io/greenbus/sql/DbConnection.scala new file mode 100755 index 0000000..b8ffaea --- /dev/null +++ b/sql/src/main/scala/io/greenbus/sql/DbConnection.scala @@ -0,0 +1,49 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.sql + +import java.sql.Connection +import org.squeryl.logging.StatisticsListener + +/** + * contains the session factory necessary to talk to the database. Handles the setting up of the + * session and transaction blocks. + * + * These functions should be used instead of PrimitiveTypeMode.transaction + */ +trait DbConnection { + + /** + * will open and close a new transaction around the passed in code + */ + def transaction[A](listener: Option[StatisticsListener])(fun: => A): A + def transaction[A](fun: => A): A + + /** + * will open a new transaction if one doesn't already exist, otherwise goes into same transaction + */ + def inTransaction[A](fun: => A): A + + /** + * get the underlying jdbc Connection for use in low-level tasks like schema setup, cleanup. + * Handled in a block because we need to return the connection to the session pool when we are done + */ + def underlyingConnection[A](fun: (Connection) => A): A +} + diff --git a/sql/src/main/scala/io/greenbus/sql/DbConnector.scala b/sql/src/main/scala/io/greenbus/sql/DbConnector.scala new file mode 100755 index 0000000..bba04ff --- /dev/null +++ b/sql/src/main/scala/io/greenbus/sql/DbConnector.scala @@ -0,0 +1,31 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.sql + +object DbConnector { + + def connect(sqlSettings: SqlSettings) = { + val provider = new postgres.Connector + provider.connect(sqlSettings) + } +} + +trait DbConnector { + def connect(dbInfo: SqlSettings): DbConnection +} diff --git a/sql/src/main/scala/io/greenbus/sql/ExclusiveAccess.scala b/sql/src/main/scala/io/greenbus/sql/ExclusiveAccess.scala new file mode 100755 index 0000000..d23e665 --- /dev/null +++ b/sql/src/main/scala/io/greenbus/sql/ExclusiveAccess.scala @@ -0,0 +1,83 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.sql + +import org.squeryl.{ Table, KeyedEntity } +import org.squeryl.PrimitiveTypeMode._ + +/** + * grants exclusive access to a squeryl object to a single thread or process. It is + * hinged on there being some property of the sql object that indicates whether the + * action we want to take needs to occur. We try to get exclusive access to the object, + * check the precondition, run the user block, then check that we have updated the + * object to no longer match the precondition (or else other threads would think + * they were the first to get access to the object) + */ +object ExclusiveAccess { + + class ExclusiveAccessException(msg: String) extends Exception(msg) + class ObjectMissingException(msg: String) extends ExclusiveAccessException(msg) + class AcquireConditionNotMetException(msg: String) extends ExclusiveAccessException(msg) + class AcquireConditionStillValidException(msg: String) extends ExclusiveAccessException(msg) + class InvalidUpdateException(msg: String) extends ExclusiveAccessException(msg) + + def exclusiveAccess[A <: KeyedEntity[Long]]( + dbConnection: DbConnection, + table: Table[A], + id: Long, + updateFun: A => Any, + acquireCondition: A => Boolean)(lockFun: A => A): A = { + + // Wrap/unwrap in list + val list = exclusiveAccess(dbConnection, table, List(id), updateFun, acquireCondition) { list => + List(lockFun(list.head)) + } + list.head + } + + def exclusiveAccess[A <: KeyedEntity[Long]](dbConnection: DbConnection, + table: Table[A], + ids: List[Long], + updateFun: A => Any, + acquireCondition: A => Boolean)(lockFun: List[A] => List[A]): List[A] = { + + dbConnection.transaction { + // Select for update + val objList = table.where(c => c.id in ids).forUpdate.toList + + // Fail if we have nothing + if (objList.size < ids.size) throw new ObjectMissingException("Missing objects: " + ids + " got: " + objList.map { _.id }) + + // Precondition on all objects + if (objList.exists(!acquireCondition(_))) throw new AcquireConditionNotMetException("Couldn't acquire objects") + + // Get results, do any work inside the lock + val results = lockFun(objList) + + // Postcondition on all objects + if (results.exists(acquireCondition(_))) throw new AcquireConditionStillValidException("Objects were not updated") + + // Call update for each row + results.foreach(updateFun(_)) + + results + } + } + +} diff --git a/sql/src/main/scala/io/greenbus/sql/SqlSettings.scala b/sql/src/main/scala/io/greenbus/sql/SqlSettings.scala new file mode 100755 index 0000000..54ed1ca --- /dev/null +++ b/sql/src/main/scala/io/greenbus/sql/SqlSettings.scala @@ -0,0 +1,45 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.sql + +import com.typesafe.scalalogging.slf4j.Logging + +object SqlSettings extends Logging { + import io.greenbus.util.PropertyReading._ + + def load(file: String): SqlSettings = { + apply(loadFile(file)) + } + + def apply(props: Map[String, String]): SqlSettings = { + SqlSettings( + get(props, "io.greenbus.sql.url"), + get(props, "io.greenbus.sql.username"), + get(props, "io.greenbus.sql.password"), + getLong(props, "io.greenbus.sql.slowquery"), + optionalInt(props, "io.greenbus.sql.maxactive") getOrElse 8) + } +} + +case class SqlSettings(url: String, user: String, password: String, + slowQueryTimeMilli: Long, poolMaxActive: Int) { + + // custom to hide password + override def toString() = user + "@" + url +} diff --git a/sql/src/main/scala/io/greenbus/sql/TransactionMetricsListener.scala b/sql/src/main/scala/io/greenbus/sql/TransactionMetricsListener.scala new file mode 100644 index 0000000..8c6198a --- /dev/null +++ b/sql/src/main/scala/io/greenbus/sql/TransactionMetricsListener.scala @@ -0,0 +1,69 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.sql + +import org.squeryl.logging.{ StatementInvocationEvent, StatisticsListener } +import io.greenbus.jmx.Metrics +import com.typesafe.scalalogging.slf4j.Logging + +class TransactionMetrics(metrics: Metrics) { + + val queryCount = metrics.average("SqlQueryCount") + val queryTime = metrics.average("SqlQueryTime") + val timePerQuery = metrics.average("SqlTimePerQuery") +} + +class TransactionMetricsListener(requestId: String) extends StatisticsListener with Logging { + + private var queryCount: Int = 0 + private var queryTime: Int = 0 + + def queryExecuted(se: StatementInvocationEvent): Unit = { + queryCount += 1 + val elapsed = (se.end - se.start).toInt + queryTime += elapsed + logger.trace(s"$requestId - Query ($elapsed) [${se.rowCount}]: " + se.jdbcStatement.take(30).replace('\n', ' ') + " : " + se.definitionOrCallSite) + } + + def updateExecuted(se: StatementInvocationEvent): Unit = { + val elapsed = (se.end - se.start).toInt + logger.trace(s"$requestId - Update ($elapsed) [${se.rowCount}]: " + se.jdbcStatement.take(30).replace('\n', ' ')) + } + + def insertExecuted(se: StatementInvocationEvent): Unit = { + val elapsed = (se.end - se.start).toInt + logger.trace(s"$requestId - Insert ($elapsed) [${se.rowCount}]: " + se.jdbcStatement.take(30).replace('\n', ' ')) + } + + def deleteExecuted(se: StatementInvocationEvent): Unit = { + val elapsed = (se.end - se.start).toInt + logger.trace(s"$requestId - Delete ($elapsed) [${se.rowCount}]: " + se.jdbcStatement.take(30).replace('\n', ' ')) + } + + def resultSetIterationEnded(statementInvocationId: String, iterationEndTime: Long, rowCount: Int, iterationCompleted: Boolean): Unit = { + + } + + def report(metrics: TransactionMetrics) { + metrics.queryCount(queryCount) + metrics.queryTime(queryTime) + val timePerQuery = if (queryCount > 0) (queryTime.toDouble / queryCount.toDouble).toInt else 0 + metrics.timePerQuery(timePerQuery) + } +} diff --git a/sql/src/main/scala/io/greenbus/sql/impl/SessionDbConnection.scala b/sql/src/main/scala/io/greenbus/sql/impl/SessionDbConnection.scala new file mode 100644 index 0000000..9b1fe5e --- /dev/null +++ b/sql/src/main/scala/io/greenbus/sql/impl/SessionDbConnection.scala @@ -0,0 +1,71 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.sql.impl + +import org.squeryl.{ PrimitiveTypeMode, Session } +import java.sql.Connection +import io.greenbus.sql.DbConnection +import com.typesafe.scalalogging.slf4j.Logging +import org.squeryl.logging.StatisticsListener + +/** + * default DbConnection implementation that takes a session factory function and generates + * a new session whenever a new transaction is needed + */ +class SessionDbConnection(sessionFactory: (Option[StatisticsListener]) => Session) extends DbConnection with Logging { + + def transaction[A](fun: => A): A = { + transaction(None)(fun) + } + + def transaction[A](listener: Option[StatisticsListener])(fun: => A): A = { + val session = sessionFactory(listener) + if (logger.underlying.isTraceEnabled) { + session.setLogger(s => logger.trace(s)) + } + + val result = PrimitiveTypeMode.using(session) { + PrimitiveTypeMode.transaction(session) { + fun + } + } + result + } + + def inTransaction[A](fun: => A): A = { + + if (Session.hasCurrentSession) fun + else transaction(fun) + + } + + def underlyingConnection[A](fun: (Connection) => A) = { + val c = if (Session.hasCurrentSession) Session.currentSession.connection + else sessionFactory(None).connection + + try { + fun(c) + } finally { + // return the connection to the pool + c.close() + } + + } + +} diff --git a/sql/src/main/scala/io/greenbus/sql/impl/SlowQueryTracing.scala b/sql/src/main/scala/io/greenbus/sql/impl/SlowQueryTracing.scala new file mode 100755 index 0000000..0c177f3 --- /dev/null +++ b/sql/src/main/scala/io/greenbus/sql/impl/SlowQueryTracing.scala @@ -0,0 +1,52 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.sql.impl + +import org.squeryl.internals.{ StatementWriter, DatabaseAdapter } +import org.squeryl.Session +import java.sql.SQLException + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.util.Timing + +trait SlowQueryTracing extends DatabaseAdapter with Logging { + + val slowQueryTimeMilli: Long + + override def execFailSafeExecute(sw: StatementWriter, silenceException: SQLException => Boolean): Unit = { + val timingFun = monitorSlowQueries(slowQueryTimeMilli, sw) _ + Timing.time(timingFun) { + super.execFailSafeExecute(sw, silenceException) + } + } + + override def exec[A](s: Session, sw: StatementWriter)(block: Iterable[AnyRef] => A): A = { + val timingFun = monitorSlowQueries(slowQueryTimeMilli, sw) _ + Timing.time[A](timingFun) { + super.exec[A](s, sw)(block) + } + } + + def monitorSlowQueries(maxTimeMilli: Long, sw: StatementWriter)(actualTimeMilli: Long): Unit = { + logger.trace("Db time: " + actualTimeMilli + "; " + sw.toString().take(40).replace('\n', ' ')) + if (actualTimeMilli >= maxTimeMilli) { + logger.info("SlowQuery, actual: " + actualTimeMilli + " ms, max allowed: " + maxTimeMilli + "ms, query: " + sw.toString) + } + } +} \ No newline at end of file diff --git a/sql/src/main/scala/io/greenbus/sql/postgres/Connector.scala b/sql/src/main/scala/io/greenbus/sql/postgres/Connector.scala new file mode 100755 index 0000000..37a3acc --- /dev/null +++ b/sql/src/main/scala/io/greenbus/sql/postgres/Connector.scala @@ -0,0 +1,53 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.sql.postgres + +import org.squeryl.Session +import org.squeryl.adapters.PostgreSqlAdapter + +import com.typesafe.scalalogging.slf4j.Logging +import io.greenbus.sql.{ DbConnector, SqlSettings } +import io.greenbus.sql.impl.{ SessionDbConnection, SlowQueryTracing } +import org.squeryl.logging.StatisticsListener + +class Connector extends DbConnector with Logging { + + def connect(sqlSettings: SqlSettings) = { + + val pool = new org.apache.commons.dbcp.BasicDataSource + pool.setDriverClassName("org.postgresql.Driver") + pool.setUrl(sqlSettings.url) + pool.setUsername(sqlSettings.user) + pool.setPassword(sqlSettings.password) + pool.setMaxActive(sqlSettings.poolMaxActive) + + logger.info("Initializing postgres connection pool: " + sqlSettings) + + def sessionFactory(listener: Option[StatisticsListener]) = { + new Session( + pool.getConnection(), + new PostgreSqlAdapter with SlowQueryTracing { + val slowQueryTimeMilli = sqlSettings.slowQueryTimeMilli + }, + listener) + } + + new SessionDbConnection(sessionFactory) + } +} diff --git a/sql/src/test/scala/io/greenbus/sql/test/DatabaseUsingTestBase.scala b/sql/src/test/scala/io/greenbus/sql/test/DatabaseUsingTestBase.scala new file mode 100755 index 0000000..edfb415 --- /dev/null +++ b/sql/src/test/scala/io/greenbus/sql/test/DatabaseUsingTestBase.scala @@ -0,0 +1,80 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.sql.test + +import org.scalatest.matchers.ShouldMatchers +import org.scalatest.{ BeforeAndAfterAll, BeforeAndAfterEach, FunSuite } +import io.greenbus.util.Timing +import com.typesafe.scalalogging.slf4j.Logging +import org.squeryl.Schema +import io.greenbus.sql.{ SqlSettings, DbConnection, DbConnector } + +object ConnectionStorage { + private var lastConnection = Option.empty[DbConnection] + private var lastOptions = Option.empty[SqlSettings] + + def connect(sqlSettings: SqlSettings): DbConnection = { + if (lastOptions != Some(sqlSettings)) { + lastConnection = Some(DbConnector.connect(sqlSettings)) + lastOptions = Some(sqlSettings) + } + lastConnection.get + } + + var dbNeedsReset = true +} + +trait DatabaseUsingTestBaseNoTransaction extends FunSuite with ShouldMatchers with BeforeAndAfterAll with BeforeAndAfterEach with Logging { + lazy val dbConnection = ConnectionStorage.connect(SqlSettings.load("io.greenbus.test.cfg")) + + def schemas: Seq[Schema] + + protected def alwaysReset = true + + override def beforeAll() { + if (ConnectionStorage.dbNeedsReset || alwaysReset) { + val prepareTime = Timing.benchmark { + dbConnection.transaction { + schemas.foreach { s => + s.drop + s.create + } + } + } + logger.info("Prepared db in: " + prepareTime) + ConnectionStorage.dbNeedsReset = false + } + } + + /** + * we only need to rebuild the database schema when a Non-Transaction-Safe test suite has been run. + */ + protected def resetDbAfterTestSuite: Boolean + override def afterAll() { + ConnectionStorage.dbNeedsReset = resetDbAfterTestSuite + } +} + +abstract class DatabaseUsingTestBase extends DatabaseUsingTestBaseNoTransaction with RunTestsInsideTransaction { + protected override def resetDbAfterTestSuite = false +} + +abstract class DatabaseUsingTestNotTransactionSafe extends DatabaseUsingTestBaseNoTransaction { + protected override def resetDbAfterTestSuite = true +} \ No newline at end of file diff --git a/sql/src/test/scala/io/greenbus/sql/test/RunTestsInsideTransaction.scala b/sql/src/test/scala/io/greenbus/sql/test/RunTestsInsideTransaction.scala new file mode 100755 index 0000000..d4bdce3 --- /dev/null +++ b/sql/src/test/scala/io/greenbus/sql/test/RunTestsInsideTransaction.scala @@ -0,0 +1,80 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.sql.test + +import org.scalatest._ +import io.greenbus.sql.DbConnection + +trait RunTestsInsideTransaction extends FunSuite with BeforeAndAfterEach { + + def dbConnection: DbConnection + + class TransactionAbortException extends Exception + + /** + * test classes shouldn't use beforeEach if using RunTestsInsideTransaction + */ + final override def beforeEach() {} + final override def afterEach() {} + + /** + * should be overriden by tests to run setup code inside same transaction + * as the test run. + */ + def beforeEachInTransaction() {} + + /** + * should be overriden by tests to run teardown code inside same transaction + * as the test run. + */ + def afterEachInTransaction() {} + + override def runTest( + testName: String, + reporter: Reporter, + stopper: Stopper, + configMap: Map[String, Any], + tracker: Tracker): Unit = { + + // transaction will always be rolledback + neverCompletingTransaction { + beforeEachInTransaction() + super.runTest(testName, reporter, stopper, configMap, tracker) + afterEachInTransaction() + } + } + + /** + * each test occur from within a transaction, that way when the test completes _all_ changes + * made during the test are reverted so each test gets a clean environment to test against + */ + def neverCompletingTransaction[A](func: => A) = { + try { + dbConnection.transaction { + val result = func + // we abort the transaction if we get to here, so changes get rolled back + throw new TransactionAbortException + + result + } + } catch { + case ex: TransactionAbortException => + } + } +} \ No newline at end of file diff --git a/util/pom.xml b/util/pom.xml new file mode 100755 index 0000000..3e46acd --- /dev/null +++ b/util/pom.xml @@ -0,0 +1,36 @@ + + 4.0.0 + + + io.greenbus + greenbus-scala-base + 3.0.0 + ../scala-base + + + greenbus-util + jar + + + + Apache 2 + http://www.apache.org/licenses/LICENSE-2.0.txt + A business-friendly OSS license + + + + + + + + com.mycila.maven-license-plugin + maven-license-plugin + +
../APACHE_FILE_HEADER
+
+
+
+
+ +
diff --git a/util/src/main/scala/io/greenbus/jmx/MBeanUtils.scala b/util/src/main/scala/io/greenbus/jmx/MBeanUtils.scala new file mode 100644 index 0000000..be766e5 --- /dev/null +++ b/util/src/main/scala/io/greenbus/jmx/MBeanUtils.scala @@ -0,0 +1,37 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.jmx + +import javax.management.ObjectName + +object MBeanUtils { + + def objectName(domain: String, name: String): ObjectName = { + new ObjectName(domain + ":name=" + name) + } + + def objectName(domain: String, tags: List[Tag], name: String): ObjectName = { + val tagsText = tags match { + case Nil => "" + case full => tags.map(t => t.name + "=" + t.value).mkString(",") + "," + } + val text = domain + ":" + tagsText + "name=" + name + new ObjectName(text) + } +} \ No newline at end of file diff --git a/util/src/main/scala/io/greenbus/jmx/MetricValue.scala b/util/src/main/scala/io/greenbus/jmx/MetricValue.scala new file mode 100644 index 0000000..087cd98 --- /dev/null +++ b/util/src/main/scala/io/greenbus/jmx/MetricValue.scala @@ -0,0 +1,98 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.jmx + +import java.util.concurrent.atomic.AtomicInteger + +trait MetricValue { + def update(i: Int) + + def reset() + + def value: AnyRef +} + +object MetricValue { + + abstract class IntMetricHolder extends MetricValue { + + protected val current = new AtomicInteger(0) + + def update(i: Int) + + def value = current.get.asInstanceOf[AnyRef] + + def reset() { + current.set(0) + } + + } + + class CounterMetric extends IntMetricHolder { + def update(i: Int) { + current.addAndGet(i) + } + } + + class GaugeMetric extends IntMetricHolder { + def update(i: Int) { + current.set(i) + } + } + + /** + * keeps a windowed average over the last size updates + */ + class AverageMetric(size: Int) extends MetricValue { + + private var next = 0 + private val counts = new Array[Int](size) + private var sum = 0 + + def update(i: Int) { + synchronized { + sum -= counts.apply(next % size) + counts.update(next % size, i) + next += 1 + sum += i + } + } + + def reset() { + synchronized { + next = 0 + sum = 0 + Range(0, size).foreach(counts.update(_, 0)) + } + } + + def value = { + var s = 0 + synchronized { + s = sum + } + // can look this up unsafely since after startup we dont actually + // need this value to be accurate + val num = if (next > size) size else next + val d = if (num == 0) 0 else s.toDouble / num + d.asInstanceOf[AnyRef] + } + } + +} diff --git a/util/src/main/scala/io/greenbus/jmx/Metrics.scala b/util/src/main/scala/io/greenbus/jmx/Metrics.scala new file mode 100755 index 0000000..f8f10d0 --- /dev/null +++ b/util/src/main/scala/io/greenbus/jmx/Metrics.scala @@ -0,0 +1,174 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.jmx + +import io.greenbus.util.Timing +import scala.collection.mutable +import management.ManagementFactory +import javax.management.ObjectName + +trait Timer { + def apply[A](f: => A): A +} + +trait MetricsContainer { + def add(name: String, v: MetricValue) + def get(name: String): MetricValue + def getAll: List[(String, MetricValue)] +} + +object MetricsContainer { + + def apply(): MetricsContainer = new DefaultMetricsContainer + + class DefaultMetricsContainer extends MetricsContainer { + private val map = mutable.Map.empty[String, MetricValue] + + def add(name: String, v: MetricValue) { + map += (name -> v) + } + + def get(name: String) = { + map.get(name) getOrElse { throw new Exception("Failed to get metrics from metrics container " + name) } + } + + def getAll: List[(String, MetricValue)] = map.toList + } +} + +trait Metrics { + def gauge(name: String): (Int) => Unit + def counter(name: String): (Int) => Unit + def average(name: String): (Int) => Unit + def timer(name: String): Timer +} + +object Metrics { + + def apply(container: MetricsContainer): Metrics = new DefaultMetrics(container) + + class DefaultTimer(metric: MetricValue.AverageMetric) extends Timer { + def apply[A](f: => A) = { + Timing.time(x => metric.update(x.toInt))(f) + } + } + + class DefaultMetrics(container: MetricsContainer) extends Metrics { + + private def register(name: String, v: MetricValue) = { + container.add(name, v) + container.get(name).update(_) + } + + def gauge(name: String) = { + register(name, new MetricValue.GaugeMetric) + } + + def counter(name: String) = { + register(name, new MetricValue.CounterMetric) + } + + def average(name: String) = { + register(name, new MetricValue.AverageMetric(30)) + } + def average(name: String, size: Int) = { + register(name, new MetricValue.AverageMetric(size)) + } + + def timer(name: String) = { + val metric = new MetricValue.AverageMetric(30) + container.add(name, metric) + new DefaultTimer(metric) + } + } +} + +trait MetricsSource { + def metrics(name: String): Metrics + def metrics(name: String, subTags: Tag): Metrics + def metrics(name: String, subTags: List[Tag]): Metrics +} + +trait MetricsManager extends MetricsSource { + def register() + def unregister() +} + +case class Tag(name: String, value: String) + +object MetricsManager { + + val instanceTag = "instance" + + def apply(domain: String): MetricsManager = new DefaultMetricsManager(domain, Nil) + def apply(domain: String, instance: String): MetricsManager = new DefaultMetricsManager(domain, List(Tag(instanceTag, instance))) + + case class MetricsInfo(domain: String, tags: List[Tag], name: String, container: MetricsContainer) + + class DefaultMetricsManager(domain: String, tags: List[Tag]) extends MetricsManager { + + private var stash = List.empty[MetricsInfo] + private var registered = List.empty[ObjectName] + + private def addMetrics(name: String, tags: List[Tag]): Metrics = { + val container = MetricsContainer() + val info = MetricsInfo(domain, tags, name, container) + stash ::= info + Metrics(container) + } + + def metrics(name: String, subTag: Tag): Metrics = { + addMetrics(name, tags ::: List(subTag)) + } + def metrics(name: String, subTags: List[Tag]): Metrics = { + addMetrics(name, tags ::: subTags) + } + + def metrics(name: String): Metrics = { + addMetrics(name, tags) + } + + // TODO: invariants on calling only once and not calling metrics again afterwards + def register() { + + val server = ManagementFactory.getPlatformMBeanServer + + registered = stash.map { + case MetricsInfo(domain, tags, name, container) => { + val objectName = MBeanUtils.objectName(domain, tags, name) + + // Exception will be thrown if we try to register twice, + // since we may have modified what attributes the bean has we just replace it + if (server.isRegistered(objectName)) { + server.unregisterMBean(objectName) + } + + server.registerMBean(new MetricsMBean(objectName, container), objectName) + + objectName + } + } + } + + def unregister() { + val server = ManagementFactory.getPlatformMBeanServer + registered.foreach(name => server.unregisterMBean(name)) + } + } +} diff --git a/util/src/main/scala/io/greenbus/jmx/MetricsMBean.scala b/util/src/main/scala/io/greenbus/jmx/MetricsMBean.scala new file mode 100644 index 0000000..629bcc8 --- /dev/null +++ b/util/src/main/scala/io/greenbus/jmx/MetricsMBean.scala @@ -0,0 +1,56 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.jmx + +import javax.management._ + +class MetricsMBean(name: ObjectName, container: MetricsContainer) extends DynamicMBean { + + def getName: ObjectName = name + + def getAttribute(attribute: String): AnyRef = { + container.get(attribute).value + } + + def getAttributes(attributes: Array[String]): AttributeList = { + val results = new AttributeList() + attributes.foreach { name => + results.add(new Attribute(name, container.get(name).value)) + } + results + } + + def getMBeanInfo: MBeanInfo = { + val attributes = container.getAll.map(kv => new MBeanAttributeInfo(kv._1, kv._2.getClass.getSimpleName, "", true, false, false)) + + new MBeanInfo(this.getClass.getName, "GreenBus MetricsMBean", attributes.toArray, null, null, null) + } + + def setAttributes(attributes: AttributeList): AttributeList = { + throw new UnsupportedOperationException("setAttributes is not implemented") + } + + def invoke(actionName: String, params: Array[AnyRef], signature: Array[String]): AnyRef = { + throw new UnsupportedOperationException("invoke is not implemented") + } + + def setAttribute(attribute: Attribute) { + throw new UnsupportedOperationException("setAttribute is not implemented") + } +} diff --git a/util/src/main/scala/io/greenbus/util/Optional.scala b/util/src/main/scala/io/greenbus/util/Optional.scala new file mode 100644 index 0000000..43a97eb --- /dev/null +++ b/util/src/main/scala/io/greenbus/util/Optional.scala @@ -0,0 +1,36 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.util + +object Optional { + + /** + * calls the has (or all of the has) functions, taking advantage of short circuiting, to return None if any of the has functions + * fail or the result of the get function wrapped in Some. + */ + def optGet[A](has: => Boolean, get: => A): Option[A] = if (has) Some(get) else None + def optGet[A](has: => Boolean, has1: => Boolean, get: => A): Option[A] = if (has && has1) Some(get) else None + def optGet[A](has: => Boolean, has1: => Boolean, has2: => Boolean, get: => A): Option[A] = if (has && has1 && has2) Some(get) else None + + // if (isTrue) Some(obj) else None ---> isTrue thenGet obj | isTrue ? obj + implicit def boolWrapper(b: Boolean) = new OptionalBoolean(b) + class OptionalBoolean(b: Boolean) { + def thenGet[A](t: => A): Option[A] = if (b) Some(t) else None + } +} diff --git a/util/src/main/scala/io/greenbus/util/PropertyReading.scala b/util/src/main/scala/io/greenbus/util/PropertyReading.scala new file mode 100644 index 0000000..b8e65c7 --- /dev/null +++ b/util/src/main/scala/io/greenbus/util/PropertyReading.scala @@ -0,0 +1,86 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.util + +import java.util.Properties +import scala.collection.JavaConversions._ +import java.io.FileInputStream + +class ConfigurationException(message: String) extends Exception(message) + +object PropertyReading { + + def loadFile(filename: String): Map[String, String] = { + val properties = new Properties + try { + properties.load(new FileInputStream(filename)) + asMap(properties) + } catch { + case ex: Throwable => + throw new ConfigurationException("Problem loading configuration file: " + ex.getMessage) + } + } + + private def asMap(properties: Properties): Map[String, String] = { + properties.stringPropertyNames().map(name => (name, properties.getProperty(name))).toMap + } + + private def missing(name: String) = new ConfigurationException(s"Missing property: '$name'") + + def optional(props: Map[String, String], name: String): Option[String] = { + Option(System.getProperty(name)) orElse props.get(name) + } + + def get(props: Map[String, String], name: String): String = { + optional(props, name) getOrElse (throw missing(name)) + } + + def optionalLong(props: Map[String, String], name: String): Option[Long] = { + checkSignedInt(name) { + optional(props, name).map(_.toLong) + } + } + + def getLong(props: Map[String, String], name: String): Long = { + checkSignedInt(name) { + get(props, name).toLong + } + } + + def optionalInt(props: Map[String, String], name: String): Option[Int] = { + checkSignedInt(name) { + optional(props, name).map(_.toInt) + } + } + + def getInt(props: Map[String, String], name: String): Int = { + checkSignedInt(name) { + get(props, name).toInt + } + } + + private def checkSignedInt[A](name: String)(fun: => A): A = { + try { + fun + } catch { + case ex: NumberFormatException => + throw new ConfigurationException(s"Configuration property '$name' must be a signed integer.") + } + } +} diff --git a/util/src/main/scala/io/greenbus/util/Timing.scala b/util/src/main/scala/io/greenbus/util/Timing.scala new file mode 100644 index 0000000..d1d2c19 --- /dev/null +++ b/util/src/main/scala/io/greenbus/util/Timing.scala @@ -0,0 +1,60 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.util + +/** + * methods for determining elapsed time for a supplied function. + */ +object Timing { + + /** + * very simple class to make measuring elapsed time easier in benchmark and test code + */ + trait Stopwatch { + def elapsed: Long + } + object Stopwatch { + def start: Stopwatch = new Stopwatch { + private val startTime = System.nanoTime() + def elapsed: Long = convertNanoToMilli(System.nanoTime() - startTime) + } + } + + /** + * Runs a block of code and returns how long it took in milliseconds (not the return value of the block) + */ + def benchmark[A](fun: => A): Long = { + val stopwatch = Stopwatch.start + fun + stopwatch.elapsed + } + + /** + * Runs a block of code and passes the length of time it took to another function + */ + def time[A](timingFun: Long => Unit)(fun: => A): A = { + val stopwatch = Stopwatch.start + val ret = fun + timingFun(stopwatch.elapsed) + ret + } + + private def convertNanoToMilli[A](value: Long): Long = scala.math.floor(value / 1000000d).toLong + +} \ No newline at end of file diff --git a/util/src/main/scala/io/greenbus/util/UserSettings.scala b/util/src/main/scala/io/greenbus/util/UserSettings.scala new file mode 100755 index 0000000..4c30c60 --- /dev/null +++ b/util/src/main/scala/io/greenbus/util/UserSettings.scala @@ -0,0 +1,39 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.util + +object UserSettings { + import io.greenbus.util.PropertyReading._ + + def load(file: String): UserSettings = { + apply(loadFile(file)) + } + + def apply(props: Map[String, String]): UserSettings = { + UserSettings( + get(props, "io.greenbus.user.username"), + get(props, "io.greenbus.user.password")) + } +} + +case class UserSettings(user: String, password: String) { + + // custom to hide password + override def toString() = user + ",[password]" +} diff --git a/util/src/main/scala/io/greenbus/util/XmlHelper.scala b/util/src/main/scala/io/greenbus/util/XmlHelper.scala new file mode 100644 index 0000000..29699a6 --- /dev/null +++ b/util/src/main/scala/io/greenbus/util/XmlHelper.scala @@ -0,0 +1,85 @@ +/** + * Copyright 2011-2016 Green Energy Corp. + * + * Licensed to Green Energy Corp (www.greenenergycorp.com) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. Green Energy + * Corp licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.greenbus.util + +import javax.xml.bind.Unmarshaller.Listener +import javax.xml.bind.{ Unmarshaller, JAXBContext, Marshaller } +import java.io._ +import javax.xml.stream.{ Location, XMLInputFactory } + +object XmlHelper { + + def read[A](text: String, klass: Class[A]): A = read(text.getBytes("UTF-8"), klass) + + def read[A](bytes: Array[Byte], klass: Class[A]): A = { + val ctx = JAXBContext.newInstance(klass) + val um = ctx.createUnmarshaller + val is = new ByteArrayInputStream(bytes) + um.unmarshal(is).asInstanceOf[A] + } + + def read[A](file: File, klass: Class[A]): A = { + val ctx = JAXBContext.newInstance(klass) + val um = ctx.createUnmarshaller + um.unmarshal(file).asInstanceOf[A] + } + + def readWithLocation[A](file: File, klass: Class[A]): (A, Map[Any, Location]) = { + val ctx = JAXBContext.newInstance(klass) + + val xif = XMLInputFactory.newFactory() + val xsr = xif.createXMLStreamReader(new FileInputStream(file)) + + val um = ctx.createUnmarshaller + + val mapBuilder = Map.newBuilder[Any, Location] + + val listener = new Listener { + override def beforeUnmarshal(target: Any, parent: Any): Unit = { + mapBuilder += ((target, xsr.getLocation)) + } + } + + um.setListener(listener) + val result = um.unmarshal(xsr).asInstanceOf[A] + + (result, mapBuilder.result()) + } + + def writeToFile[A](value: A, klass: Class[A], filewriter: FileWriter): Unit = { + val ctx = JAXBContext.newInstance(klass) + val m = ctx.createMarshaller + m.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true) + + m.marshal(value, filewriter) + } + + def writeToString[A](value: A, klass: Class[A], formatted: Boolean = false): String = { + val ctx = JAXBContext.newInstance(klass) + val m = ctx.createMarshaller + if (formatted) { + m.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true) + } + val sw = new StringWriter + + m.marshal(value, sw) + sw.toString + } + +}