├── .gitignore ├── .travis.yml ├── README.md ├── build.sbt ├── project ├── assembly.sbt ├── graph.sbt ├── packaging.sbt └── pgp.sbt └── src ├── main └── scala │ └── com │ └── jbrisbin │ └── docker │ ├── Docker.scala │ ├── DockerMessage.scala │ ├── SSL.scala │ └── package.scala └── test ├── resources └── logback.xml └── scala └── com └── jbrisbin └── docker └── DockerTests.scala /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | target 3 | project/target 4 | project/project 5 | .idea 6 | *.iml 7 | 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | sudo: required 3 | before_install: 4 | - sudo apt-get -o Dpkg::Options::="--force-confnew" install -y docker-engine 5 | services: 6 | - docker 7 | jdk: 8 | - oraclejdk8 9 | scala: 10 | - 2.11.8 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Akka HTTP Docker 2 | 3 | `akka-http-docker` is a Docker client that is based on Akka HTTP. It's aim is to provide a Scala and Akka Streams-centric approach to interacting with Docker. 4 | 5 | ### Building 6 | 7 | Use SBT: 8 | 9 | $ sbt package 10 | 11 | ### Running tests 12 | 13 | Before running the tests, you need the `alpine:latest` docker image: 14 | 15 | $ docker pull alpine:latest 16 | 17 | Then use SBT to run the tests: 18 | 19 | $ sbt test 20 | 21 | #### On Mac OS X 22 | 23 | If you are running Docker using [Docker Toolbox](https://docs.docker.com/mac/step_one/), 24 | then launch Docker Quickstart Terminal, navigate to this project's directory, and 25 | run `sbt` from there. 26 | 27 | ### Including as a Dependency 28 | 29 | To include the `akka-http-docker` in your project as a dependency, use the Sonatype Snapshot repositories: 30 | 31 | ```scala 32 | resolvers ++= Seq( 33 | Resolver.sonatypeRepo("snapshots") 34 | ), 35 | 36 | libraryDependencies ++= { 37 | Seq( 38 | // Akka HTTP Docker 39 | "com.jbrisbin.docker" %% "akka-http-docker" % "0.1.0-SNAPSHOT" 40 | ) 41 | } 42 | ``` 43 | 44 | ### Using 45 | 46 | The API will be simple in order to express the maximum amount of information with the least amount of static. To list all containers, and map the IDs of those containers to another processing chain, you would do the following: 47 | 48 | ```scala 49 | val docker = Docker() 50 | 51 | docker.containers() onComplete { 52 | case Success(c) => c.map(_.Id) 53 | case Failure(ex) => log.error(ex.getMessage, ex) 54 | } 55 | ``` 56 | 57 | #### Starting a Container 58 | 59 | To start a container, you may need to create one first. You can map the output of the create into another step using `flatMap`, though, so it can all be done in one step. 60 | 61 | To create and start a container, use something like this: 62 | 63 | ```scala 64 | val docker = Docker() 65 | 66 | docker.create(CreateContainer( 67 | Image="alpine", 68 | Volumes = Map("/Users/johndoe/src/myapp" -> "/usr/lib/myapp") 69 | )) 70 | .flatMap(c => docker.start(c.Id)) 71 | .map(c => c ! Exec(Seq("/usr/lib/myapp/bin/start.sh"))) 72 | ``` 73 | 74 | You can also use a `for` comprehension: 75 | 76 | ```scala 77 | val docker = Docker() 78 | 79 | for { 80 | c <- docker.create(CreateContainer(Image="alpine", Volumes = Map("/Users/johndoe/src/myapp" -> "/usr/lib/myapp"))) 81 | ref <- docker.start(c.Id) 82 | exec <- ref ! Exec(Seq("/usr/lib/myapp/bin/start.sh")) 83 | } yield exec 84 | ``` 85 | 86 | The `start()` method returns a `Future[ActorRef]` that represents your link to the container. To interact with a running container, send it messages using `!` or `?`. 87 | 88 | ### Contributions Welcome! 89 | 90 | This is a new project in its infancy. There is a LOT to do if this tool is to be useful to more than just a handful of use cases. If you have the time and inclination, please create a ticket or submit a PR with helpful changes that expand the client functionality and implement some new features that you need. 91 | 92 | ### License 93 | 94 | Apache 2.0 95 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import net.virtualvoid.sbt.graph.Plugin._ 2 | 3 | lazy val akkaHttpDocker = (project in file(".")) 4 | .enablePlugins(JavaAppPackaging) 5 | .settings( 6 | exportJars := true, 7 | scalaVersion := "2.11.8", 8 | 9 | organization := "com.jbrisbin.docker", 10 | name := "akka-http-docker", 11 | version := "0.1.0-SNAPSHOT", 12 | 13 | publishMavenStyle := true, 14 | publishTo := { 15 | val nexus = "https://oss.sonatype.org/" 16 | if (isSnapshot.value) 17 | Some("snapshots" at nexus + "content/repositories/snapshots") 18 | else 19 | Some("releases" at nexus + "service/local/staging/deploy/maven2") 20 | }, 21 | pomIncludeRepository := { _ => false }, 22 | pomExtra := ( 23 | https://github.com/jbrisbin/akka-http-docker/ 24 | 25 | 26 | Apache-2.0 27 | https://opensource.org/licenses/Apache-2.0 28 | repo 29 | 30 | 31 | 32 | git@github.com:jbrisbin/akka-http-docker.git 33 | scm:git:git@github.com:jbrisbin/akka-http-docker.git 34 | 35 | 36 | 37 | jbrisbin 38 | Jon Brisbin 39 | http://jbrisbin.com 40 | 41 | 42 | ), 43 | 44 | resolvers ++= Seq( 45 | Resolver.bintrayRepo("hseeberger", "maven") 46 | ), 47 | 48 | libraryDependencies ++= { 49 | val scalaLoggingVersion = "2.1.2" 50 | val jacksonVersion = "2.7.3" 51 | val akkaVersion = "2.4.5" 52 | val junitVersion = "4.12" 53 | val scalaTestVersion = "3.0.0-M15" 54 | 55 | Seq( 56 | // Logging 57 | "com.typesafe.scala-logging" %% "scala-logging-slf4j" % scalaLoggingVersion, 58 | 59 | // Jackson JSON 60 | "com.fasterxml.jackson.module" %% "jackson-module-scala" % jacksonVersion, 61 | "org.json4s" %% "json4s-jackson" % "3.3.0", 62 | 63 | // Akka 64 | "com.typesafe.akka" %% "akka-stream" % akkaVersion, 65 | "com.typesafe.akka" %% "akka-http-core" % akkaVersion, 66 | "de.heikoseeberger" %% "akka-http-json4s" % "1.6.0", 67 | 68 | // SSL 69 | "org.apache.httpcomponents" % "httpclient" % "4.5.2", 70 | "org.bouncycastle" % "bcpkix-jdk15on" % "1.54", 71 | 72 | // Testing 73 | "junit" % "junit" % junitVersion % "test", 74 | "com.novocode" % "junit-interface" % "0.11" % "test", 75 | "org.hamcrest" % "hamcrest-library" % "1.3" % "test", 76 | "org.scalatest" %% "scalatest" % scalaTestVersion % "test", 77 | "ch.qos.logback" % "logback-classic" % "1.1.7" % "test" 78 | ) 79 | }, 80 | 81 | ivyScala := ivyScala.value map { 82 | _.copy(overrideScalaVersion = true) 83 | }, 84 | 85 | graphSettings 86 | ) 87 | -------------------------------------------------------------------------------- /project/assembly.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.3") 2 | -------------------------------------------------------------------------------- /project/graph.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.7.5") 2 | -------------------------------------------------------------------------------- /project/packaging.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.0.6") -------------------------------------------------------------------------------- /project/pgp.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.0.0") 2 | -------------------------------------------------------------------------------- /src/main/scala/com/jbrisbin/docker/Docker.scala: -------------------------------------------------------------------------------- 1 | package com.jbrisbin.docker 2 | 3 | import java.net.URI 4 | import java.nio.ByteOrder 5 | import java.nio.charset.Charset 6 | import javax.net.ssl.SSLContext 7 | 8 | import akka.NotUsed 9 | import akka.actor.ActorDSL._ 10 | import akka.actor.{ActorRef, ActorSystem, Props, Status} 11 | import akka.http.scaladsl.client.RequestBuilding 12 | import akka.http.scaladsl.model.StatusCodes._ 13 | import akka.http.scaladsl.model.Uri.Path./ 14 | import akka.http.scaladsl.model._ 15 | import akka.http.scaladsl.unmarshalling.Unmarshal 16 | import akka.http.scaladsl.{ConnectionContext, Http} 17 | import akka.stream.ActorMaterializer 18 | import akka.stream.actor.ActorPublisher 19 | import akka.stream.scaladsl._ 20 | import akka.util.ByteString 21 | import com.fasterxml.jackson.databind.ObjectMapper 22 | import com.fasterxml.jackson.module.scala.DefaultScalaModule 23 | import de.heikoseeberger.akkahttpjson4s.Json4sSupport._ 24 | import org.json4s.jackson.JsonMethods 25 | import org.json4s.{DefaultFormats, Formats, Serialization, jackson} 26 | import org.slf4j.LoggerFactory 27 | 28 | import scala.concurrent.{ExecutionContext, Future} 29 | 30 | /** 31 | * Docker client that uses the HTTP remote API to interact with the Docker daemon. 32 | * 33 | * @see [[https://docs.docker.com/engine/reference/api/docker_remote_api_v1.22]] 34 | */ 35 | class Docker(dockerHost: URI, sslctx: SSLContext) 36 | (implicit val system: ActorSystem, 37 | implicit val materializer: ActorMaterializer, 38 | implicit val execCtx: ExecutionContext) { 39 | 40 | implicit val formats: Formats = DefaultFormats 41 | implicit val serialization: Serialization = jackson.Serialization 42 | 43 | val logger = LoggerFactory.getLogger(classOf[Docker]) 44 | val charset = Charset.defaultCharset().toString 45 | val mapper = { 46 | val m = new ObjectMapper() 47 | m.registerModule(DefaultScalaModule) 48 | m 49 | } 50 | val docker = Http().outgoingConnectionHttps(dockerHost.getHost, dockerHost.getPort, ConnectionContext.https(sslctx)) 51 | 52 | /** 53 | * Create a Docker container described by the [[CreateContainer]]. 54 | * 55 | * @param container describes how the container should be configured 56 | * @return the results of an [[inspect()]], namely a [[Future]] of [[ContainerInfo]] 57 | */ 58 | def create(container: CreateContainer): Future[ContainerInfo] = { 59 | var params = Map[String, String]() 60 | 61 | container.Name match { 62 | case "" | null => 63 | case n => params += ("name" -> n) 64 | } 65 | 66 | val req = RequestBuilding.Post( 67 | Uri(path = /("containers") / "create", queryString = Some(Uri.Query(params).toString)), 68 | container 69 | ) 70 | request(req) 71 | .mapAsync(1)(_.to[Map[String, AnyRef]]) 72 | .map(m => { 73 | m.get("Warnings") match { 74 | case None | Some(null) => 75 | case Some(w) => w.asInstanceOf[Seq[String]].foreach(logger.warn) 76 | } 77 | m("Id").asInstanceOf[String] 78 | }) 79 | .mapAsync(1)(inspect) 80 | .runWith(Sink.head) 81 | } 82 | 83 | /** 84 | * Removes containers identified by their [[Container]] object. 85 | * 86 | * Used to chain this operation into a longer series of calls, where the list of [[Container]] is obtained from the daemon, probably using some filters applied. 87 | * 88 | * @param containers executes a DELETE for every [[Container]] in the source stream 89 | * @param volumes whether to delete volumes or not 90 | * @param force whether to force a shutdown of the container or not 91 | * @return a boolean indicating overall success of all deletions 92 | */ 93 | def remove(containers: Source[Container, _], 94 | volumes: Boolean = false, 95 | force: Boolean = false): Future[Boolean] = { 96 | var params = Map[String, String]() 97 | 98 | if (volumes) 99 | params += ("v" -> "true") 100 | if (force) 101 | params += ("force" -> "true") 102 | 103 | containers 104 | .map(c => { 105 | RequestBuilding.Delete( 106 | Uri(path = /("containers") / c.Id, queryString = Some(Uri.Query(params).toString)) 107 | ) 108 | }) 109 | .via(docker) 110 | .runFold(true)((last, resp) => resp.status match { 111 | case Success(_) => true 112 | case e@(ClientError(_) | ServerError(_)) => { 113 | logger.error(e.reason()) 114 | false 115 | } 116 | case _ => false 117 | }) 118 | } 119 | 120 | /** 121 | * Inspect a container and gather metadata about it. 122 | * 123 | * @param container the name or ID of the container to inspect 124 | * @return a [[Container]] that describes the container 125 | */ 126 | def inspect(container: String): Future[ContainerInfo] = { 127 | request(RequestBuilding.Get(Uri(path = /("containers") / container / "json"))) 128 | .mapAsync(1)(_.to[ContainerInfo]) 129 | .runWith(Sink.head) 130 | } 131 | 132 | /** 133 | * Start a container. 134 | * 135 | * @param container the name or ID of the container to start 136 | * @param detachKeys configure the keys recognized to detach 137 | * @return an [[ActorRef]] to which messages can be sent to interact with the running container 138 | */ 139 | def start(container: String, detachKeys: Option[String] = None): Future[ActorRef] = { 140 | var params = Map[String, String]() 141 | 142 | detachKeys match { 143 | case Some(k) => params += ("detachKeys" -> k) 144 | case None => 145 | } 146 | 147 | val req = RequestBuilding.Post( 148 | Uri(path = /("containers") / container / "start", queryString = Some(Uri.Query(params).toString)) 149 | ) 150 | request(req) 151 | .runWith(Sink.head) 152 | .map(ignored => startContainerActor(container)) 153 | } 154 | 155 | /** 156 | * Stop a container. 157 | * 158 | * @param container the name or ID of the container to stop 159 | * @param timeout timeout after which the container will be killed 160 | * @return `true`, indicating the container was stopped, `false` indicating it was *already* stopped, or a failure of the [[Future]] which indicates an error while trying to stop the container 161 | */ 162 | def stop(container: String, timeout: Option[Int] = None): Future[Boolean] = { 163 | var params = Map[String, String]() 164 | 165 | timeout foreach { 166 | t => params += ("t" -> t.toString) 167 | } 168 | 169 | val req = RequestBuilding.Post( 170 | Uri(path = /("containers") / container / "stop", queryString = Some(Uri.Query(params).toString)) 171 | ) 172 | requestStream(req) 173 | .runWith(Sink.head) 174 | .map(_.status match { 175 | case NoContent => true 176 | case NotModified => false 177 | case e@(ClientError(_) | ServerError(_)) => throw new IllegalStateException(e.reason()) 178 | case _ => ??? 179 | }) 180 | } 181 | 182 | /** 183 | * Execute an operation in the context of a container. 184 | * 185 | * @param container the name or ID of the container 186 | * @param ex description of the execution to perform 187 | * @return a [[Source]] which will have output published to it 188 | */ 189 | def exec(container: String, ex: Exec): Source[ExecOutput, NotUsed] = { 190 | request(RequestBuilding.Post(Uri(path = /("containers") / container / "exec"), ex)) 191 | .mapAsync(1)(_.to[Map[String, AnyRef]]) 192 | .flatMapConcat(m => { 193 | logger.debug("created exec: {}", m) 194 | 195 | m.get("Warnings") foreach { 196 | _.asInstanceOf[Seq[String]].foreach(logger.warn) 197 | } 198 | val execId = m("Id").asInstanceOf[String] 199 | 200 | requestStream(RequestBuilding.Post(Uri(path = /("exec") / execId / "start"), ExecStart(Detach = ex.Detach))) 201 | .flatMapConcat(resp => 202 | resp.status match { 203 | case OK => 204 | resp 205 | .entity 206 | .dataBytes 207 | .via(Framing.lengthField(4, 4, 1024 * 1024, ByteOrder.BIG_ENDIAN)) 208 | .map({ 209 | case b if b.isEmpty => ??? 210 | case b if b(0) == 1 => StdOut(b.drop(8)) 211 | case b if b(0) == 2 => StdErr(b.drop(8)) 212 | case _ => ??? 213 | }: PartialFunction[ByteString, ExecOutput]) 214 | case e@(ClientError(_) | ServerError(_)) => throw new IllegalStateException(e.reason) 215 | case status => throw new IllegalStateException(s"Unknown status $status") 216 | }) 217 | }) 218 | } 219 | 220 | /** 221 | * List available containers. 222 | * 223 | * @param all `true` to list all containers. Defaults to `false`. 224 | * @param limit Maximum number of containers to return. Defaults to `0`, which means everything. 225 | * @param since Only show containers created since the given container. 226 | * @param before Only show containers created before the given container. 227 | * @param size Show the size of the container as well. 228 | * @param filters Filter the list by the provided filters. 229 | * @return A [[Future]] that will eventually contain the `List` of [[Container]] that satisfies the query. 230 | */ 231 | def containers(all: Boolean = false, 232 | limit: Int = 0, 233 | since: String = null, 234 | before: String = null, 235 | size: Boolean = false, 236 | filters: Map[String, Seq[String]] = Map.empty): Future[List[Container]] = { 237 | var params = Map[String, String]() 238 | 239 | if (all) 240 | params += ("all" -> "true") 241 | if (limit > 0) 242 | params += ("limit" -> limit.toString) 243 | if (before ne null) 244 | params += ("before" -> before) 245 | if (since ne null) 246 | params += ("since" -> since) 247 | if (size) 248 | params += ("size" -> "true") 249 | if (filters.nonEmpty) 250 | params += ("filters" -> mapper.writeValueAsString(filters)) 251 | 252 | val req = RequestBuilding.Get(Uri( 253 | path = /("containers") / "json", 254 | queryString = Some(Uri.Query(params).toString) 255 | )) 256 | request(req) 257 | .mapAsync(1)(_.to[List[Container]]) 258 | .runWith(Sink.head) 259 | } 260 | 261 | /** 262 | * List available images. 263 | * 264 | * @param all 265 | * @param filter 266 | * @param filters 267 | * @return 268 | */ 269 | def images(all: Boolean = false, 270 | filter: String = null, 271 | filters: Map[String, Seq[String]] = Map.empty): Future[List[Image]] = { 272 | var params = Map[String, String]() 273 | 274 | if (all) 275 | params += ("all" -> "true") 276 | if (filter ne null) 277 | params += ("filter" -> filter) 278 | if (filters.nonEmpty) 279 | params += ("filters" -> JsonMethods.mapper.writeValueAsString(filters)) 280 | 281 | val req = RequestBuilding.Get(Uri( 282 | path = /("images") / "json", 283 | queryString = Some(Uri.Query(params).toString) 284 | )) 285 | request(req) 286 | .mapAsync(1)(_.to[List[Image]]) 287 | .runWith(Sink.head) 288 | } 289 | 290 | private[docker] def requestStream(req: HttpRequest): Source[HttpResponse, NotUsed] = { 291 | Source.single(req) 292 | .map(req => { 293 | logger.debug(req.toString) 294 | req 295 | }) 296 | .via(docker) 297 | } 298 | 299 | private[docker] def request(req: HttpRequest): Source[Unmarshal[ResponseEntity], NotUsed] = { 300 | requestStream(req) 301 | .mapAsync(1)(resp => resp.status match { 302 | case OK | Created | NoContent => Future.successful(Unmarshal(resp.entity)) 303 | case e@(ClientError(_) | ServerError(_)) => { 304 | logger.error("Request failed: {}", resp) 305 | resp 306 | .entity 307 | .dataBytes 308 | .runFold(ByteString.empty)(_ ++ _) 309 | .map(msg => throw new IllegalStateException(msg.decodeString(charset))) 310 | } 311 | case e => Future.failed(new RuntimeException(s"Unknown error $e")) 312 | }) 313 | } 314 | 315 | private[docker] def startContainerActor(containerId: String): ActorRef = { 316 | system.actorOf(Props(new Act with ActorPublisher[ExecOutput] { 317 | become { 318 | case Stop() => { 319 | val replyTo = sender() 320 | 321 | stop(containerId) onComplete { 322 | case util.Success(_) => 323 | replyTo ! containerId 324 | case util.Failure(_) => 325 | replyTo ! Status.Failure(new IllegalStateException(s"Container $containerId not stopped")) 326 | } 327 | context stop self 328 | } 329 | 330 | case ex: Exec => 331 | val replyTo = sender() 332 | 333 | exec(containerId, ex) 334 | .runFold((StdOut(ByteString()): StdOut, StdErr(ByteString()): StdErr))((acc, output) => { 335 | output match { 336 | case StdOut(bytes) => (StdOut(acc._1.bytes ++ bytes), acc._2) 337 | case StdErr(bytes) => (acc._1, StdErr(acc._2.bytes ++ bytes)) 338 | } 339 | }) 340 | .onSuccess { 341 | case (stdout, stderr) if stdout.bytes.nonEmpty => replyTo ! stdout.bytes 342 | case (stdout, stderr) if stderr.bytes.nonEmpty => { 343 | val msg = stderr.bytes.decodeString(charset) 344 | replyTo ! Status.Failure(new IllegalStateException(msg)) 345 | } 346 | case _ => ??? 347 | } 348 | 349 | case msg => logger.warn("unknown message: {}", msg) 350 | } 351 | }), containerId) 352 | } 353 | 354 | } 355 | 356 | object Docker { 357 | val dockerHostEnv = System.getenv("DOCKER_HOST") 358 | 359 | def apply(dockerHost: String = dockerHostEnv, sslctx: SSLContext = SSL.createSSLContext) 360 | (implicit system: ActorSystem, materializer: ActorMaterializer, execCtx: ExecutionContext): Docker = { 361 | new Docker(URI.create(dockerHost), sslctx) 362 | } 363 | } 364 | -------------------------------------------------------------------------------- /src/main/scala/com/jbrisbin/docker/DockerMessage.scala: -------------------------------------------------------------------------------- 1 | package com.jbrisbin.docker 2 | 3 | import akka.util.ByteString 4 | 5 | /** 6 | * @author Jon Brisbin 7 | */ 8 | sealed trait DockerMessage { 9 | } 10 | 11 | // Requests 12 | final case class HostConfig(Binds: Option[Seq[String]] = None, 13 | Links: Option[Seq[String]] = None, 14 | Memory: Option[BigInt] = None, 15 | MemorySwap: Option[BigInt] = None, 16 | MemoryReservation: Option[BigInt] = None, 17 | KernelMemory: Option[BigInt] = None, 18 | CpuShares: Option[BigInt] = None, 19 | CpuPeriod: Option[BigInt] = None, 20 | PortBindings: Option[Map[String, Seq[Map[String, String]]]] = None, 21 | PublishAllPorts: Boolean = true) 22 | 23 | final case class CreateContainer(Name: String = "", 24 | Hostname: Option[String] = None, 25 | Domainname: String = "", 26 | User: String = "", 27 | AttachStdin: Boolean = false, 28 | AttachStdout: Boolean = true, 29 | AttachStderr: Boolean = true, 30 | Tty: Boolean = false, 31 | OpenStdin: Boolean = false, 32 | StdinOnce: Boolean = false, 33 | Env: Option[Seq[String]] = None, 34 | Cmd: Option[Seq[String]] = None, 35 | Entrypoint: Option[String] = None, 36 | Image: String, 37 | Labels: Option[Map[String, String]] = None, 38 | Volumes: Map[String, String] = Map.empty, 39 | WorkingDir: String = "", 40 | NetworkDisabled: Boolean = false, 41 | MacAddress: String = "", 42 | ExposedPorts: Map[String, Port] = Map.empty, 43 | StopSignal: String = "SIGTERM", 44 | HostConfig: Option[HostConfig] = None) 45 | 46 | final case class Run(image: String) extends DockerMessage 47 | 48 | final case class Stop() extends DockerMessage 49 | 50 | final case class Exec(Cmd: Seq[String], 51 | AttachStdin: Boolean = false, 52 | AttachStdout: Boolean = true, 53 | AttachStderr: Boolean = true, 54 | DetachKeys: Option[String] = None, 55 | Detach: Boolean = false, 56 | Tty: Boolean = true) extends DockerMessage 57 | 58 | final case class ExecStart(Detach: Boolean = false, 59 | Tty: Boolean = false) extends DockerMessage 60 | 61 | trait ExecOutput extends DockerMessage 62 | 63 | final case class StdOut(bytes: ByteString) extends ExecOutput 64 | 65 | final case class StdErr(bytes: ByteString) extends ExecOutput 66 | 67 | final case class Complete() extends ExecOutput 68 | 69 | // Responses 70 | final case class Port(PrivatePort: Int, PublicPort: Int, Type: String = "tcp") 71 | 72 | final case class Network(IPAMConfig: Option[Map[String, String]], 73 | Links: Option[Seq[String]], 74 | Aliases: String, 75 | NetworkID: String, 76 | EndpointID: String, 77 | Gateway: String, 78 | IPAddress: String, 79 | IPPrefixLen: Int, 80 | IPv6Gateway: String, 81 | GlobalIPv6Address: String, 82 | GlobalIPv6PrefixLen: Int, 83 | MacAddress: String) 84 | 85 | final case class NetworkSettings(Networks: Option[Map[String, Network]]) 86 | 87 | final case class Container(Id: String = null, 88 | Names: Seq[String] = Seq.empty, 89 | Image: String = null, 90 | Cmd: Seq[String] = Seq.empty, 91 | Created: Long = 0, 92 | Status: String = null, 93 | Ports: Seq[Port] = Seq.empty, 94 | Labels: Option[Map[String, String]] = None, 95 | SizeRw: Option[Long] = None, 96 | SizeRootFs: Option[Long] = None, 97 | HostConfig: Map[String, String] = Map.empty, 98 | NetworkSettings: NetworkSettings) extends DockerMessage 99 | 100 | final case class ContainerState(Status: String = null, 101 | Running: Boolean = false, 102 | Paused: Boolean = false, 103 | Restarting: Boolean = false, 104 | OOMKilled: Boolean = false, 105 | Dead: Boolean = false, 106 | Pid: Int = 0, 107 | ExitCode: Int = 0, 108 | Error: String = null, 109 | StartedAt: String = null, 110 | FinishedAt: String = null) extends DockerMessage 111 | 112 | final case class ContainerConfig(Hostname: String = null, 113 | Domainname: String = null, 114 | User: String = null, 115 | AttachStdin: Boolean = false, 116 | AttachStdout: Boolean = true, 117 | AttachStderr: Boolean = true, 118 | ExposedPorts: Map[String, Port] = Map.empty, 119 | Tty: Boolean = true, 120 | OpenStdin: Boolean = false, 121 | StdinOnce: Boolean = false, 122 | Env: Seq[String] = Seq.empty, 123 | Cmd: Seq[String] = Seq.empty, 124 | Image: String = null, 125 | Volumes: Map[String, String] = Map.empty) extends DockerMessage 126 | 127 | final case class ContainerInfo(Id: String = null, 128 | Name: String = null, 129 | Path: String = null, 130 | Args: Seq[String] = Seq.empty, 131 | Created: Long = 0, 132 | State: ContainerState = null, 133 | Image: String = null, 134 | RestartCount: Int = 0, 135 | Driver: String = null, 136 | MountLabel: String = null, 137 | ProcessLabel: String = null, 138 | AppArmorProfile: String = null, 139 | ExecIDs: Option[AnyRef] = None, 140 | HostConfig: HostConfig = null, 141 | Mounts: Seq[Map[String, AnyRef]] = null, 142 | Config: ContainerConfig = null, 143 | NetworkSettings: NetworkSettings = null) extends DockerMessage 144 | 145 | final case class Image(Id: String, 146 | ParentId: String, 147 | RepoTags: Seq[String], 148 | RepoDigests: Seq[String], 149 | Created: Long, 150 | Size: BigInt, 151 | VirtualSize: BigInt, 152 | Labels: Option[Map[String, String]]) 153 | -------------------------------------------------------------------------------- /src/main/scala/com/jbrisbin/docker/SSL.scala: -------------------------------------------------------------------------------- 1 | package com.jbrisbin.docker 2 | 3 | import java.nio.file.{Paths, Files} 4 | import java.security.{KeyStore, KeyFactory} 5 | import java.security.cert.CertificateFactory 6 | import java.security.spec.PKCS8EncodedKeySpec 7 | import javax.net.ssl.SSLContext 8 | 9 | import org.apache.http.ssl.SSLContexts 10 | import org.bouncycastle.openssl.{PEMKeyPair, PEMParser} 11 | 12 | /** 13 | * @author Jon Brisbin 14 | */ 15 | private[docker] object SSL { 16 | 17 | def createSSLContext: SSLContext = { 18 | val dockerCertPath = System.getenv("DOCKER_CERT_PATH") 19 | 20 | val cf: CertificateFactory = CertificateFactory.getInstance("X.509") 21 | 22 | val caPem = Files.newInputStream(Paths.get(dockerCertPath, "ca.pem")) 23 | val caCert = cf.generateCertificate(caPem) 24 | 25 | val clientPem = Files.newInputStream(Paths.get(dockerCertPath, "cert.pem")) 26 | val clientCert = cf.generateCertificate(clientPem) 27 | 28 | val clientKeyPairPem = Files.newBufferedReader(Paths.get(dockerCertPath, "key.pem")) 29 | val clientKeyPair = new PEMParser(clientKeyPairPem).readObject().asInstanceOf[PEMKeyPair] 30 | 31 | val spec = new PKCS8EncodedKeySpec(clientKeyPair.getPrivateKeyInfo.getEncoded()) 32 | val kf = KeyFactory.getInstance("RSA") 33 | val clientKey = kf.generatePrivate(spec) 34 | 35 | val trustStore = KeyStore.getInstance(KeyStore.getDefaultType) 36 | trustStore.load(null, null) 37 | trustStore.setEntry("ca", new KeyStore.TrustedCertificateEntry(caCert), null) 38 | 39 | val keyStorePasswd = "p@ssw0rd".toCharArray 40 | val keyStore = KeyStore.getInstance(KeyStore.getDefaultType) 41 | keyStore.load(null, null) 42 | keyStore.setCertificateEntry("client", clientCert) 43 | keyStore.setKeyEntry("key", clientKey, keyStorePasswd, Array(clientCert)) 44 | 45 | SSLContexts.custom() 46 | .loadTrustMaterial(trustStore, null) 47 | .loadKeyMaterial(keyStore, keyStorePasswd) 48 | .build() 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/main/scala/com/jbrisbin/docker/package.scala: -------------------------------------------------------------------------------- 1 | package com.jbrisbin 2 | 3 | import java.nio.charset.Charset 4 | import java.time.Instant 5 | 6 | import akka.util.ByteString 7 | 8 | /** 9 | * @author Jon Brisbin 10 | */ 11 | package object docker { 12 | 13 | val charset = Charset.defaultCharset().toString 14 | 15 | implicit def toEpoch(l: Long): Instant = Instant.ofEpochSecond(l) 16 | 17 | implicit def byteString2String(bytes: ByteString): String = bytes.decodeString(charset) 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/test/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/test/scala/com/jbrisbin/docker/DockerTests.scala: -------------------------------------------------------------------------------- 1 | package com.jbrisbin.docker 2 | 3 | import java.nio.charset.Charset 4 | 5 | import akka.actor.ActorSystem 6 | import akka.pattern.ask 7 | import akka.stream.ActorMaterializer 8 | import akka.stream.scaladsl.Source 9 | import akka.util.{ByteString, Timeout} 10 | import org.scalatest.{BeforeAndAfterEach, Matchers, WordSpec} 11 | import org.slf4j.LoggerFactory 12 | 13 | import scala.concurrent.Await 14 | import scala.concurrent.duration._ 15 | 16 | /** 17 | * Unit tests for Docker client. 18 | */ 19 | class DockerTests extends WordSpec with Matchers with BeforeAndAfterEach { 20 | 21 | val log = LoggerFactory.getLogger(classOf[DockerTests]) 22 | 23 | val charset = Charset.defaultCharset().toString 24 | val testLabels = Some(Map("test" -> "true")) 25 | 26 | implicit val system = ActorSystem("tests") 27 | implicit val materializer = ActorMaterializer() 28 | implicit val timeout: Timeout = Timeout(30 seconds) 29 | 30 | import system.dispatcher 31 | 32 | val docker = Docker() 33 | 34 | override def afterEach(): Unit = { 35 | Await.result( 36 | for { 37 | containers <- docker.containers(all = true, filters = Map("label" -> Seq("test=true"))) 38 | done <- docker.remove(containers = Source(containers), volumes = true, force = true) 39 | } yield done, 40 | timeout.duration 41 | ) 42 | } 43 | 44 | "Docker" should { 45 | "list containers" in { 46 | val containers = Await.result(docker.containers(all = true), timeout.duration) 47 | log.debug("containers: {}", containers) 48 | 49 | containers should not be empty 50 | } 51 | } 52 | 53 | "Filter containers" in { 54 | Await.result( 55 | docker 56 | .create(CreateContainer( 57 | Image = "alpine", 58 | Cmd = Some(Seq("/bin/sh")), 59 | Labels = testLabels 60 | )), timeout.duration 61 | ) 62 | val containers = Await.result( 63 | docker.containers(all = true, filters = Map("label" -> Seq("test"))), 64 | timeout.duration 65 | ) 66 | log.debug("containers: {}", containers) 67 | 68 | containers should not be empty 69 | } 70 | 71 | "Create a container" in { 72 | val container = Await.result(docker.create( 73 | CreateContainer( 74 | Name = "runtest", 75 | Image = "alpine", 76 | Cmd = Some(Seq("/bin/sh")), 77 | Labels = testLabels 78 | ) 79 | ), timeout.duration) 80 | log.debug("container: {}", container) 81 | 82 | container.Id should not be (null) 83 | } 84 | 85 | "Execute in a container" in { 86 | // Create and start a container 87 | val container = Await.result( 88 | docker 89 | .create(CreateContainer( 90 | Name = "runtest", 91 | Cmd = Some(Seq("/bin/cat")), 92 | Image = "alpine", 93 | Labels = testLabels, 94 | Tty = true 95 | )) 96 | .flatMap(c => docker.start(c.Id)), 97 | timeout.duration 98 | ) 99 | log.debug("container: {}", container) 100 | 101 | // Interact with container via Source 102 | val src = docker.exec("runtest", Exec(Seq("ls", "-la", "/bin/busybox"))) 103 | .map { 104 | case StdOut(bytes) => { 105 | val str = bytes.decodeString(charset) 106 | log.debug(str) 107 | true 108 | } 109 | case StdErr(bytes) => { 110 | val str = bytes.decodeString(charset) 111 | log.error(str) 112 | false 113 | } 114 | } 115 | 116 | Await.result(src.runFold(true)(_ | _), timeout.duration) should be(true) 117 | Await.result(docker.stop("runtest"), timeout.duration) should be(true) 118 | } 119 | 120 | "Stream output" in { 121 | val res = Await.result( 122 | docker 123 | .create(CreateContainer( 124 | Image = "alpine", 125 | Tty = true, 126 | Cmd = Some(Seq("/bin/cat")), 127 | Labels = testLabels 128 | )) 129 | .flatMap(c => docker.start(c.Id)) 130 | .flatMap(c => c ? Exec(Seq("ls", "-la", "/bin"))), 131 | timeout.duration 132 | ) 133 | //log.debug("result: {}", res) 134 | 135 | val count = res match { 136 | case stdout: ByteString => { 137 | stdout 138 | .decodeString(charset) 139 | .split("\n") 140 | .filter(s => s.contains("uname")) 141 | .foldRight(0)((line, count) => count + 1) 142 | } 143 | } 144 | 145 | count should be > 0 146 | } 147 | 148 | "List images" in { 149 | val images = Await.result(docker.images(), timeout.duration) 150 | log.debug("images: {}", images) 151 | 152 | images should not be empty 153 | } 154 | 155 | } 156 | --------------------------------------------------------------------------------