From ccdc00629b9d9c61d9d3611bf5bf96918a5cdfaf Mon Sep 17 00:00:00 2001
From: Florian Schmaus <flow@cs.fau.de>
Date: Tue, 19 Apr 2022 11:08:13 +0200
Subject: [PATCH] Add XmppHook

---
 benchmark-runner/build.sc                     |   4 +
 .../main/src/de/fau/cs/mazstab/Mazstab.scala  |  10 +-
 .../fau/cs/mazstab/MazstabConfiguration.scala |  22 ++-
 .../de/fau/cs/mazstab/hooks/MazstabHook.scala |  11 +-
 .../de/fau/cs/mazstab/hooks/XmppHook.scala    | 165 ++++++++++++++++++
 5 files changed, 203 insertions(+), 9 deletions(-)
 create mode 100644 benchmark-runner/main/src/de/fau/cs/mazstab/hooks/XmppHook.scala

diff --git a/benchmark-runner/build.sc b/benchmark-runner/build.sc
index 0b27f14..11edc82 100644
--- a/benchmark-runner/build.sc
+++ b/benchmark-runner/build.sc
@@ -14,6 +14,8 @@ object main extends ScalaModule
   def mainClass = Some("de.fau.cs.mazstab.Mazstab")
 
   def scalaVersion = "2.13.5"
+
+  val smackVersion = "4.4.5"
   def ivyDeps = Agg(
     ivy"com.google.guava:guava:30.1.1-jre",
     ivy"com.jakewharton.picnic:picnic:0.5.0",
@@ -28,6 +30,8 @@ object main extends ScalaModule
     ivy"org.apache.commons:commons-configuration2:2.7",
     ivy"org.apache.commons:commons-math3:3.6.1",
     ivy"org.eclipse.jgit:org.eclipse.jgit:5.13.0.202109080827-r",
+    ivy"org.igniterealtime.smack:smack-java8:$smackVersion",
+    ivy"org.igniterealtime.smack:smack-tcp:$smackVersion",
     ivy"org.rogach::scallop:4.1.0",
     ivy"org.scala-lang.modules::scala-collection-contrib:0.2.2",
     ivy"org.slf4j:slf4j-jdk14:1.7.32",
diff --git a/benchmark-runner/main/src/de/fau/cs/mazstab/Mazstab.scala b/benchmark-runner/main/src/de/fau/cs/mazstab/Mazstab.scala
index 9745f52..b087481 100644
--- a/benchmark-runner/main/src/de/fau/cs/mazstab/Mazstab.scala
+++ b/benchmark-runner/main/src/de/fau/cs/mazstab/Mazstab.scala
@@ -86,11 +86,13 @@ class Mazstab(val context: MazstabContext) extends AutoCloseable {
 
   @SuppressWarnings(Array("scalafix:DisableSyntax.var"))
   private var throwable: Option[Throwable] = None
+  def getThrowable = throwable
 
   val hooks: Seq[MazstabHook] = {
     import de.fau.cs.mazstab.hooks._
     List(
-      new UserDirectoryHook(this)
+      new UserDirectoryHook(this),
+      new XmppHook(this),
     )
   }
 
@@ -477,6 +479,8 @@ class Mazstab(val context: MazstabContext) extends AutoCloseable {
     errLogFileStream.close()
 
     deleteIfExistsAndEmpty(errLogFile)
+
+    for (h <- hooks) h.close()
   }
 
   // TODO: Once https://github.com/com-lihaoyi/os-lib/pull/100 is
@@ -549,6 +553,10 @@ class Mazstab(val context: MazstabContext) extends AutoCloseable {
     val NEWLINE, NO_NEWLINE = Value
   }
 
+  def logv(verboseMessage: String) = if (conf.isVerbose) log(verboseMessage)
+
+  def logd(debugMessage: String) = if (conf.isDebug) log(debugMessage)
+
   def log(
       message: String,
       logOption: LogOption.Value = LogOption.NEWLINE,
diff --git a/benchmark-runner/main/src/de/fau/cs/mazstab/MazstabConfiguration.scala b/benchmark-runner/main/src/de/fau/cs/mazstab/MazstabConfiguration.scala
index ce6050e..1188edd 100644
--- a/benchmark-runner/main/src/de/fau/cs/mazstab/MazstabConfiguration.scala
+++ b/benchmark-runner/main/src/de/fau/cs/mazstab/MazstabConfiguration.scala
@@ -63,10 +63,12 @@ case class MazstabUserConfiguration(
     outDirectory: Path,
     myDomains: Option[List[String]],
     debugRoot: Option[Path],
+    xmppNotification: Option[de.fau.cs.mazstab.hooks.XmppHookUserConfiguration],
 )
 
 object MazstabConfigurationYamlProtocol extends DefaultYamlProtocol {
-  implicit val mazstabUserConfigurationFormat = yamlFormat3(
+  import de.fau.cs.mazstab.hooks.XmppHookUserConfigurationProtocol._
+  implicit val mazstabUserConfigurationFormat = yamlFormat4(
     MazstabUserConfiguration
   )
 }
@@ -99,7 +101,7 @@ object MazstabConfiguration {
 
   val xdgConfigDirectory = os.Path(mazstabXdgProjectDirectories.configDir)
 
-  def getUserConfiguration(): Option[MazstabUserConfiguration] = {
+  lazy val userConfiguration: Option[MazstabUserConfiguration] = {
     val userConfigurationYaml = xdgConfigDirectory / "config.yml"
 
     if (userConfigurationYaml.toIO.isFile) {
@@ -114,13 +116,13 @@ object MazstabConfiguration {
     }
   }
 
-  lazy val debugRoot: os.Path = getUserConfiguration()
+  lazy val debugRoot: os.Path = userConfiguration
     .map(_.debugRoot)
     .flatten
     .getOrElse(pathOf("~/repos/uni/mazstab"))
 
   def getOutDirectory(mazstabRootDirectory: Path): Path =
-    getUserConfiguration() map {
+    userConfiguration map {
       _.outDirectory
     } getOrElse (mazstabRootDirectory / "out")
 
@@ -140,7 +142,7 @@ object MazstabConfiguration {
     if (nameComponents.length == 1) { dnsName }
     else {
       val domainpart = nameComponents.drop(1).mkString(".")
-      val shorten = getUserConfiguration() map {
+      val shorten = userConfiguration map {
         _.myDomains
       } map {
         _.contains(domainpart)
@@ -154,7 +156,7 @@ object MazstabConfiguration {
     }
   }
 
-  lazy val hostNameForExperimentDirectory: String = {
+  lazy val mazstabHostname: String = {
     val canonicalHostName =
       java.net.InetAddress.getLocalHost().getCanonicalHostName()
     maybeShortenDnsName(canonicalHostName)
@@ -172,7 +174,7 @@ class MazstabConfiguration(arguments: Seq[String])
   lazy val defaultHostExperimentDirectory =
     MazstabConfiguration.getOutDirectory(
       rootDirectory()
-    ) / MazstabConfiguration.hostNameForExperimentDirectory
+    ) / MazstabConfiguration.mazstabHostname
 
   lazy val defaultExperimentDirectory = defaultHostExperimentDirectory / Mazstab
     .getTimestamp()
@@ -307,6 +309,12 @@ class MazstabConfiguration(arguments: Seq[String])
     default = Some(false)
   )
 
+  val verbose = tally()
+
+  def isVerbose = verbose() > 0
+
+  def isDebug = verbose() > 1
+
   val verifyResult = opt[VerifyResult.Value](
     default = Some(if (extended()) VerifyResult.always else VerifyResult.once),
     descr =
diff --git a/benchmark-runner/main/src/de/fau/cs/mazstab/hooks/MazstabHook.scala b/benchmark-runner/main/src/de/fau/cs/mazstab/hooks/MazstabHook.scala
index 247055f..2b0114e 100644
--- a/benchmark-runner/main/src/de/fau/cs/mazstab/hooks/MazstabHook.scala
+++ b/benchmark-runner/main/src/de/fau/cs/mazstab/hooks/MazstabHook.scala
@@ -2,10 +2,19 @@
 // Copyright © 2022 Florian Schmaus
 package de.fau.cs.mazstab.hooks
 
-class PreHookFailure {}
+class PreHookFailure
+
+object ThrowablePreHookFailure {
+  def apply(throwable: Throwable) = new ThrowablePreHookFailure(throwable)
+}
+
+class ThrowablePreHookFailure(val throwable: Throwable) extends PreHookFailure {
+  override def toString = throwable.toString()
+}
 
 abstract class MazstabHook {
   // Default hooks do nothing.
   def preHook(): Option[PreHookFailure] = None
   def postHook(): Unit = {}
+  def close(): Unit = {}
 }
diff --git a/benchmark-runner/main/src/de/fau/cs/mazstab/hooks/XmppHook.scala b/benchmark-runner/main/src/de/fau/cs/mazstab/hooks/XmppHook.scala
new file mode 100644
index 0000000..b37e763
--- /dev/null
+++ b/benchmark-runner/main/src/de/fau/cs/mazstab/hooks/XmppHook.scala
@@ -0,0 +1,165 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright © 2022 Florian Schmaus
+package de.fau.cs.mazstab.hooks
+
+import scala.util._
+
+import de.fau.cs.mazstab.Mazstab
+import de.fau.cs.mazstab.MazstabConfiguration
+
+import net.jcazevedo.moultingyaml._
+
+import org.jxmpp.jid.EntityBareJid
+import org.jivesoftware.smack.tcp.XMPPTCPConnection
+import org.jivesoftware.smack.tcp.XMPPTCPConnectionConfiguration
+
+object XmppHookUserConfigurationProtocol extends DefaultYamlProtocol {
+  implicit object EntityBareJidFormat extends YamlFormat[EntityBareJid] {
+    override def write(jid: EntityBareJid) = YamlString(jid.toString)
+    override def read(value: YamlValue) = value match {
+      case YamlString(s) =>
+        Try(org.jxmpp.jid.impl.JidCreate.entityBareFrom(s)) match {
+          case Success(jid) => jid
+          case Failure(throwable) =>
+            deserializationError(s"'$s' is not a valid JID", throwable)
+        }
+      case other => deserializationError(s"Invalid entity bare JID: $other")
+    }
+  }
+
+  implicit val xmppHookUserConfiguration = yamlFormat3(
+    XmppHookUserConfiguration
+  )
+}
+
+case class XmppHookUserConfiguration(
+    jid: EntityBareJid,
+    password: String,
+    recipient: EntityBareJid,
+)
+
+class XmppHook(mazstab: Mazstab) extends MazstabHook {
+  import java.util.concurrent.{Semaphore, TimeUnit}
+  import scala.concurrent._
+  import ExecutionContext.Implicits.global
+
+  val timeout = duration.Duration(300, duration.MILLISECONDS)
+  val initialExtraTimeout = duration.Duration(1000, duration.MILLISECONDS)
+
+  def hookConfigOption: Option[XmppHookUserConfiguration] =
+    MazstabConfiguration.userConfiguration.map(_.xmppNotification).flatten
+
+  val connectionOption = hookConfigOption map { hookConfig =>
+    Future {
+      val xmppConnectionConfig = XMPPTCPConnectionConfiguration
+        .builder()
+        .setXmppAddressAndPassword(hookConfig.jid, hookConfig.password)
+        .build()
+
+      val connection = new XMPPTCPConnection(xmppConnectionConfig)
+      blocking {
+        connection.connect().login()
+      }
+
+      mazstab.logv(s"$connection established")
+
+      connection
+    }
+  }
+
+  private def sendMessage(
+      connection: XMPPTCPConnection,
+      subject: String,
+      message: String,
+  ) = {
+    val recipient = hookConfigOption.get.recipient
+    val stanza = connection
+      .getStanzaFactory()
+      .buildMessageStanza()
+      .ofType(org.jivesoftware.smack.packet.Message.Type.headline)
+      .to(recipient)
+      .setSubject(subject)
+      .setBody(message)
+      .build()
+
+    val ackListenerAndSemOption = if (connection.isSmEnabled()) {
+      val ackSem = new Semaphore(0)
+      val ackListener = new org.jivesoftware.smack.StanzaListener() {
+        override def processStanza(
+            stanza: org.jivesoftware.smack.packet.Stanza
+        ) = ackSem.release()
+      }
+      connection.addStanzaIdAcknowledgedListener(
+        stanza.getStanzaId(),
+        ackListener,
+      )
+      Some(ackListener, ackSem)
+    } else None
+
+    mazstab.logd(s"Sending XMPP message to $recipient")
+
+    try {
+      connection.sendStanza(stanza)
+
+      ackListenerAndSemOption map { ackListenerAndSem =>
+        {
+          connection.requestSmAcknowledgement()
+
+          ackListenerAndSem._2.tryAcquire(
+            timeout.toMillis,
+            TimeUnit.MILLISECONDS,
+          )
+        }
+      }
+    } finally {
+      ackListenerAndSemOption map { ackListenerAndSem =>
+        connection.removeStanzaAcknowledgedListener(ackListenerAndSem._1)
+      }
+    }
+  }
+
+  override def preHook(): Option[PreHookFailure] =
+    connectionOption flatMap { connectionFuture =>
+      {
+        mazstab.log("Preparing to send pre-hook notification via XMPP")
+        val hostname = MazstabConfiguration.mazstabHostname
+
+        val subject = s"Mazstab run started on $hostname"
+        val message = s"$subject at ${Mazstab.getTimestamp()}"
+
+        import de.fau.cs.mazstab.hooks.ThrowablePreHookFailure
+
+        Try(
+          Await.result(connectionFuture, timeout + initialExtraTimeout)
+        ) match {
+          case Success(connection) => {
+            sendMessage(connection, subject, message)
+            None
+          }
+          case Failure(throwable) =>
+            Some(ThrowablePreHookFailure(throwable))
+        }
+      }
+    }
+
+  override def postHook() = connectionOption map { connectionFuture =>
+    {
+      val hostname = MazstabConfiguration.mazstabHostname
+      val throwable = mazstab.getThrowable
+      val status = if (throwable.isDefined) "failed" else "successful"
+
+      val subject = s"Mazstab run $status on $hostname"
+      val message = s"$subject. Took ${mazstab.context.getPrettyPrintedAge()}"
+
+      Try(Await.result(connectionFuture, timeout))
+        .map(connection => sendMessage(connection, subject, message))
+    }
+  }
+
+  override def close() = connectionOption map { connectionFuture =>
+    Try(
+      Await
+        .result(connectionFuture, timeout)
+    ).map(connection => connection.disconnect)
+  }
+}
-- 
GitLab