Skip to content

Commit fefa714

Browse files
authored
implement basic diffing logic (#8)
* implement basic diffing logic * remove obsolete apply method * minor tweaks * update README
1 parent 61b9841 commit fefa714

File tree

11 files changed

+213
-82
lines changed

11 files changed

+213
-82
lines changed

build.sbt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ val baseSettings = Seq(
2828
libraryDependencies ++= Seq(
2929
"org.typelevel" %% "cats-core" % "1.1.0",
3030
"org.typelevel" %% "cats-effect" % "0.10",
31+
"org.typelevel" %% "kittens" % "1.1.0",
3132
"org.scalatest" %% "scalatest" % "3.0.4" % "test",
3233
"com.github.julien-truffaut" %% "monocle-core" % monocleVersion,
3334
"com.github.julien-truffaut" %% "monocle-macro" % monocleVersion,

core/src/main/scala/com.itv/servicebox/algebra/ContainerController.scala

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,25 @@ import cats.MonadError
44
import cats.syntax.flatMap._
55
import cats.syntax.functor._
66
import com.itv.servicebox.algebra.ContainerController.ContainerGroups
7+
import Container._
78

89
abstract class ContainerController[F[_]](imageRegistry: ImageRegistry[F],
910
logger: Logger[F],
1011
network: Option[NetworkName])(implicit M: MonadError[F, Throwable]) {
1112
def containerGroups(spec: Service.Registered[F]): F[ContainerGroups]
1213

13-
//TODO: revisit this, as it hides parsing errors
14-
def runningContainers(spec: Service.Registered[F]): F[List[Container.Registered]] =
14+
def matchedContainers(spec: Service.Registered[F]): F[List[Registered]] =
1515
containerGroups(spec).map(_.matched)
16-
// containerGroups(spec).flatMap { groups =>
17-
// if (groups.notMatched.nonEmpty)
18-
// M.raiseError(
19-
// new IllegalStateException(
20-
// s"Some containers are mismatched:\n expected: ${spec.toSpec}\n actual: ${groups.notMatched.head.toSpec}"))
21-
// else
22-
// M.pure(groups.matched)
23-
// }
2416

25-
protected def startContainer(serviceRef: Service.Ref, container: Container.Registered): F[Unit]
17+
protected def startContainer(serviceRef: Service.Ref, container: Registered): F[Unit]
2618

27-
def fetchImageAndStartContainer(serviceRef: Service.Ref, container: Container.Registered): F[Unit] =
19+
def fetchImageAndStartContainer(serviceRef: Service.Ref, container: Registered): F[Unit] =
2820
imageRegistry.fetchUnlessExists(container.imageName) >> startContainer(serviceRef, container)
2921

30-
def removeContainer(serviceRef: Service.Ref, container: Container.Ref): F[Unit]
22+
def removeContainer(serviceRef: Service.Ref, container: Ref): F[Unit]
3123
}
3224
object ContainerController {
33-
case class ContainerGroups(matched: List[Container.Registered], notMatched: List[Container.Registered])
25+
case class ContainerGroups(matched: List[Registered], notMatched: List[(Registered, Diff)])
3426
object ContainerGroups {
3527
val Empty = ContainerGroups(Nil, Nil)
3628
}

core/src/main/scala/com.itv/servicebox/algebra/ServiceController.scala

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ class ServiceController[F[_]](logger: Logger[F],
2121
containerGroups <- ctrl.containerGroups(registered)
2222

2323
_ <- logger.debug(
24-
s"found ${containerGroups.notMatched.size} running containers which do not match the current spec for ${spec.ref}: ${containerGroups.notMatched} ...")
24+
s"found ${containerGroups.notMatched.size} running containers which do not match the current spec for ${spec.ref}")
25+
_ <- containerGroups.notMatched.map(_._2).traverse { diff =>
26+
logger.debug(s"diff: ${diff.show}")
27+
}
2528

26-
toStop = containerGroups.notMatched
29+
toStop = containerGroups.notMatched.map(_._1)
2730
_ <- toStop.traverse(c => ctrl.removeContainer(registered.ref, c.ref))
2831

2932
toStart = registered.containers.filterNot(c => containerGroups.matched.exists(_.ref == c.ref))
@@ -50,7 +53,7 @@ class ServiceController[F[_]](logger: Logger[F],
5053
def rmUpdatingRegistry(serviceRef: Service.Ref, container: Container.Registered) =
5154
ctrl.removeContainer(serviceRef, container.ref) >> registry.deregister(serviceRef, container.ref).void
5255
for {
53-
containers <- ctrl.runningContainers(service)
56+
containers <- ctrl.matchedContainers(service)
5457
_ <- logger.info(s"found ${containers.size} containers to delete ...")
5558
_ <- containers.traverse(rmUpdatingRegistry(service.ref, _))
5659
} yield ()

core/src/main/scala/com.itv/servicebox/algebra/package.scala

Lines changed: 127 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,12 @@ import java.util.concurrent.atomic.AtomicReference
66

77
import cats.data.{NonEmptyList, StateT}
88
import cats.effect.{Effect, IO}
9-
import cats.instances.list._
10-
import cats.instances.map._
11-
import cats.instances.string._
12-
import cats.instances.either._
9+
import cats.instances.all._
1310
import cats.syntax.all._
14-
import cats.{ApplicativeError, Monad, Show}
11+
import cats.{ApplicativeError, Eq, Monad, Show}
1512
import monocle.function.all._
1613

17-
import scala.concurrent.duration.{Duration, FiniteDuration}
18-
import scala.concurrent.{Await, ExecutionContext, Future}
14+
import scala.concurrent.duration.FiniteDuration
1915

2016
package object algebra {
2117
private val L = Lenses
@@ -36,6 +32,12 @@ package object algebra {
3632
case class BindMount(from: Path, to: Path, readOnly: Boolean = false)
3733

3834
object BindMount {
35+
implicit val pathEq: Eq[Path] = Eq.by(_.toAbsolutePath)
36+
implicit val eq: Eq[BindMount] = Eq.fromUniversalEquals[BindMount] //cats.derived.semi.eq[BindMount]
37+
implicit val show: Show[BindMount] = Show.show { bm =>
38+
s"${bm.from} -> ${bm.to} ${if (bm.readOnly) "[RO]" else ""}"
39+
}
40+
3941
def fromTmpFileContent[F[_]](baseDir: Path)(to: Path, ro: Boolean = false)(
4042
files: (String, Array[Byte])*)(implicit E: Effect[F], M: Monad[F]): F[BindMount] = {
4143

@@ -57,7 +59,6 @@ package object algebra {
5759
}
5860
}
5961

60-
//TODO: provide some builder methods
6162
private[algebra] sealed trait Container {
6263
def imageName: String
6364
def ref(ref: Service.Ref): Container.Ref = Container.Ref(s"${ref.show}/$imageName")
@@ -73,6 +74,15 @@ package object algebra {
7374
}
7475

7576
object PortSpec {
77+
78+
val onlyInternalEq: Eq[PortSpec] = Eq.by(_.internalPort)
79+
80+
implicit val show: Show[PortSpec] = Show.show {
81+
case PortSpec.Assign(internal, host) => s"$host:$internal"
82+
case PortSpec.AutoAssign(internal) => s":$internal"
83+
}
84+
implicit val eq: Eq[PortSpec] = Eq.fromUniversalEquals[PortSpec] //cats.derived.semi.eq[PortSpec]
85+
7686
case class AutoAssign(internalPort: Int) extends PortSpec {
7787
override def autoAssigned = true
7888
}
@@ -116,7 +126,7 @@ package object algebra {
116126
ref(serviceRef),
117127
imageName,
118128
env,
119-
mappings.toSet,
129+
mappings,
120130
command,
121131
mounts,
122132
name
@@ -125,12 +135,7 @@ package object algebra {
125135
}
126136

127137
object Spec {
128-
def apply(imageName: String,
129-
env: Map[String, String],
130-
ports: Set[Int],
131-
command: Option[NonEmptyList[String]],
132-
mounts: Option[NonEmptyList[BindMount]]): Spec =
133-
Spec(imageName, env, ports.map(PortSpec.autoAssign), command, mounts)
138+
implicit val eq: Eq[Spec] = cats.derived.semi.eq[Spec]
134139
}
135140

136141
case class Registered(ref: Container.Ref,
@@ -152,6 +157,109 @@ package object algebra {
152157
).withAbsolutePaths
153158
}
154159

160+
case class Diff(toNel: NonEmptyList[Diff.Entry])
161+
162+
object Diff {
163+
implicit val show: Show[Diff] = Show.show[Diff](_.toNel.mkString_("\n", "\n", "\n"))
164+
165+
case class Entry(fieldName: String, message: String)
166+
object Entry {
167+
168+
implicit val show: Show[Entry] = Show.show(e => s" - ${e.fieldName}: ${e.message}")
169+
170+
def apply[T](fieldName: String, actual: T, expected: T)(implicit eq: Eq[T], diff: DiffShow[T]): Option[Entry] =
171+
if (actual === expected) None
172+
else Some(Entry(fieldName, diff.showDiff(actual, expected)))
173+
}
174+
175+
def apply(a: Container.Spec, b: Container.Spec): Option[Diff] = {
176+
implicit val portSpecEq: Eq[PortSpec] = PortSpec.onlyInternalEq
177+
178+
if (a === b) None
179+
else {
180+
NonEmptyList
181+
.fromList(
182+
List(
183+
Entry("imageName", a.imageName, b.imageName),
184+
Entry("env", a.env, b.env),
185+
Entry("ports", a.ports, b.ports),
186+
Entry("command", a.command, b.command),
187+
Entry("mounts", a.mounts, b.mounts)
188+
).flatten)
189+
.map(Diff(_))
190+
}
191+
}
192+
}
193+
194+
trait DiffShow[T] {
195+
def showDiff(actual: T, expected: T): String
196+
}
197+
object DiffShow {
198+
import cats.syntax.show._
199+
200+
case class Report(missing: Option[NonEmptyList[String]],
201+
unexpected: Option[NonEmptyList[String]],
202+
different: Option[NonEmptyList[String]])
203+
204+
object Report {
205+
def fromIterables[A: Show](missing: Iterable[A], unexpected: Iterable[A], different: Iterable[A]) =
206+
Report(
207+
NonEmptyList.fromList(missing.toList.map(_.show)),
208+
NonEmptyList.fromList(unexpected.toList.map(_.show)),
209+
NonEmptyList.fromList(different.toList.map(_.show))
210+
)
211+
212+
implicit val show: Show[Report] = Show.show[Report] { r =>
213+
def fmtNel(nel: NonEmptyList[String]) = nel.mkString_("\n", "\n", "")
214+
List(
215+
r.missing.map(nel => s" Unexpected: ${fmtNel(nel)}"),
216+
r.different.map(nel => s" Mismatches: ${fmtNel(nel)}"),
217+
r.unexpected.map(nel => s" Unexpected: ${fmtNel(nel)}")
218+
).flatten.mkString("\n", "\n", "")
219+
}
220+
}
221+
222+
def instance[T](f: (T, T) => String): DiffShow[T] = new DiffShow[T] {
223+
override def showDiff(actual: T, expected: T): String = f(actual, expected)
224+
}
225+
226+
implicit def mapShowDiff[K, V](implicit kShow: Show[K], vShow: DiffShow[V]): DiffShow[Map[K, V]] =
227+
instance[Map[K, V]] { (actual, expected) =>
228+
val mismatches = expected.toList.flatMap {
229+
case (k, v) =>
230+
actual.get(k).filter(_ != v).map(v1 => s" ${k.show}: ${vShow.showDiff(v1, v)}")
231+
}
232+
233+
Report
234+
.fromIterables(
235+
(expected.keySet -- actual.keySet).map(_.show),
236+
(actual.keySet -- expected.keySet).map(_.show),
237+
mismatches
238+
)
239+
.show
240+
}
241+
242+
implicit def showDiff[A: Show]: DiffShow[A] = instance[A]((a, b) => s"${a.show}${b.show}")
243+
244+
implicit def setShowDiff[A: Show]: DiffShow[Set[A]] = instance[Set[A]] { (actual, expected) =>
245+
Report.fromIterables(expected -- actual, actual -- expected, Nil).show
246+
}
247+
248+
implicit def nelShowDiff[A: Show]: DiffShow[NonEmptyList[A]] = { (actual, expected) =>
249+
def toMap(nel: NonEmptyList[A]) = nel.toList.zipWithIndex.map(_.swap).toMap
250+
mapShowDiff[Int, A].showDiff(toMap(actual), toMap(expected))
251+
}
252+
253+
implicit def optShowDiff[A: Show]: DiffShow[Option[A]] = instance { (a, b) =>
254+
(a, b) match {
255+
case (Some(x), Some(y)) => showDiff[A].showDiff(x, y)
256+
case (None, Some(y)) => s" none [expected: ${y.show}]"
257+
case (Some(x), None) => s"${x.show} [not expected]"
258+
case _ => throw new InternalError("unreachable code")
259+
}
260+
}
261+
}
262+
155263
trait Matcher[Repr] {
156264
def apply(matched: Repr, expected: Container.Registered): Matcher.Result[Repr]
157265
}
@@ -162,20 +270,19 @@ package object algebra {
162270
def actual: Container.Registered
163271
def isSuccess: Boolean
164272
}
273+
165274
object Result {
166275
def apply[Repr](matched: Repr, expected: Container.Registered)(actual: Container.Registered): Result[Repr] =
167-
if (expected.toSpec == actual.toSpec)
168-
Success(matched, expected, actual)
169-
else Mismatch(matched, expected, actual)
276+
Diff(actual.toSpec, expected.toSpec)
277+
.fold[Result[Repr]](Success(matched, expected, actual))(Mismatch(matched, expected, actual, _))
170278
}
171279

172280
case class Success[Repr](matched: Repr, expected: Container.Registered, actual: Container.Registered)
173281
extends Result[Repr] {
174282
override val isSuccess = true
175283
}
176284

177-
//TODO: consider adding some diffing here
178-
case class Mismatch[Repr](matched: Repr, expected: Container.Registered, actual: Container.Registered)
285+
case class Mismatch[Repr](matched: Repr, expected: Container.Registered, actual: Container.Registered, diff: Diff)
179286
extends Result[Repr] {
180287
override val isSuccess = false
181288
}

core/src/test/scala/com/itv/servicebox/fake/ContainerController.scala

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.itv.servicebox.algebra._
1111
import org.scalatest.Matchers._
1212
import ContainerController.{ContainerStates, ContainerWithState}
1313
import cats.MonadError
14+
import cats.data.NonEmptyList
1415
import cats.effect.Effect
1516
import cats.syntax.flatMap._
1617
import cats.syntax.functor._
@@ -24,18 +25,25 @@ class ContainerController[F[_]](
2425

2526
private val containersByRef = new AtomicReference[ContainerStates](initialState)
2627

27-
def containerGroups(spec: Service.Registered[F]) =
28+
def containerGroups(spec: Service.Registered[F]) = {
29+
import PortSpec.onlyInternalEq
30+
import Container.Diff
31+
import Diff.Entry
32+
2833
for {
2934
containers <- spec.containers.toList
3035
.traverse[F, Option[ContainerWithState]] { c =>
3136
val ref = c.ref(spec.ref)
32-
E.delay(containersByRef.get).map(_.get(ref).filter(_.container.toSpec == c.toSpec))
37+
E.delay(containersByRef.get).map(_.get(ref).filter(_.container.toSpec === c.toSpec))
3338
}
3439
.map(_.flatten)
3540
} yield {
3641
val (running, notRunning) = containers.partition(_.isRunning)
37-
ContainerGroups(running.map(_.container), notRunning.map(_.container))
42+
43+
ContainerGroups(running.map(_.container),
44+
notRunning.map(_.container -> Diff(NonEmptyList.of(Entry("diff-suppressed", "...")))))
3845
}
46+
}
3947

4048
override protected def startContainer(serviceRef: Service.Ref, container: Container.Registered): F[Unit] =
4149
for {

0 commit comments

Comments
 (0)