├── .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 |
--------------------------------------------------------------------------------