All Downloads are FREE. Search and download functionalities are using the official Maven repository.

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