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

net.oneandone.stool.docker.Engine Maven / Gradle / Ivy

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();
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy