net.oneandone.stool.docker.Engine Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of main Show documentation
Show all versions of main Show documentation
Stool's main component. Java Library, cli, setup code.
The newest version!
/*
* Copyright 1&1 Internet AG, https://github.com/1and1/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.oneandone.stool.docker;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.JsonPrimitive;
import jnr.posix.POSIX;
import jnr.posix.POSIXFactory;
import jnr.unixsocket.UnixSocketAddress;
import jnr.unixsocket.UnixSocketChannel;
import net.oneandone.sushi.fs.Node;
import net.oneandone.sushi.fs.World;
import net.oneandone.sushi.fs.file.FileNode;
import net.oneandone.sushi.fs.http.HttpFilesystem;
import net.oneandone.sushi.fs.http.HttpNode;
import net.oneandone.sushi.fs.http.StatusException;
import net.oneandone.sushi.fs.http.io.AsciiInputStream;
import net.oneandone.sushi.fs.http.model.Body;
import net.oneandone.sushi.fs.http.model.Method;
import net.oneandone.sushi.util.Strings;
import org.kamranzafar.jtar.TarEntry;
import org.kamranzafar.jtar.TarHeader;
import org.kamranzafar.jtar.TarOutputStream;
import javax.net.SocketFactory;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.EOFException;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.StringWriter;
import java.io.UnsupportedEncodingException;
import java.io.Writer;
import java.net.InetAddress;
import java.net.Socket;
import java.net.URLEncoder;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Connect to local docker engine via unix socket. https://docs.docker.com/engine/api/v1.37/ */
public class Engine implements AutoCloseable {
public enum Status {
CREATED,
RUNNING,
EXITED,
REMOVING
}
public static Engine open(String socketPath, String wirelog) throws IOException {
// local World because I need a special socket factory
World world;
HttpFilesystem fs;
HttpNode root;
world = World.create();
if (wirelog != null) {
HttpFilesystem.wireLog(wirelog);
}
fs = (HttpFilesystem) world.getFilesystem("http");
fs.setSocketFactorySelector((String protocol, String hostname) ->
new SocketFactory() {
@Override
public Socket createSocket(String s, int i) throws IOException {
return socket();
}
@Override
public Socket createSocket(String s, int i, InetAddress inetAddress, int i1) throws IOException {
return socket();
}
@Override
public Socket createSocket(InetAddress inetAddress, int i) throws IOException {
return socket();
}
@Override
public Socket createSocket(InetAddress inetAddress, int i, InetAddress inetAddress1, int i1) throws IOException {
return socket();
}
private Socket socket() throws IOException {
UnixSocketAddress address;
address = new UnixSocketAddress(new File(socketPath));
return UnixSocketChannel.open(address).socket();
}
}
);
root = (HttpNode) world.validNode("http://localhost/v1.37");
root.getRoot().addExtraHeader("Content-Type", "application/json");
return new Engine(root);
}
private final HttpNode root;
private final JsonParser parser;
public Engine(HttpNode root) {
this.root = root;
this.parser = new JsonParser();
}
public void close() {
root.getWorld().close();
}
//--
public String version() throws IOException {
return root.join("version").readString();
}
//-- images
/** @return image ids */
public List imageList(Map labels) throws IOException {
String filters;
Node node;
JsonArray array;
List result;
String id;
node = root.join("images/json");
filters = labels.isEmpty()? "" : "&filters=" + enc("{\"label\" : [" + labelsToJsonArray(labels) + "] }");
node = node.getRoot().node(node.getPath(), "all=true" + filters);
array = parser.parse(node.readString()).getAsJsonArray();
result = new ArrayList<>(array.size());
for (JsonElement element : array) {
id = element.getAsJsonObject().get("Id").getAsString();
id = Strings.removeLeft(id, "sha256:");
result.add(id);
}
return result;
}
/**
* @param image may be null
* @return container ids
*/
public List containerList(String image) throws IOException {
String filters;
Node node;
JsonArray array;
List result;
String id;
node = root.join("containers/json");
filters = image == null? "" : "&filters=" + enc("{\"ancestor\" : [\"" + image + "\"] }");
node = node.getRoot().node(node.getPath(), "all=true" + filters);
array = parser.parse(node.readString()).getAsJsonArray();
result = new ArrayList<>(array.size());
for (JsonElement element : array) {
id = element.getAsJsonObject().get("Id").getAsString();
result.add(id);
}
return result;
}
/** @return output */
public String imageBuildWithOutput(String nameTag, FileNode context) throws IOException {
try (StringWriter dest = new StringWriter()) {
imageBuild(nameTag, Collections.emptyMap(), context, false, dest);
return dest.toString();
}
}
/**
* @param log may be null
* @return image id */
public String imageBuild(String nameTag, Map labels, FileNode context, boolean noCache, Writer log) throws IOException {
HttpNode node;
StringBuilder output;
JsonObject object;
String error;
JsonObject errorDetail;
JsonElement value;
AsciiInputStream in;
String line;
JsonElement aux;
String id;
node = root.join("build");
node = node.getRoot().node(node.getPath(), "t=" + nameTag + labelsToJsonObject(labels) + (noCache ? "&nocache=true" : ""));
output = new StringBuilder();
error = null;
errorDetail = null;
id = null;
try (InputStream raw = postStream(node, tar(context))) {
in = new AsciiInputStream(raw, 4096);
while (true) {
line = in.readLine();
if (line == null) {
if (error != null) {
throw new BuildError(error, errorDetail, output.toString());
}
if (id == null) {
throw new IOException("missing id");
}
return id;
}
object = parser.parse(line).getAsJsonObject();
eatStream(object, output, log);
eatString(object, "status", output, log);
eatString(object, "id", output, log);
eatString(object, "progress", output, log);
eatObject(object, "progressDetail", output, log);
aux = eatObject(object, "aux", output, log);
if (aux != null) {
if (id != null) {
throw new IOException("duplicate id");
}
id = Strings.removeLeft(aux.getAsJsonObject().get("ID").getAsString(), "sha256:");
}
value = eatString(object, "error", output, log);
if (value != null) {
if (error != null) {
throw new IOException("multiple errors");
}
error = value.getAsString();
}
value = eatObject(object, "errorDetail", output, log);
if (value != null) {
if (errorDetail != null) {
throw new IOException("multiple errors");
}
errorDetail = value.getAsJsonObject();
}
if (object.size() > 0) {
throw new IOException("unknown build output: " + object);
}
}
}
}
private void eatStream(JsonObject object, StringBuilder result, Writer log) throws IOException {
eat(object, "stream", "", "", true, result, log);
}
private JsonElement eatString(JsonObject object, String key, StringBuilder result, Writer log) throws IOException {
return eat(object, key, "[" + key + "] ", "\n", true, result, log);
}
private JsonElement eatObject(JsonObject object, String key, StringBuilder result, Writer log) throws IOException {
return eat(object, key, "[" + key + "] ", "\n",false, result, log);
}
private JsonElement eat(JsonObject object, String key, String prefix, String suffix, boolean isString, StringBuilder result, Writer log) throws IOException {
JsonElement value;
String str;
value = object.remove(key);
if (value == null) {
return null;
}
if (isString) {
str = value.getAsString();
} else {
str = value.getAsJsonObject().toString();
}
str = prefix + str + suffix;
result.append(str);
if (log != null) {
log.write(str);
}
return value;
}
/** tar directory into byte array */
private byte[] tar(FileNode context) throws IOException {
List all;
ByteArrayOutputStream dest;
TarOutputStream tar;
byte[] bytes;
Iterator iter;
FileNode file;
long now;
dest = new ByteArrayOutputStream();
tar = new TarOutputStream(dest);
now = System.currentTimeMillis();
all = context.find("**/*");
iter = all.iterator();
while (iter.hasNext()) {
file = iter.next();
if (file.isDirectory()) {
tar.putNextEntry(new TarEntry(TarHeader.createHeader(file.getRelative(context), 0, now, true, 0700)));
iter.remove();
}
}
iter = all.iterator();
while (iter.hasNext()) {
file = iter.next();
bytes = file.readBytes();
tar.putNextEntry(new TarEntry(TarHeader.createHeader(file.getRelative(context), bytes.length, now, false, 0700)));
tar.write(bytes);
}
tar.close();
return dest.toByteArray();
}
public void imageRemove(String id) throws IOException {
Method.delete(root.join("images", id));
}
//-- containers
public String containerCreate(String image, String hostname) throws IOException {
return containerCreate(image, hostname, false, null, null, null, Collections.emptyMap(), Collections.emptyMap(), Collections.emptyMap());
}
/**
* @param memory is the memory limit in bytes. Or null for no limit. At least 1024*1024*4. The actual value used by docker is something
* rounded of this parameter
* @param stopSignal or null to use default (SIGTERM)
* @param stopTimeout default timeout when stopping this container without explicit timeout value; null to use default (10 seconds)
* @return container id
*/
public String containerCreate(String image, String hostname, boolean priviledged, Long memory, String stopSignal, Integer stopTimeout,
Map env, Map bindMounts, Map ports) throws IOException {
JsonObject body;
JsonObject response;
JsonObject hostConfig;
JsonArray binds;
JsonObject portBindings;
JsonArray drops;
if (priviledged) {
body = body("Image", image, "Hostname", hostname);
} else {
body = body("Image", image, "Hostname", hostname, "User", Long.toString(geteuid()), "Group", Long.toString(getegid()));
}
if (stopSignal != null) {
body.add("StopSignal", new JsonPrimitive(stopSignal));
}
if (stopTimeout != null) {
body.add("StopTimeout", new JsonPrimitive(stopTimeout));
}
hostConfig = new JsonObject();
body.add("HostConfig", hostConfig);
if (!env.isEmpty()) {
body.add("Env", env(env));
}
if (memory != null) {
hostConfig.add("Memory", new JsonPrimitive(memory));
// unlimited; important, because debian stretch kernel does not support this
hostConfig.add("MemorySwap", new JsonPrimitive(-1));
}
if (priviledged) {
hostConfig.add("Privileged", new JsonPrimitive(true));
}
binds = new JsonArray();
hostConfig.add("Binds", binds);
for (Map.Entry entry : bindMounts.entrySet()) {
binds.add(entry.getKey() + ":" + entry.getValue());
}
drops = new JsonArray(); // added security - not sure if I really need this
drops.add(new JsonPrimitive("setuid"));
drops.add(new JsonPrimitive("setgid"));
drops.add(new JsonPrimitive("chown"));
drops.add(new JsonPrimitive("dac_override"));
drops.add(new JsonPrimitive("fowner"));
drops.add(new JsonPrimitive("fsetid"));
drops.add(new JsonPrimitive("kill"));
drops.add(new JsonPrimitive("setpcap"));
drops.add(new JsonPrimitive("net_bind_service"));
drops.add(new JsonPrimitive("net_raw"));
drops.add(new JsonPrimitive("sys_chroot"));
drops.add(new JsonPrimitive("mknod"));
drops.add(new JsonPrimitive("setfcap"));
hostConfig.add("CapDrop", drops);
portBindings = new JsonObject();
for (Map.Entry entry: ports.entrySet()) {
portBindings.add(Integer.toString(entry.getKey()) + "/tcp", hostPort(entry.getValue()));
}
hostConfig.add("PortBindings", portBindings);
body.add("ExposedPorts", exposedPorts(ports.keySet()));
response = post(root.join("containers/create"), body);
checWarnings(response);
return response.get("Id").getAsString();
}
private static JsonObject fuseDevice() { // https://gist.github.com/dims/0d1ac1a5598e0b8a72e0
JsonObject result;
result = new JsonObject();
result.add("PathOnHost", new JsonPrimitive("/dev/fuse"));
result.add("PathInContainer", new JsonPrimitive("/dev/fuse"));
result.add("CgroupPermissions", new JsonPrimitive("mrw"));
return result;
}
private static JsonArray env(Map env) {
JsonArray result;
result = new JsonArray();
for (Map.Entry entry : env.entrySet()) {
result.add(entry.getKey() + "=" + entry.getValue());
}
return result;
}
private static JsonObject exposedPorts(Set ports) {
JsonObject obj;
obj = new JsonObject();
for (Integer port : ports) {
obj.add(Integer.toString(port) + "/tcp", new JsonObject());
}
return obj;
}
private static JsonArray hostPort(int port) {
JsonArray result;
JsonObject obj;
obj = new JsonObject();
obj.add("HostPort", new JsonPrimitive(Integer.toString(port)));
result = new JsonArray();
result.add(obj);
return result;
}
public void containerStart(String id) throws IOException {
post(root.join("containers", id, "start"), "");
}
/**
* Sends stop signal as specified containerCreate to pid 1. If process does not terminate after timeout, SIGKILL is used
* @param timeout null to use timeout specified by containerCreate
* */
public void containerStop(String id, Integer timeout) throws IOException {
HttpNode stop;
stop = root.join("containers", id, "stop");
if (timeout != null) {
stop = stop.getRoot().node(stop.getPath(), "t=" + timeout);
}
post(stop, "");
}
public void containerRemove(String id) throws IOException {
Method.delete(root.join("containers", id));
}
public String containerLogs(String id) throws IOException {
final StringBuilder str;
OutputStream dest;
str = new StringBuilder();
dest = new OutputStream() {
@Override
public void write(int b) {
str.append((char) b);
}
};
doContainerLogs(id, "stdout=1&stderr=1", dest);
return str.toString();
}
public void containerLogsFollow(String id, OutputStream dest) throws IOException {
doContainerLogs(id, "stdout=1&stderr=1&follow=1", dest);
}
private void doContainerLogs(String id, String options, OutputStream dest) throws IOException {
HttpNode node;
DataInputStream data;
int len;
node = root.join("containers", id, "logs");
data = new DataInputStream(node.getRoot().node(node.getPath(), options).newInputStream());
while (true) {
try {
data.readInt(); // type is ignored
} catch (EOFException e) {
return;
}
len = data.readInt();
for (int i = 0; i < len; i++) {
dest.write(data.readByte());
}
}
}
public int containerWait(String id) throws IOException {
JsonObject response;
response = post(root.join("containers", id, "wait"), body());
return response.get("StatusCode").getAsInt();
}
public Status containerStatus(String id) throws IOException {
JsonObject state;
state = containerState(id);
return Status.valueOf(state.get("Status").getAsString().toUpperCase());
}
/** @return null if container is not started */
public Stats containerStats(String id) throws IOException {
HttpNode node;
JsonObject stats;
JsonObject memory;
node = root.join("containers", id, "stats");
node = node.getRoot().node(node.getPath(), "stream=false");
stats = parser.parse(node.readString()).getAsJsonObject();
if (stats.get("cpu_stats").getAsJsonObject().get("system_cpu_usage") == null) {
// empty default document - this is returned if that container id is invalid
return null;
}
memory = stats.get("memory_stats").getAsJsonObject();
return new Stats(cpu(stats), memory.get("usage").getAsLong(), memory.get("limit").getAsLong());
}
private static int cpu(JsonObject stats) {
JsonObject current;
JsonObject previous;
long cpuDelta;
long systemDelta;
current = stats.get("cpu_stats").getAsJsonObject();
previous = stats.get("precpu_stats").getAsJsonObject();
cpuDelta = current.get("cpu_usage").getAsJsonObject().get("total_usage").getAsLong() - previous.get("cpu_usage").getAsJsonObject().get("total_usage").getAsLong();
systemDelta = current.get("system_cpu_usage").getAsLong() - previous.get("system_cpu_usage").getAsLong();
return (int) (cpuDelta * 100 / systemDelta);
}
// https://github.com/moby/moby/pull/15010
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.n'Z'");
public long containerStartedAt(String id) throws IOException {
JsonObject state;
String str;
LocalDateTime result;
state = containerState(id);
str = state.get("StartedAt").getAsString();
try {
result = LocalDateTime.parse(str, DATE_FORMAT);
} catch (DateTimeParseException e) {
throw new IOException("cannot parse date: " + str);
}
// CAUTION: container executes in GMT timezone
return result.atZone(ZoneId.of("GMT")).toInstant().toEpochMilli();
}
private JsonObject containerState(String id) throws IOException {
JsonObject response;
JsonObject state;
String error;
response = containerInspect(id, false);
state = response.get("State").getAsJsonObject();
error = state.get("Error").getAsString();
if (!error.isEmpty()) {
throw new IOException("error state: " + error);
}
return state;
}
public JsonObject containerInspect(String id, boolean size) throws IOException {
HttpNode node;
node = root.join("containers", id, "json");
if (size) {
node = node.withParameter("size", "true");
}
return parser.parse(node.readString()).getAsJsonObject();
}
//--
private void checWarnings(JsonObject response) throws IOException {
if (!JsonNull.INSTANCE.equals(response.get("Warnings"))) {
throw new IOException("response warnings: " + response.toString());
}
}
private JsonObject post(HttpNode dest, JsonObject obj) throws IOException {
return parser.parse(post(dest, obj.toString() + '\n')).getAsJsonObject();
}
private InputStream postStream(HttpNode dest, byte[] body) throws IOException {
return dest.postStream(new Body(null, null, body.length, new ByteArrayInputStream(body), false));
}
private String post(HttpNode dest, String body) throws IOException {
try {
return dest.post(body);
} catch (StatusException e) {
if (e.getStatusLine().code == 204) {
return "";
} else {
throw e;
}
}
}
private static JsonObject body(Object... keyvalues) {
JsonObject body;
Object arg;
if (keyvalues.length % 2 != 0) {
throw new IllegalArgumentException();
}
body = new JsonObject();
for (int i = 0; i < keyvalues.length; i += 2) {
arg = keyvalues[i + 1];
if (arg instanceof String) {
arg = new JsonPrimitive((String) arg);
} else if (arg instanceof Number) {
arg = new JsonPrimitive((Number) arg);
} else if (arg instanceof Boolean) {
arg = new JsonPrimitive((Boolean) arg);
}
body.add((String) keyvalues[i], (JsonElement) arg);
}
return body;
}
private static String labelsToJsonArray(Map map) {
StringBuilder builder;
builder = new StringBuilder();
for (Map.Entry entry : map.entrySet()) {
if (builder.length() > 0) {
builder.append(", ");
}
builder.append('"');
builder.append(entry.getKey());
builder.append('=');
builder.append(entry.getValue());
builder.append('"');
}
return builder.toString();
}
private static String labelsToJsonObject(Map maps) {
StringBuilder result;
if (maps.isEmpty()) {
return "";
}
result = new StringBuilder();
result.append(obj(maps).toString());
return "&labels=" + enc(result.toString());
}
private static String enc(String str) {
try {
return URLEncoder.encode(str, "utf8");
} catch (UnsupportedEncodingException e) {
throw new IllegalStateException(e);
}
}
private static JsonObject obj(Map obj) {
JsonObject result;
result = new JsonObject();
for (Map.Entry entry : obj.entrySet()) {
result.add(entry.getKey(), new JsonPrimitive(entry.getValue()));
}
return result;
}
//--
private static final jnr.posix.POSIX POSIX = POSIXFactory.getPOSIX();
public static int geteuid() {
return POSIX.geteuid();
}
public static int getegid() {
return POSIX.getegid();
}
}