@adpt/cloud
Version:
AdaptJS cloud component library
549 lines • 21.1 kB
JavaScript
;
/*
* 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));
}
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";
/*
* 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 fetchContainerInfo(context, this.props, this.state.info);
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 fetchContainerInfo(context, this.props, this.state.info);
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);
}
const newInfo = await fetchContainerInfo(context, this.props, this.state.info);
this.setState({ info: newInfo });
return;
case "delete":
await stopAndRmContainer(context, oldInfo, this.props);
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})`;
}
}
DockerContainer.defaultProps = {
dockerHost: process.env.DOCKER_HOST
};
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