"use strict";
/*
 * Copyright 2019-2020 Unbounded Systems, LLC
 *
 * 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.
 */
Object.defineProperty(exports, "__esModule", { value: true });
const tslib_1 = require("tslib");
const core_1 = require("@adpt/core");
const utils_1 = require("@adpt/utils");
const lodash_1 = require("lodash");
const util_1 = require("util");
const action_1 = require("../action");
const common_1 = require("../common");
const env_1 = require("../env");
const cli_1 = require("./cli");
const docker_observer_1 = require("./docker_observer");
const labels_1 = require("./labels");
const network_set_1 = require("./network_set");
/**
 * Compute a unique name for the container
 *
 * @internal
 */
exports.computeContainerName = common_1.makeResourceName(/[^a-z-.]/g, 63);
function computeContainerNameFromBuildData(props, buildData) {
    if (!props.key)
        throw new utils_1.InternalError(`DockerContainer with id '${buildData.id}' has no key`);
    return exports.computeContainerName(props.key, buildData.id, buildData.deployID);
}
function computeContainerNameFromContext(props, context) {
    return computeContainerNameFromBuildData(props, context.buildData);
}
async function updateImageInfo(id, savedImage, props) {
    const savedId = savedImage && savedImage.Id;
    if (savedId === id)
        return savedImage;
    const imgs = await cli_1.dockerInspect([id], { type: "image", dockerHost: props.dockerHost });
    if (imgs.length === 1)
        return imgs[0];
    if (imgs.length === 0) {
        throw new Error(`Image for running container cannot be found. Image Id=${id}`);
    }
    throw new Error(`Found ${imgs.length} images matching Id ${id}`);
}
async function fetchContainerInfo(context, props, saved) {
    const name = computeContainerNameFromContext(props, context);
    const insp = await cli_1.dockerInspect([name], { type: "container", dockerHost: props.dockerHost });
    if (insp.length > 1)
        throw new Error(`Multiple containers match single name: ${name}`);
    const data = insp[0]; //will be undefined if no container found
    const info = { name };
    if (data) {
        info.data = data;
        info.image = await updateImageInfo(data.Image, saved && saved.image, props);
    }
    return info;
}
function containerExists(info) { return info.data !== undefined; }
function containerExistsAndIsFromDeployment(info, context) {
    if (info.data === undefined)
        return false;
    if (info.data.Config.Labels &&
        info.data.Config.Labels[labels_1.adaptDockerDeployIDKey] === context.buildData.deployID)
        return true;
    return false;
}
async function getImageId(source, props) {
    if (core_1.isHandle(source)) {
        const image = core_1.callInstanceMethod(source, undefined, "latestImage");
        if (!image)
            return undefined;
        return image.id;
    }
    else {
        return cli_1.dockerImageId(source, { dockerHost: props.dockerHost });
    }
}
/**
 * Returns the name of the default docker network
 *
 * @returns the name of the default docker network, undefined if it does not exist
 * @internal
 */
async function dockerDefaultNetwork(opts) {
    const networks = await cli_1.dockerNetworks(opts);
    for (const net of networks) {
        if (net.Options["com.docker.network.bridge.default_bridge"] === "true")
            return net.Name;
    }
    return undefined;
}
/**
 * The list of networks that a container should be connected to, according
 * to its props.
 * @internal
 */
async function requestedNetworks(props) {
    if (props.networks)
        return props.networks;
    const defaultNetwork = await dockerDefaultNetwork(props);
    return defaultNetwork ? [defaultNetwork] : [];
}
async function networkDiff(info, props, op) {
    async function resolver(names) {
        const infos = await cli_1.dockerInspect(names, Object.assign({}, props, { type: "network" }));
        return infos.map((i) => ({ name: i.Name, id: i.Id }));
    }
    const data = info.data;
    if (!data)
        throw new utils_1.InternalError(`No inspect report in networkDiff`);
    const existing = network_set_1.containerNetworks(data);
    const requested = await requestedNetworks(props);
    return existing[op](requested, resolver);
}
function labelsUpToDate(info, context, props) {
    const ctr = info.data;
    const img = info.image;
    if (!ctr)
        throw new utils_1.InternalError(`No container report`);
    if (!img)
        throw new utils_1.InternalError(`No image report`);
    const deployLabel = { [labels_1.adaptDockerDeployIDKey]: context.buildData.deployID };
    // We expect whatever labels the container's image has, merged with props
    const expected = env_1.mergeEnvSimple(img.Config.Labels, props.labels, deployLabel);
    const actual = ctr.Config.Labels;
    return lodash_1.isEqual(actual, expected);
}
function parseEnvString(envString) {
    const eql = envString.indexOf("=");
    if (eql === -1) {
        throw new utils_1.InternalError(`No equal sign in container environment variable`);
    }
    return {
        key: envString.slice(0, eql),
        val: envString.slice(eql + 1),
    };
}
function getEnv(report) {
    const ret = {};
    const envArray = report.Config.Env || [];
    for (const e of envArray) {
        const { key, val } = parseEnvString(e);
        if (ret[key] !== undefined) {
            throw new utils_1.InternalError(`Repeated environment variable ${key} in ` +
                `container or image config`);
        }
        ret[key] = val;
    }
    return ret;
}
function envUpToDate(info, _context, props) {
    const ctr = info.data;
    const img = info.image;
    if (!ctr)
        throw new utils_1.InternalError(`No container report`);
    if (!img)
        throw new utils_1.InternalError(`No image report`);
    // We expect whatever ENV the container's image has, merged with props
    const expected = env_1.mergeEnvSimple(getEnv(img), props.environment);
    const actual = getEnv(ctr);
    return lodash_1.isEqual(actual, expected);
}
const stringPortRe = /^\d+(\/(tcp|udp|sctp))?$/;
function canonicalPort(port) {
    if (typeof port === "number")
        return `${port}/tcp`;
    if (typeof port === "string") {
        if (stringPortRe.test(port)) {
            return (port.includes("/")) ? port : `${port}/tcp`;
        }
    }
    throw new Error(`Invalid port number ${port}`);
}
function portsUpToDate(info, _context, props) {
    const ctr = info.data;
    const img = info.image;
    if (!ctr)
        throw new utils_1.InternalError(`No container report`);
    if (!img)
        throw new utils_1.InternalError(`No image report`);
    // We expect exposed ports to include:
    // - exposed ports in the container image
    // - ports.props
    // - the container ports from props.portBindings
    const imgPorts = Object.keys(img.Config.ExposedPorts || {});
    const propsPorts = (props.ports || []).map(canonicalPort);
    const boundPorts = Object.keys(props.portBindings || {}).map(canonicalPort);
    const expected = lodash_1.sortedUniq([...imgPorts, ...propsPorts, ...boundPorts].sort());
    const actual = lodash_1.sortedUniq(Object.keys(ctr.Config.ExposedPorts || {}).sort());
    return lodash_1.isEqual(actual, expected);
}
function portBindingsUpToDate(info, _context, props) {
    const ctr = info.data;
    const img = info.image;
    if (!ctr)
        throw new utils_1.InternalError(`No container report`);
    if (!img)
        throw new utils_1.InternalError(`No image report`);
    const fromProps = props.portBindings || {};
    const fromCtr = ctr.HostConfig.PortBindings;
    const expected = {};
    Object.keys(fromProps).forEach((p) => {
        expected[canonicalPort(p)] = fromProps[p];
    });
    const actual = {};
    Object.keys(fromCtr).forEach((p) => {
        const entry = fromCtr[p];
        if (!Array.isArray(entry)) {
            throw new utils_1.InternalError(`PortBinding entry not understood: ${entry}`);
        }
        if (entry.length !== 1) {
            throw new utils_1.InternalError(`PortBinding entry with length ` +
                `${entry.length} not supported`);
        }
        const hostPort = Number(entry[0].HostPort);
        if (isNaN(hostPort)) {
            throw new utils_1.InternalError(`PortBinding HostPort '${hostPort}' is ` +
                `not a number`);
        }
        actual[p] = hostPort;
    });
    return lodash_1.isEqual(actual, expected);
}
const anyVal = (key) => (val) => [key, val];
const mountTransform = {
    Destination: anyVal("destination"),
    Propagation: anyVal("propagation"),
    RW: (val) => ["readonly", !val],
    Source: anyVal("source"),
    Type: anyVal("type"),
};
const mountCompare = (a, b) => a.destination < b.destination ? -1 : a.destination > b.destination ? 1 : 0;
const mountDefaultProps = {
    readonly: false,
    propagation: "rprivate",
};
function mountsUpToDate(info, _context, props) {
    const ctr = info.data;
    if (!ctr)
        throw new utils_1.InternalError(`No container report`);
    const expectedIn = props.mounts || [];
    const expected = expectedIn.map((m) => (Object.assign({}, mountDefaultProps, m)));
    const actualIn = ctr.Mounts || [];
    const actual = actualIn.map((m) => {
        // We only support bind mounts at the moment. Ignore other mounts.
        if (m.Type !== "bind")
            return null;
        const out = {};
        Object.entries(m).forEach(([k, v]) => {
            const xform = mountTransform[k];
            if (xform) {
                const [outKey, outVal] = xform(v);
                out[outKey] = outVal;
            }
        });
        return out;
    }).filter(Boolean);
    return lodash_1.isEqual(actual.sort(mountCompare), expected.sort(mountCompare));
}
function privilegedUpToDate(info, _context, props) {
    const ctr = info.data;
    if (!ctr)
        throw new utils_1.InternalError(`No container report`);
    return props.privileged === ctr.HostConfig.Privileged;
}
async function containerIsUpToDate(info, context, props) {
    if (!containerExists(info))
        return "noExist";
    if (!containerExistsAndIsFromDeployment(info, context))
        return "existsUnmanaged";
    if (!info.data)
        throw new Error(`Container exists, but no info.data??: ${info}`);
    /*
     * Differences that require the container to be replaced.
     */
    if (await getImageId(props.image, props) !== info.data.Image)
        return "replace";
    if (!labelsUpToDate(info, context, props))
        return "replace";
    if (!envUpToDate(info, context, props))
        return "replace";
    if (!portsUpToDate(info, context, props))
        return "replace";
    if (!portBindingsUpToDate(info, context, props))
        return "replace";
    if (!mountsUpToDate(info, context, props))
        return "replace";
    if (!privilegedUpToDate(info, context, props))
        return "replace";
    /*
     * Differences that can be updated on a running container.
     */
    if (!(await networkDiff(info, props, "equals")))
        return "update";
    return "upToDate";
}
/**
 * Update a container described in info to match props that are updateable
 *
 * @remarks
 * Note that this will only update props that are updateable.  If there is a non-updateable change,
 * it will not take effect.  This should only be used if `containerIsUpToDate` returns `"update"` for info
 * and props.
 *
 * @internal
 */
async function updateContainer(info, _context, props) {
    //Networks
    if (!info.data)
        throw new Error(`No data for container??: ${info}`);
    const diff = await networkDiff(info, props, "diff");
    await cli_1.dockerNetworkConnect(info.name, diff.toAdd, Object.assign({}, props, { alreadyConnectedError: false }));
    await cli_1.dockerNetworkDisconnect(info.name, diff.toDelete, Object.assign({}, props, { alreadyDisconnectedError: false }));
}
async function stopAndRmContainer(_context, info, props) {
    if (!info.data)
        return;
    try {
        await cli_1.dockerStop([info.data.Id], { dockerHost: props.dockerHost });
    }
    catch (err) {
        // Ignore if it's already stopped
        if (err.message && /No such container/.test(err.message))
            return;
        throw err;
    }
    try {
        await cli_1.dockerRm([info.data.Id], { dockerHost: props.dockerHost });
    }
    catch (err) {
        const message = err.message || "";
        if (/already in progress/.test(message))
            return;
        // If autoRemove is set, container may not exist
        if (/No such container/.test(message))
            return;
        throw err;
    }
}
function getImageNameOrId(props) {
    const source = props.image;
    if (core_1.isHandle(source)) {
        const image = core_1.callInstanceMethod(source, undefined, "latestImage");
        if (!image)
            return undefined;
        if (image.nameTag)
            return image.nameTag;
        return image.id;
    }
    else {
        return source;
    }
}
async function runContainer(context, props) {
    const image = getImageNameOrId(props);
    const name = computeContainerNameFromContext(props, context);
    if (image === undefined)
        return;
    const { networks } = props, propsNoNetworks = tslib_1.__rest(props, ["networks"]);
    const opts = Object.assign({}, propsNoNetworks, { name,
        image, labels: Object.assign({}, (props.labels || {}), { [labels_1.adaptDockerDeployIDKey]: `${context.buildData.deployID}` }), network: (networks && networks[0]) || undefined });
    await cli_1.dockerRun(opts);
    if (networks && networks.length > 1) {
        const remainingNetworks = networks.slice(1);
        await cli_1.dockerNetworkConnect(name, remainingNetworks, Object.assign({}, props));
    }
}
function networkStatus(net, networks) {
    if ((net in networks) && (networks[net] !== undefined))
        return networks[net];
    for (const name of Object.keys(networks)) {
        if (net === networks[name].NetworkID)
            return networks[name];
    }
    return undefined;
}
/**
 * Component to instantiate an image container with docker
 *
 * @remarks
 * See {@link docker.DockerContainerProps}.
 *
 * @public
 */
class DockerContainer extends action_1.Action {
    constructor() {
        super(...arguments);
        this.dependsOn = (_goalStatus, helpers) => {
            if (!core_1.isHandle(this.props.image))
                return undefined;
            return helpers.dependsOn(this.props.image);
        };
    }
    /** @internal */
    async shouldAct(diff, context) {
        const containerInfo = await this.refreshContainerInfo(context);
        const displayName = this.displayName(context);
        switch (diff) {
            case "modify":
            case "create":
                const status = await containerIsUpToDate(containerInfo, context, this.props);
                switch (status) {
                    case "noExist":
                        return { act: true, detail: `Creating container ${displayName}` };
                    case "replace":
                        return { act: true, detail: `Replacing container ${displayName}` };
                    case "update":
                        return { act: true, detail: `Updating container ${displayName}` };
                    case "existsUnmanaged":
                        throw new Error(`Container ${containerInfo.name} already exstis,`
                            + ` but is not part of this deployment: ${containerInfo}`);
                    case "upToDate":
                        return false;
                    default:
                        throw new utils_1.InternalError(`Unhandled status '${status}' in DockerContainer`);
                }
            case "delete":
                return containerExistsAndIsFromDeployment(containerInfo, context)
                    ? { act: true, detail: `Deleting container ${displayName}` }
                    : false;
            case "none":
            case "replace":
            default:
                throw new utils_1.InternalError(`Unhandled ChangeType '${diff}' in DockerContainer`);
        }
    }
    /** @internal */
    async action(diff, context) {
        const oldInfo = await this.refreshContainerInfo(context);
        switch (diff) {
            case "modify":
            case "create":
                const image = getImageNameOrId(this.props);
                if (!image) {
                    // dependsOn should have prevented this condition
                    throw new Error(`Container cannot be deployed because the ` +
                        `specified image is not available`);
                }
                const status = await containerIsUpToDate(oldInfo, context, this.props);
                if (status === "existsUnmanaged") {
                    throw new Error(`Container ${oldInfo.name} already exstis,`
                        + ` but is not part of this deployment: ${util_1.inspect(oldInfo)}`);
                }
                if (status === "upToDate")
                    return;
                if (status === "update") {
                    await updateContainer(oldInfo, context, this.props);
                }
                if (status === "replace") {
                    await stopAndRmContainer(context, oldInfo, this.props);
                }
                if (status === "replace" || status === "noExist") {
                    await runContainer(context, this.props);
                }
                await this.refreshContainerInfo(context, true);
                return;
            case "delete":
                await stopAndRmContainer(context, oldInfo, this.props);
                this.info = undefined;
                this.setState({ info: undefined });
                return;
            case "none":
            case "replace":
            default:
                throw new utils_1.InternalError(`Unhandled ChangeType '${diff}' in DockerContainer`);
        }
    }
    async status(observe, buildData) {
        return containerStatus(observe, computeContainerNameFromBuildData(this.props, buildData), this.props.dockerHost);
    }
    /**
     * Get the IP address of the container, optionally for a specific Docker
     * network.
     * @remarks
     * The IP addresses that are returned by this function are valid only
     * on the associated Docker network, which is often only associated
     * with a single host node for most Docker network types.
     *
     * @param network - Name of a Docker network. If `network` is provided
     * and the container is connected to the network with an IP address, that
     * address will be returned. If the container is not connected to the
     * network, `undefined` will be returned. If `network` is not provided,
     * the default container IP address will be returned.
     *
     * @beta
     */
    dockerIP(network) {
        if (!this.state.info || !this.state.info.data)
            return undefined;
        const stat = this.state.info.data;
        if (!network) {
            if (stat.NetworkSettings.IPAddress === "")
                return undefined;
            return stat.NetworkSettings.IPAddress;
        }
        const netStat = networkStatus(network, stat.NetworkSettings.Networks);
        if (!netStat)
            return undefined;
        if (netStat.IPAddress === "")
            return undefined;
        return netStat.IPAddress;
    }
    /** @internal */
    initialState() { return {}; }
    displayName(context) {
        const name = computeContainerNameFromContext(this.props, context);
        return `'${this.props.key}' (${name})`;
    }
    async refreshContainerInfo(context, force = false) {
        if (!this.info || force) {
            // Only update info state once per deployOpID, unless forced.
            if (force || !this.state.info || this.state.deployOpID !== this.deployInfo.deployOpID) {
                const info = await fetchContainerInfo(context, this.props, this.state.info);
                this.setState({
                    deployOpID: this.deployInfo.deployOpID,
                    info,
                });
                this.info = info;
            }
            else {
                this.info = this.state.info;
            }
        }
        return this.info;
    }
}
DockerContainer.defaultProps = {
    dockerHost: cli_1.defaultDockerHost,
    privileged: false,
};
exports.DockerContainer = DockerContainer;
exports.default = DockerContainer;
/**
 * Compute the status of a container based on a graphQL schema
 *
 * @internal
 */
async function containerStatus(observe, containerName, dockerHost) {
    try {
        const obs = await observe(docker_observer_1.DockerObserver, core_1.gql `
            query ($name: String!, $dockerHost: String!) {
                withDockerHost(dockerHost: $dockerHost) {
                    ContainerInspect(id: $name) @all(depth: 10)
                }
            }`, {
            name: containerName,
            dockerHost,
        });
        return obs.withDockerHost.ContainerInspect;
    }
    catch (err) {
        if (!lodash_1.isError(err))
            throw err;
        if (err instanceof utils_1.MultiError &&
            err.errors.length === 1 &&
            err.errors[0].message &&
            err.errors[0].message.startsWith("No such container")) {
            return { noStatus: err.errors[0].message };
        }
        return { noStatus: err.message };
    }
}
exports.containerStatus = containerStatus;
//# sourceMappingURL=DockerContainer.js.map