Here is small module which can sort of interact with the docker socket. This is just an example and error handling and other things are not taken care of here but it should give you an idea. I hope I can get the time to extract my “real” docker api code into a library. It is actually fun to work on but I have little time
For more information on http format such as the request line, how to read and write headers, how chunked reading work please consult: https://tools.ietf.org/html/rfc2616
Any question on this please feel free to ask.
Use D.request
to send data to the docker daemon. I also added in how to attach and read and write to a container.
An addition. For real code you might want to change the connection type to {:active, true}
or {:active, 1}
, when in stream mode. Then the process controlling the socket will get sent a message whenever there is data instead of having to read it all the time. So read_stream/1
could have looked like this: def read_stream(s), do: :inet.setopts(s, [{:active, true}, {:packet, :line}])
ex(1)> D.request("GET", "/containers/json")
{:ok,
%D{
body: "",
headers: [
{:"Content-Length", "795"},
{:Date, "Sat, 28 Nov 2020 23:05:52 GMT"},
{:Server, "Docker/19.03.12 (linux)"},
{"Ostype", "linux"},
{"Docker-Experimental", "false"},
{:"Content-Type", "application/json"},
{"Api-Version", "1.40"}
],
status: 200
},
[
%{
"Command" => "/bin/bash",
"Created" => 1606603421,
"HostConfig" => %{"NetworkMode" => "default"},
"Id" => "4ae5d1c540dba82541dbdc1226764f18c0c9aa257a777503f588abb0295f2630",
"Image" => "esbuild",
"ImageID" => "sha256:0aeb5e50e13bd782db9f3815355c189dd9b49f6c825a56e7a27a898daec2e1d6",
"Labels" => %{},
"Mounts" => [],
"Names" => ["/vigorous_brown"],
"NetworkSettings" => %{
"Networks" => %{
"bridge" => %{
"Aliases" => nil,
"DriverOpts" => nil,
"EndpointID" => "40e36f11ce41c964f823d3d7b11abd6b63ae76812e16fcaf2ba63de0585b51db",
"Gateway" => "172.17.0.1",
"GlobalIPv6Address" => "",
"GlobalIPv6PrefixLen" => 0,
"IPAMConfig" => nil,
"IPAddress" => "172.17.0.2",
"IPPrefixLen" => 16,
"IPv6Gateway" => "",
"Links" => nil,
"MacAddress" => "02:42:ac:11:00:02",
"NetworkID" => "ac553b1326b9ac9c0beebdc0c35362aa8f9bf84ee86c7c54b89d045c23fb6ba6"
}
}
},
"Ports" => [],
"State" => "running",
"Status" => "Up 22 minutes"
}
]}
iex(2)> {:stream, _, socket} = D.request("POST", "/containers/4ae5d1c540dba82541dbdc1226764f18c0c9aa257a777503f588abb0295f2630/attach?stdin=1&stdout=1&stream=1")
{:stream,
%D{
body: "",
headers: ["Content-Type": "application/vnd.docker.raw-stream"],
status: 200
}, #Port<0.24>}
iex(3)> D.write_stream(socket, "echo \"Hello World!\"\r\n")
:ok
iex(4)> D.read_stream(socket)
{:ok, "echo \"Hello World!\"\r\n"}
iex(5)> D.read_stream(socket)
{:ok, "Hello World!\r\n"}
iex(6)> D.read_stream(socket)
{:ok, "bash-5.0# \r\n"}
iex(7)> D.read_stream(socket)
{:error, :timeout}
iex(8)>
Here is the module I’ve been working with
defmodule D do
defstruct status: 0, headers: [], body: ""
# Send requests to the docker daemon.
# request("GET", "/containers/json")
# request("POST", "/containers/abc/attach?stream=1&stdin=1&stdout=1")
#
# To post data you need to add a Content-Type and a Content-Length header to the
# request and then send the data to the socket
def request(method, path) do
{:ok, socket} = :gen_tcp.connect({:local, "/var/run/docker.sock"}, 0, [
:binary,
{:active, false},
{:packet, :http_bin}
])
:gen_tcp.send(socket, "#{method} #{path} HTTP/1.1\r\nHost: #{:net_adm.localhost()}\r\n\r\n")
do_recv(socket)
end
# Reads from tty. In case of non tty you need to read
# {:packet, :raw} and decode as described under Stream Format here: https://docs.docker.com/engine/api/v1.40/#operation/ContainerAttach
# For now just read lines from TTY or timeout after 5 seconds if nothing to be read
# This requires an attached socket:
# {:stream, _, socket} = request("POST", "/containers/abc/attach?stream=1&stdin=1&stdout=1")
# read_stream(socket)
def read_stream(socket) do
:inet.setopts(socket, [{:packet, :line}])
:gen_tcp.recv(socket, 0, 5000)
end
# Writes to attached container
# This requires an attached socket:
# {:stream, _, socket} = request("POST", "/containers/abc/attach?stream=1&stdin=1&stdout=1")
# write_stream(socket, "echo \"Hello World\"\r\n")
# read_stream(socket)
def write_stream(socket, data) do
:gen_tcp.send(socket, data)
end
def do_recv(socket), do: do_recv(socket, :gen_tcp.recv(socket, 0 , 5000), %D{})
def do_recv(socket, {:ok, {:http_response, {1,1}, code, _}}, resp) do
do_recv(socket, :gen_tcp.recv(socket, 0, 5000), %D{resp | status: code})
end
def do_recv(socket, {:ok, {:http_header, _, h, _, v}}, resp) do
do_recv(socket, :gen_tcp.recv(socket, 0, 5000), %D{resp | headers: [{h, v} | resp.headers]})
end
def do_recv(socket, {:ok, :http_eoh}, resp) do
# Now we only have body left.
# # Depending on headers here you may want to do different things.
# # The response might be chunked, or upgraded in case you have attached to the container
# # Now I can receive the response. Because of `:active, false} I need to explicitly ask for data, otherwise it gets send to the process as messages.
case :proplists.get_value(:"Content-Type", resp.headers) do
"application/vnd.docker.raw-stream" -> {:stream, resp, socket} # Return the socket for bi-directional communication
"application/json" -> {:ok, resp, Jason.decode!(read_body(socket, resp))}
_ -> {:ok, resp, read_body(socket, resp)}
end
end
def read_body(socket, resp) do
case :proplists.get_value(:"Content-Length", resp.headers) do
:undefined ->
# No content length. Checked if chunked
case :proplists.get_value(:"Transfer-Encoding", resp.headers) do
"chunked" -> read_chunked_body(socket, resp)
_ -> "" # No body
end
content_length ->
bytes_to_read = String.to_integer(content_length)
:inet.setopts(socket, [{:packet, :raw}]) # No longer line based http, just read data
case :gen_tcp.recv(socket, bytes_to_read, 5000) do
{:ok, data} ->
data
{:error, reason} ->
{:error, reason}
end
end
end
def read_chunked_body(socket, resp), do: read_chunked_body(socket, resp, [])
def read_chunked_body(socket, resp, acc) do
:inet.setopts(socket, [{:packet, :line}])
case :gen_tcp.recv(socket, 0, 5000) do
{:ok, length} ->
length = String.trim_trailing(length, "\r\n") |> String.to_integer(16)
if length == 0 do
{:ok, :erlang.iolist_to_binary(Enum.reverse(acc))}
else
:inet.setopts(socket, [{:packet, :raw}])
{:ok, data} = :gen_tcp.recv(socket, length, 5000)
:gen_tcp.recv(socket, 2, 5000)
read_chunked_body(socket, resp, [data | acc])
end
other ->
{:error, other}
end
end
end