
net.oneandone.stool.server.docker.Engine Maven / Gradle / Ivy
/*
* 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.server.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.unixsocket.UnixSocketAddress;
import jnr.unixsocket.UnixSocketChannel;
import net.oneandone.stool.server.ArgumentException;
import net.oneandone.stool.server.util.FileNodes;
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 javax.net.SocketFactory;
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.Writer;
import java.net.InetAddress;
import java.net.Socket;
import java.time.Instant;
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.HashMap;
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/
* Not thread-safe because the io buffer is shared.
*/
public class Engine implements AutoCloseable {
public static final DateTimeFormatter CREATED_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.n'Z'");
public enum Status {
CREATED,
RUNNING,
EXITED,
REMOVING /* not used in my code, by docker engine documentation says it can be returned */
}
public static Engine create() throws IOException {
return create(null);
}
public static Engine create(String wirelog) throws IOException {
return create("/var/run/docker.sock", wirelog);
}
public static Engine create(String socketPath, String wirelog) throws IOException {
World world;
HttpFilesystem fs;
HttpNode root;
// CAUTION: local World because I need a special socket factory and multiple Engine instances must *not* share the same buffers
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.38");
root.getRoot().addExtraHeader("Content-Type", "application/json");
return new Engine(root);
}
public final World world;
private final HttpNode root;
/** Thread safe - has no fields at all */
private final JsonParser parser;
private Engine(HttpNode root) {
this.world = root.getWorld();
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 mapped to ImageInfo */
public Map imageList() throws IOException {
return imageList(Collections.emptyMap());
}
public Map imageList(Map labels) throws IOException {
HttpNode node;
JsonArray array;
Map result;
String id;
JsonElement repoTags;
List repositoryTags;
JsonObject object;
JsonElement l;
node = root.join("images/json");
node = node.withParameter("all", "true");
if (!labels.isEmpty()) {
node = node.withParameter("filters", "{\"label\" : [" + labelsToJsonArray(labels) + "] }");
}
array = parser.parse(node.readString()).getAsJsonArray();
result = new HashMap<>(array.size());
for (JsonElement element : array) {
object = element.getAsJsonObject();
id = object.get("Id").getAsString();
id = Strings.removeLeft(id, "sha256:");
repoTags = object.get("RepoTags");
repositoryTags = repoTags.isJsonNull() ? new ArrayList<>() : stringList(repoTags.getAsJsonArray());
l = object.get("Labels");
result.put(id, new ImageInfo(id, repositoryTags, toLocalTime(object.get("Created").getAsLong()),
l.isJsonNull() ? new HashMap<>() : toStringMap(l.getAsJsonObject())));
}
return result;
}
private static LocalDateTime toLocalTime(long epochSeconds) {
Instant instant = Instant.ofEpochSecond(epochSeconds);
return instant.atZone(ZoneId.systemDefault()).toLocalDateTime();
}
/** @return output */
public String imageBuildWithOutput(String repositoryTag, FileNode context) throws IOException {
try (StringWriter dest = new StringWriter()) {
imageBuild(repositoryTag, Collections.emptyMap(), Collections.emptyMap(), context, false, dest);
return dest.toString();
}
}
/**
* @param log may be null
* @return image id */
public String imageBuild(String repositoryTag, Map args, Map labels,
FileNode context, boolean noCache, Writer log) throws IOException {
HttpNode build;
StringBuilder output;
JsonObject object;
String error;
JsonObject errorDetail;
JsonElement value;
AsciiInputStream in;
String line;
JsonElement aux;
String id;
FileNode tar;
validateReference(repositoryTag);
build = root.join("build");
build = build.withParameter("t", repositoryTag);
if (!labels.isEmpty()) {
build = build.withParameter("labels", obj(labels).toString());
}
build = build.withParameter("buildargs", obj(args).toString());
if (noCache) {
build = build.withParameter("nocache", "true");
}
output = new StringBuilder();
error = null;
errorDetail = null;
id = null;
tar = FileNodes.tar(context);
try {
try (InputStream raw = postStream(build, tar)) {
in = new AsciiInputStream(raw, 4096);
while (true) {
line = in.readLine();
if (line == null) {
if (error != null) {
throw new BuildError(repositoryTag, 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);
}
}
}
} finally {
tar.deleteFile();
}
}
public JsonObject imageInspect(String id) throws IOException {
HttpNode node;
node = root.join("images", id, "json");
return parser.parse(node.readString()).getAsJsonObject();
}
public void imageRemove(String tagOrId, boolean force) throws IOException {
HttpNode node;
node = root.join("images", tagOrId);
if (force) {
node = node.withParameter("force", "true");
}
Method.delete(node);
}
//-- containers
public Map containerListRunning(String key, String value) throws IOException {
return containerList("{\"label\" : [\"" + key + "=" + value + "\"], \"status\" : [\"running\"] }", false);
}
public Map containerListRunning(String key) throws IOException {
return containerList("{\"label\" : [\"" + key + "\"], \"status\" : [\"running\"] }", false);
}
public Map containerList(String key, String value) throws IOException {
return containerList("{\"label\" : [\"" + key + "=" + value + "\"] }", true);
}
public Map containerList(String key) throws IOException {
return containerList("{\"label\" : [\"" + key + "\"] }", true);
}
public Map containerList(String filters, boolean all) throws IOException {
HttpNode node;
JsonArray array;
Map result;
JsonObject object;
String id;
String imageId;
Status state; // TODO: sometimes it's called Status, somestimes state ...
node = root.join("containers/json");
if (filters != null) {
node = node.withParameter("filters", filters);
}
if (all) {
node = node.withParameter("all", "true");
}
array = parser.parse(node.readString()).getAsJsonArray();
result = new HashMap<>(array.size());
for (JsonElement element : array) {
object = element.getAsJsonObject();
id = object.get("Id").getAsString();
imageId = object.get("ImageID").getAsString();
state = Status.valueOf(object.get("State").getAsString().toUpperCase());
result.put(id, new ContainerInfo(id, imageId, toStringMap(object.get("Labels").getAsJsonObject()),
ports(element.getAsJsonObject().get("Ports").getAsJsonArray()),
state));
}
return result;
}
public String containerCreate(String image, String hostname) throws IOException {
return containerCreate(null, image, hostname, null, false, null, null, null,
Collections.emptyMap(), 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 hostname or null to not define the hostname
* @param stopTimeout default timeout when stopping this container without explicit timeout value; null to use default (10 seconds)
* @return container id
*/
@SuppressWarnings("checkstyle:ParameterNumber")
public String containerCreate(String name, String image, String hostname, String networkMode, boolean priviledged, Long memory, String stopSignal, Integer stopTimeout,
Map labels, Map env, Map bindMounts, Map ports) throws IOException {
JsonObject body;
JsonObject response;
JsonObject hostConfig;
JsonArray mounts;
JsonObject portBindings;
JsonArray drops;
HttpNode node;
node = root.join("containers/create");
if (name != null) {
node = node.withParameter("name", name);
}
body = object("Image", image);
if (hostname != null) {
body.add("Hostname", new JsonPrimitive(hostname));
}
if (!labels.isEmpty()) {
body.add("Labels", obj(labels));
}
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));
}
if (networkMode != null) {
hostConfig.add("NetworkMode", new JsonPrimitive(networkMode));
}
mounts = new JsonArray();
hostConfig.add("Mounts", mounts);
for (Map.Entry entry : bindMounts.entrySet()) {
mounts.add(object("type", "bind", "source", entry.getKey().getAbsolute(), "target", 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", hostMapping(entry.getValue()));
}
hostConfig.add("PortBindings", portBindings);
body.add("ExposedPorts", exposedPorts(ports.keySet()));
response = post(node, body);
checkWarnings(response);
return response.get("Id").getAsString();
}
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"), object());
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 checkWarnings(JsonObject response) throws IOException {
JsonElement warnings;
warnings = response.get("Warnings");
if (JsonNull.INSTANCE.equals(warnings)) {
return;
}
if (warnings.isJsonArray()) {
if (warnings.getAsJsonArray().size() == 0) {
return;
}
}
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, FileNode body) throws IOException {
try (InputStream src = body.newInputStream()) {
return dest.postStream(new Body(null, null, body.size(), src, 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 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;
}
// this is to avoid engine 500 error reporting "invalid reference format: repository name must be lowercase"
public static void validateReference(String reference) {
char c;
for (int i = 0, length = reference.length(); i < length; i++) {
if (Character.isUpperCase(reference.charAt(i))) {
throw new ArgumentException("invalid reference: " + reference);
}
}
}
//-- json utils
private static JsonObject object(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();
}
public 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 List stringList(JsonArray array) {
List result;
result = new ArrayList<>(array.size());
for (JsonElement element : array) {
result.add(element.getAsString());
}
return result;
}
public static Map toStringMap(JsonObject obj) {
Map result;
result = new HashMap<>();
for (Map.Entry entry : obj.entrySet()) {
result.put(entry.getKey(), entry.getValue().getAsString());
}
return result;
}
private static Map ports(JsonArray array) {
JsonObject obj;
Map ports;
ports = new HashMap<>();
for (JsonElement element : array) {
obj = element.getAsJsonObject();
ports.put(obj.get("PrivatePort").getAsInt(), obj.get("PublicPort").getAsInt());
}
return ports;
}
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 hostMapping(String ipOptPort) {
int idx;
String ip;
int port;
JsonArray result;
JsonObject obj;
idx = ipOptPort.indexOf(':');
if (idx == -1) {
ip = null;
port = Integer.parseInt(ipOptPort);
} else {
ip = ipOptPort.substring(0, idx);
port = Integer.parseInt(ipOptPort.substring(idx +1));
}
obj = new JsonObject();
if (ip != null) {
obj.add("HostIp", new JsonPrimitive(ip));
}
obj.add("HostPort", new JsonPrimitive(Integer.toString(port)));
result = new JsonArray();
result.add(obj);
return result;
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy