diff --git a/.dockerignore b/.dockerignore index 99fc118..684f5bf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,5 +2,4 @@ !project/build.properties !project/plugins.sbt !src/ -!build.sbt -!Dockerfile \ No newline at end of file +!build.sbt \ No newline at end of file diff --git a/.sdkmanrc b/.sdkmanrc index 8ed09c7..4a87479 100644 --- a/.sdkmanrc +++ b/.sdkmanrc @@ -1,2 +1,2 @@ java=17.0.3-tem -sbt=1.6.2 +sbt=1.7.1 diff --git a/Dockerfile b/Dockerfile index f4eabe1..016f45b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,7 @@ -FROM eclipse-temurin:17.0.3_7-jre-alpine as build -RUN set -euxo pipefail; \ - apk update; \ - apk add --no-cache \ - bash \ - ca-certificates \ - ; -ADD https://github.com/sbt/sbt/releases/download/v1.6.2/sbt-1.6.2.tgz /tmp/sbt-1.6.2.tgz -RUN set -euxo pipefail; \ - tar --extract --gzip --file /tmp/sbt-1.6.2.tgz --directory /opt; \ - rm -rf /tmp/sbt-1.6.2.tgz; \ - chown -hR root:root /opt/sbt -WORKDIR /opt/sbt -RUN set -euxo pipefail; \ - /opt/sbt/bin/sbt sbtVersion -COPY --chown=root:root . /tmp/bandwhichd-server/ +FROM sbtscala/scala-sbt:eclipse-temurin-17.0.3_1.7.1_3.1.3 as build WORKDIR /tmp/bandwhichd-server -RUN /opt/sbt/bin/sbt assembly +COPY --chown=sbtuser:sbtuser . ./ +RUN sbt assembly FROM eclipse-temurin:17.0.3_7-jre-alpine LABEL org.opencontainers.image.authors="neuland Open Source Maintainers " @@ -26,11 +12,11 @@ LABEL org.opencontainers.image.vendor="neuland – Büro für Informatik GmbH" LABEL org.opencontainers.image.licenses="Apache-2.0" LABEL org.opencontainers.image.title="bandwhichd-server" LABEL org.opencontainers.image.description="bandwhichd server collecting measurements and calculating statistics" -LABEL org.opencontainers.image.version="0.6.0-rc2" +LABEL org.opencontainers.image.version="0.6.0-rc3" USER guest ENTRYPOINT ["/opt/java/openjdk/bin/java"] CMD ["-jar", "/opt/bandwhichd-server.jar"] EXPOSE 8080 HEALTHCHECK --interval=5s --timeout=1s --start-period=2s --retries=2 \ CMD wget --spider http://localhost:8080/v1/health || exit 1 -COPY --from=build --chown=root:root /tmp/bandwhichd-server/target/scala-3.1.2/bandwhichd-server-assembly-0.6.0-rc2.jar /opt/bandwhichd-server.jar \ No newline at end of file +COPY --from=build --chown=root:root /tmp/bandwhichd-server/target/scala-3.1.3/bandwhichd-server-assembly-0.6.0-rc3.jar /opt/bandwhichd-server.jar \ No newline at end of file diff --git a/build.sbt b/build.sbt index 7c18781..f55addc 100644 --- a/build.sbt +++ b/build.sbt @@ -2,8 +2,8 @@ lazy val root = (project in file(".")) .settings( organization := "de.neuland-bfi", name := "bandwhichd-server", - version := "0.6.0-rc2", - scalaVersion := "3.1.2", + version := "0.6.0-rc3", + scalaVersion := "3.1.3", Compile / scalaSource := baseDirectory.value / "src" / "main" / "scala", Test / scalaSource := baseDirectory.value / "src" / "test" / "scala", Test / fork := true, diff --git a/project/build.properties b/project/build.properties index 4ff6415..b1e589d 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.6.2 \ No newline at end of file +sbt.version=1.7.1 \ No newline at end of file diff --git a/src/main/scala/de/neuland/bandwhichd/server/domain/stats/Host.scala b/src/main/scala/de/neuland/bandwhichd/server/domain/stats/Host.scala index 52a8de5..0d6c5e7 100644 --- a/src/main/scala/de/neuland/bandwhichd/server/domain/stats/Host.scala +++ b/src/main/scala/de/neuland/bandwhichd/server/domain/stats/Host.scala @@ -30,21 +30,21 @@ case class UnidentifiedHost( } object UnidentifiedHost { - type HostId = HostId.HostHostId + type HostId = HostId.Host } sealed trait IdentifiedHost[+I <: HostId] extends AnyHost[I] -sealed trait MachineIdHost extends IdentifiedHost[HostId.MachineIdHostId] { +sealed trait MachineIdHost extends IdentifiedHost[HostId.MachineId] { def hostId: MachineIdHost.HostId } object MachineIdHost { - type HostId = HostId.MachineIdHostId + type HostId = HostId.MachineId } case class MonitoredHost( - hostId: HostId.MachineIdHostId, + hostId: HostId.MachineId, agentIds: Set[AgentId], hostname: Hostname, additionalHostnames: Set[Hostname], diff --git a/src/main/scala/de/neuland/bandwhichd/server/domain/stats/HostId.scala b/src/main/scala/de/neuland/bandwhichd/server/domain/stats/HostId.scala index baf7538..e39b81e 100644 --- a/src/main/scala/de/neuland/bandwhichd/server/domain/stats/HostId.scala +++ b/src/main/scala/de/neuland/bandwhichd/server/domain/stats/HostId.scala @@ -1,7 +1,7 @@ package de.neuland.bandwhichd.server.domain.stats -import com.comcast.ip4s.{Host, Hostname, IDN} -import de.neuland.bandwhichd.server.domain.MachineId +import com.comcast.ip4s.{Host => Ip4sHost, Hostname, IDN} +import de.neuland.bandwhichd.server.domain.{MachineId => BandwhichdMachineId} import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets.UTF_8 @@ -13,19 +13,19 @@ sealed trait HostId { } object HostId { - case class MachineIdHostId(machineId: MachineId) extends HostId { + case class MachineId(machineId: BandwhichdMachineId) extends HostId { override def uuid: UUID = machineId.value } - case class HostHostId(host: Host) extends HostId { + case class Host(host: Ip4sHost) extends HostId { override def uuid: UUID = UUID.nameUUIDFromBytes(host.toString.getBytes(UTF_8)) } - def apply(machineId: MachineId): MachineIdHostId = - MachineIdHostId(machineId) + def apply(machineId: BandwhichdMachineId): MachineId = + MachineId(machineId) - def apply(host: Host): HostHostId = - HostHostId(host) + def apply(host: Ip4sHost): Host = + Host(host) } diff --git a/src/main/scala/de/neuland/bandwhichd/server/domain/stats/Stats.scala b/src/main/scala/de/neuland/bandwhichd/server/domain/stats/Stats.scala index 8368aeb..2868206 100644 --- a/src/main/scala/de/neuland/bandwhichd/server/domain/stats/Stats.scala +++ b/src/main/scala/de/neuland/bandwhichd/server/domain/stats/Stats.scala @@ -28,14 +28,31 @@ class Stats[L <: HostId, H <: AnyHost[L], R <: HostId] private ( def connections: Set[(L, R)] = bundles.values.flatMap { bundle => - bundle.remoteHostIds.map { remoteHostId => + bundle.connections.keys.map { remoteHostId => bundle.host.hostId -> remoteHostId } }.toSet + + def dropBefore(timestamp: Timing.Timestamp): Stats[L, H, R] = + new Stats( + bundles + .filterNot { case (_, bundle) => + bundle.lastSeenAt.instant.isBefore(timestamp.instant) + } + .view + .mapValues(bundle => + bundle.copy( + connections = bundle.connections.filterNot { case (_, connection) => + connection.lastSeenAt.instant.isBefore(timestamp.instant) + } + ) + ) + .toMap + ) } type AnyStats = Stats[HostId, AnyHost[HostId], HostId] -type MonitoredStats = Stats[HostId.MachineIdHostId, MonitoredHost, HostId] +type MonitoredStats = Stats[HostId.MachineId, MonitoredHost, HostId] object Stats { val defaultTimeframeDuration: Duration = Duration.ofHours(2) @@ -69,10 +86,10 @@ object Stats { interfaces, _ ) => - val hostId: HostId.MachineIdHostId = HostId(machineId) + val hostId: HostId.MachineId = HostId(machineId) val maybeBundle - : Option[Bundle[HostId.MachineIdHostId, MonitoredHost, HostId]] = + : Option[Bundle[HostId.MachineId, MonitoredHost, HostId]] = stats.bundles .get(hostId) .orElse { @@ -93,7 +110,7 @@ object Stats { interfaces = interfaces.toSet ), lastSeenAt = timing, - remoteHostIds = Set.empty + connections = Map.empty ) } { bundle => bundle.copy( @@ -121,7 +138,7 @@ object Stats { new Stats( stats.bundles + (bundle.host.hostId -> bundle.copy( lastSeenAt = timing.end, - remoteHostIds = bundle.remoteHostIds ++ connections.map { + connections = bundle.connections ++ connections.map { connection => val remoteHost: Host = connection.remoteSocket.value.host @@ -148,7 +165,9 @@ object Stats { _.host.hostId ) - remoteHostId + remoteHostId -> Bundle.Connection( + lastSeenAt = timing.end + ) } )) ) @@ -163,10 +182,10 @@ object Stats { new Stats( stats.bundles.view.mapValues { bundle => bundle.copy( - remoteHostIds = bundle.remoteHostIds.filter { - _ match - case _: HostId.MachineIdHostId => true - case HostId.HostHostId(ipAddress: IpAddress) => + connections = bundle.connections.filter { + _._1 match + case _: HostId.MachineId => true + case HostId.Host(ipAddress: IpAddress) => stats.monitoredNetworks.exists(_.contains(ipAddress)) case _ => false } @@ -176,10 +195,10 @@ object Stats { def unidentifiedRemoteHosts: Set[UnidentifiedHost] = stats.bundles.values.flatMap { bundle => - bundle.remoteHostIds.flatMap { - _ match - case HostId.HostHostId(host) => Some(UnidentifiedHost(host)) - case HostId.MachineIdHostId(_) => None + bundle.connections.flatMap { + _._1 match + case HostId.Host(host) => Some(UnidentifiedHost(host)) + case HostId.MachineId(_) => None } }.toSet @@ -187,9 +206,15 @@ object Stats { stats.hosts ++ stats.unidentifiedRemoteHosts } - private case class Bundle[L <: HostId, H <: AnyHost[L], R <: HostId]( + private[Stats] case class Bundle[L <: HostId, H <: AnyHost[L], R <: HostId]( host: H, lastSeenAt: Timing.Timestamp, - remoteHostIds: Set[R] + connections: Map[R, Bundle.Connection] ) + + private[Stats] object Bundle { + private[Stats] case class Connection( + lastSeenAt: Timing.Timestamp + ) + } } diff --git a/src/test/scala/de/neuland/bandwhichd/server/domain/stats/StatsSpec.scala b/src/test/scala/de/neuland/bandwhichd/server/domain/stats/StatsSpec.scala index f828132..fe082ab 100644 --- a/src/test/scala/de/neuland/bandwhichd/server/domain/stats/StatsSpec.scala +++ b/src/test/scala/de/neuland/bandwhichd/server/domain/stats/StatsSpec.scala @@ -13,6 +13,8 @@ import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.{AnyWordSpec, AsyncWordSpec} import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks import com.comcast.ip4s.Arbitraries.given +import de.neuland.bandwhichd.server.lib.time.Interval +import org.scalacheck.Gen import org.scalatest.Assertion import java.time.{Duration, Instant, ZoneOffset, ZonedDateTime} @@ -351,8 +353,146 @@ class StatsSpec ) } } + + "dropping" should { + "not keep host without update after drop" in { + // given + val ncTemplate = ncGen.sample.get + val nuTemplate = nuGen.sample.get + + val baseTiming = ncTemplate.timestamp.instant + + val nc = ncTemplate.copy( + timing = Timing.Timestamp(baseTiming), + interfaces = Seq.empty, + openSockets = Seq.empty + ) + val nu = nuTemplate.copy( + timing = Timing.Timeframe( + Interval(baseTiming.plusMillis(2), nuTemplate.timing.duration) + ) + ) + val stats: MonitoredStats = buildStats(nc, nu) + val timestamp = + Timing.Timestamp(baseTiming.plusSeconds(15)) + + // when + val result = stats.dropBefore(timestamp) + + // then + result.hosts shouldBe empty + } + + "keep host with configuration update after drop" in { + // given + val ncTemplate = ncGen.sample.get + + val baseTiming = ncTemplate.timestamp.instant + + val nc1 = ncTemplate.copy( + timing = Timing.Timestamp(baseTiming), + interfaces = Seq.empty, + openSockets = Seq.empty + ) + val nc2 = nc1.copy( + timing = Timing.Timestamp(baseTiming.plusSeconds(60)) + ) + val stats: MonitoredStats = buildStats(nc1, nc2) + val timestamp = + Timing.Timestamp(baseTiming.plusSeconds(15)) + + // when + val result = stats.dropBefore(timestamp) + + // then + result.hosts should not be empty + } + + "keep host with utilization update timeframe ending after drop" in { + // given + val ncTemplate = ncGen.sample.get + val nuTemplate = nuGen.sample.get.copy(agentId = ncTemplate.agentId) + + val baseTiming = ncTemplate.timestamp.instant + + val nc = ncTemplate.copy( + timing = Timing.Timestamp(baseTiming), + interfaces = Seq.empty, + openSockets = Seq.empty + ) + val nu = nuTemplate.copy( + timing = Timing.Timeframe( + Interval(baseTiming.plusMillis(4), nuTemplate.timing.duration) + ) + ) + val stats: MonitoredStats = buildStats(nc, nu) + val timestamp = + Timing.Timestamp(baseTiming.plusSeconds(5)) + + // when + val result = stats.dropBefore(timestamp) + + // then + result.hosts should not be empty + } + + "keep only connections from utilization update after drop" in { + // given + val host1 = host"host1" + val host2 = host"host2" + val host3 = host"host3" + + val ncTemplate = ncGen.sample.get + val nuTemplate = nuGen.sample.get.copy(agentId = ncTemplate.agentId) + val con1 = conGen.sample.get.copy( + remoteSocket = Remote(SocketAddress(host1, port"8080")) + ) + val con2 = conGen.sample.get.copy( + remoteSocket = Remote(SocketAddress(host2, port"8080")) + ) + val con3 = conGen.sample.get.copy( + remoteSocket = Remote(SocketAddress(host3, port"8080")) + ) + + val baseTiming = ncTemplate.timestamp.instant + + val nc = ncTemplate.copy( + timing = Timing.Timestamp(baseTiming), + interfaces = Seq.empty, + openSockets = Seq.empty + ) + val nuBefore = nuTemplate.copy( + timing = Timing.Timeframe( + Interval(baseTiming, nuTemplate.timing.duration) + ), + connections = Seq(con1, con2) + ) + val nuAfter = nuTemplate.copy( + timing = Timing.Timeframe( + Interval(baseTiming.plusSeconds(10), nuTemplate.timing.duration) + ), + connections = Seq(con2, con3) + ) + val stats: MonitoredStats = buildStats(nc, nuBefore, nuAfter) + val timestamp = + Timing.Timestamp(baseTiming.plusSeconds(15)) + + // when + val result = stats.dropBefore(timestamp) + + // then + result.connections.map(_._2) should contain theSameElementsAs Set( + HostId(con2.remoteSocket.value.host), + HostId(con3.remoteSocket.value.host) + ) + } + } } + private def ncGen = summon[Gen[Measurement.NetworkConfiguration]] + private def nuGen = summon[Gen[Measurement.NetworkUtilization]] + private def conGen = summon[Gen[Connection]] + private def buildStats(measurements: Measurement[Timing]*): MonitoredStats = measurements.foldLeft(Stats.empty) { case (stats, measurement) => stats.append(measurement)