UNPKG

@adpt/cloud

Version:
549 lines 21.1 kB
"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)); } 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