blob: d1f11251a80502f9df548045cdac3cd4cbb3eaf8 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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.
*/
'use strict';
const fetch = require('fetch-retry')(require('isomorphic-fetch'));
const kinds = require('./kinds/kinds');
const path = require('path');
const log = require("./log");
const Docker = require('dockerode');
const getPort = require('get-port');
const dockerUtils = require('./dockerutils');
const prettyBytes = require('pretty-bytes');
const isPortReachable = require('is-port-reachable');
const RUNTIME_PORT = 8080;
const MAX_INIT_RETRY_MS = 20000; // 20 sec
const INIT_RETRY_DELAY_MS = 200;
const LABEL_ACTION_NAME = "org.apache.wskdebug.action";
// https://github.com/apache/incubator-openwhisk/blob/master/docs/reference.md#system-limits
const OPENWHISK_DEFAULTS = {
timeout: 60*1000,
memory: 256
};
// if value is a function, invoke it with args, otherwise return it as object
// if value is undefined, will return undefined
function resolveValue(value, ...args) {
if (typeof value === "function") {
return value(...args);
} else {
return value;
}
}
class OpenWhiskInvoker {
constructor(actionName, action, options, wskProps, wsk) {
this.actionName = actionName;
this.action = action;
this.kind = options.kind;
this.image = options.image;
this.port = options.port;
this.internalPort = options.internalPort;
this.command = options.command;
this.dockerArgs = options.dockerArgs;
// the build path can be separate, if not, same as the source/watch path
this.sourcePath = options.buildPath || options.sourcePath;
if (this.sourcePath) {
this.sourceDir = process.cwd();
// ensure sourcePath is relative to sourceDir
this.sourceFile = path.relative(this.sourceDir, this.sourcePath);
}
this.main = options.main;
this.wskProps = wskProps;
this.wsk = wsk;
this.containerName = dockerUtils.safeContainerName(`wskdebug-${this.actionName}-${Date.now()}`);
this.docker = new Docker();
}
async checkIfDockerAvailable() {
try {
await this.docker.ping();
log.debug("docker - availability check")
} catch (e) {
throw new Error("Docker not running on local system. A local docker environment is required for the debugger.")
}
}
async getImageForKind(kind) {
try {
const owSystemInfo = await this.wsk.actions.client.request("GET", "/");
if (owSystemInfo.runtimes) {
// transform result into a nice dictionary kind => image
const runtimes = {};
for (const set of Object.values(owSystemInfo.runtimes)) {
for (const entry of set) {
let image = entry.image;
// fix for Adobe I/O Runtime reporting incorrect image prefixes
image = image.replace("bladerunner/", "adobeapiplatform/");
runtimes[entry.kind] = image;
}
}
return runtimes[kind];
} else {
log.warn("Could not retrieve runtime images from OpenWhisk, using default image list.");
}
} catch (e) {
console.log(e);
log.warn("Could not retrieve runtime images from OpenWhisk, using default image list.", e.message);
}
return kinds.images[kind];
}
async prepare() {
const action = this.action;
// this must run after initial build was kicked off in Debugger so that built files are present
// kind and image - precendence:
// 1. arguments (this.image)
// 2. action (action.exec.image)
// 3. defaults (kinds.images[kind])
const kind = this.kind || action.exec.kind;
if (kind === "blackbox") {
throw new Error("Action is of kind 'blackbox', must specify kind using `--kind` argument.");
}
this.image = this.image || action.exec.image || await this.getImageForKind(kind);
if (!this.image) {
throw new Error(`Unknown kind: ${kind}. You might want to specify --image.`);
}
// debugging instructions
this.debugKind = kinds.debugKinds[kind] || kind.split(":")[0];
try {
this.debug = require(`${__dirname}/kinds/${this.debugKind}/${this.debugKind}`);
} catch (e) {
log.warn(`Cannot find debug info for kind ${this.debugKind}:`, e.message);
this.debug = {};
}
this.debug.internalPort = this.internalPort || resolveValue(this.debug.port, this);
this.debug.port = this.port || this.internalPort || resolveValue(this.debug.port, this);
// ------------------------
this.debug.command = this.command || resolveValue(this.debug.command, this);
if (!this.debug.port) {
throw new Error(`No debug port known for kind: ${kind}. Please specify --port.`);
}
if (!this.debug.internalPort) {
throw new Error(`No debug port known for kind: ${kind}. Please specify --internal-port.`);
}
if (!this.debug.command) {
throw new Error(`No debug command known for kind: ${kind}. Please specify --command.`);
}
// limits
this.memory = (action.limits.memory || OPENWHISK_DEFAULTS.memory) * 1024 * 1024;
// source mounting
if (this.sourcePath) {
if (!this.debug.mountAction) {
log.warn(`Warning: Sorry, mounting sources not yet supported for: ${kind}.`);
this.sourcePath = undefined;
}
}
this.dockerArgsFromKind = resolveValue(this.debug.dockerArgs, this) || "";
this.dockerArgsFromUser = this.dockerArgs || "";
if (this.sourcePath && this.debug.mountAction) {
this.sourceMountAction = resolveValue(this.debug.mountAction, this);
}
}
async isImagePresent(image, debug) {
try {
await this.docker.getImage(image).inspect();
debug(`docker - image inspected, is present: ${image}`);
return true;
} catch (e) {
debug(`docker - image inspected, not found: ${image}`);
return false;
}
}
async pull(image) {
await new Promise((resolve, reject) => {
this.docker.pull(image, (err, stream) => {
// streaming output from pull...
if (err) {
return reject(err);
}
function onFinished(err, output) {
if (err) {
return reject(err);
}
return resolve(output);
}
const events = {};
function onProgress(event) {
if (!event.progress) {
return;
}
if (event.status) {
events[event.status] = events[event.status] || {};
if (event.id) {
events[event.status][event.id] = event;
}
}
const progressMsg = Object.entries(events).reduce((result, [status, events], idx) => {
const progress = Object.values(events).reduce((sum, e) => {
if (e.progressDetail && e.progressDetail.current && e.progressDetail.total) {
sum.current += e.progressDetail.current;
sum.total += e.progressDetail.total;
}
return sum;
}, { current: 0, total: 0 });
return result + `${idx > 0 ? ", " : ""}${status}: ${prettyBytes(progress.current)} of ${prettyBytes(progress.total)}`;
}, "");
log.spinner(`Pulling docker image ${image} (${progressMsg})`);
}
this.docker.modem.followProgress(stream, onFinished, onProgress);
});
});
}
getFullActionName() {
return `/${this.wskProps.namespace}/${this.actionName}`;
}
async checkExistingContainers() {
let containers = await this.docker.listContainers();
const fullActionName = this.getFullActionName();
// remove all left over containers with the same action name label
for (const container of containers) {
if (container.Labels[LABEL_ACTION_NAME] === fullActionName) {
log.warn(`Removing container from a previous wskdebug run for this action (${dockerUtils.getContainerName(container)}).`)
const oldContainer = await this.docker.getContainer(container.Id);
await oldContainer.remove({force: true});
}
}
// check if the debug port is already in use
if (await isPortReachable(this.debug.port)) {
containers = await this.docker.listContainers();
// then check if it's another container with that port
for (const container of containers) {
for (const port of container.Ports) {
if (port.PublicPort === this.debug.port) {
// check if wskdebug container by looking at our label
if (container.Labels[LABEL_ACTION_NAME]) {
// wskdebug of different action
throw new Error(`Debug port ${this.debug.port} already in use by wskdebug for action ${container.Labels[LABEL_ACTION_NAME]}, cotainer ${dockerUtils.getContainerName(container)} (id: ${container.Id}).`);
} else {
// some non-wskdebug container
throw new Error(`Debug port ${this.debug.port} already in use by another docker container ${dockerUtils.getContainerName(container)} (id: ${container.Id}).`);
}
}
}
}
// some other process uses the port
throw new Error(`Debug port ${this.debug.port} already in use.`);
}
}
async startContainer(debug) {
if (!await this.isImagePresent(this.image, debug)) {
// show after 8 seconds, as VS code will timeout after 10 secs by default,
// so that the user can see it after all the "docker pull" progress output
setTimeout(() => {
log.warn(`
+------------------------------------------------------------------------------------------+
| Docker image being downloaded: ${this.image}
| |
| Note: If you debug in VS Code and it fails with "Cannot connect to runtime process" |
| due to a timeout, run this command once: |
| |
| docker pull ${this.image}
| |
| Alternatively set a higher 'timeout' in the launch configuration, such as 60000 (1 min). |
+------------------------------------------------------------------------------------------+
`);
}, 8000);
debug(`Pulling ${this.image}`)
log.spinner(`Pulling ${this.image}...`);
await this.pull(this.image);
debug("Pull complete");
}
await this.checkExistingContainers();
log.spinner('Starting container');
// links for docker create container config:
// docker api: https://docs.docker.com/engine/api/v1.37/#operation/ContainerCreate
// docker run impl: https://github.com/docker/cli/blob/2c3797015f5e7ef4502235b638d161279c471a8d/cli/command/container/run.go#L33
// https://github.com/apocas/dockerode/issues/257
// https://github.com/apocas/dockerode/blob/master/lib/docker.js#L1442
// https://medium.com/@johnnyeric/how-to-reproduce-command-docker-run-via-docker-remote-api-with-node-js-5918d7b221ea
const containerRuntimePort = `${RUNTIME_PORT}/tcp`;
const hostRuntimePort = await getPort();
const ipAddress = process.env.DOCKER_HOST_IP || "0.0.0.0";
this.containerURL = `http://${ipAddress}:${hostRuntimePort}`;
const containerDebugPort = `${this.debug.internalPort}/tcp`;
const createContainerConfig = {
name: this.containerName,
Labels: {
[LABEL_ACTION_NAME]: this.getFullActionName()
},
Image: this.image,
Cmd: [ 'sh', '-c', this.debug.command ],
Env: [],
Volumes: {},
ExposedPorts: {
[containerRuntimePort]: {},
[containerDebugPort]: {}
},
HostConfig: {
AutoRemove: true,
PortBindings: {
[containerRuntimePort]: [{ HostPort: `${hostRuntimePort}` }],
[containerDebugPort]: [{ HostPort: `${this.debug.port}` }]
},
Memory: this.memory,
Binds: []
}
};
if (this.debug.updateContainerConfig) {
this.debug.updateContainerConfig(this, createContainerConfig);
}
dockerUtils.dockerRunArgs2CreateContainerConfig(this.dockerArgsFromUser, createContainerConfig);
debug("docker - creating container:", createContainerConfig);
this.container = await this.docker.createContainer(createContainerConfig);
const stream = await this.container.attach({
stream: true,
stdout: true,
stderr: true
});
const spinnerSafeStream = (stream) => ({
write: (data) => {
log.stopSpinner();
stream(data.toString().replace(/\n$/, ""));
log.resumeSpinner();
}
});
this.container.modem.demuxStream(
stream,
spinnerSafeStream(console.log),
spinnerSafeStream(console.error)
);
await this.container.start();
debug(`docker - started container ${this.container.id}`);
}
getSourcePath() {
return this.sourcePath;
}
getImage() {
return this.image;
}
getDebugKind() {
return this.debugKind;
}
getPort() {
return this.debug.port;
}
name() {
return this.containerName;
}
url() {
return this.containerURL || "";
}
timeout() {
return this.action.limits.timeout || OPENWHISK_DEFAULTS.timeout;
}
async init(actionWithCode) {
let action;
if (this.sourceMountAction) {
action = this.sourceMountAction;
} else {
action = {
binary: actionWithCode.exec.binary,
main: actionWithCode.exec.main || "main",
code: actionWithCode.exec.code,
};
}
const RETRIES = MAX_INIT_RETRY_MS / INIT_RETRY_DELAY_MS;
const response = await fetch(`${this.url()}/init`, {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
value: action
}),
retryDelay: INIT_RETRY_DELAY_MS,
retryOn: function(attempt, error) {
// after 1.5 seconds, show retry to user via spinner
if (attempt >= 1500 / INIT_RETRY_DELAY_MS) {
log.spinner(`Installing action (retry ${attempt}/${RETRIES})`)
}
return error !== null && attempt < RETRIES;
}
});
if (response.status === 502) {
const body = await response.json();
throw new Error("Could not initialize action code on local debug container:\n\n" + body.error);
}
}
async run(args, activationId) {
const response = await fetch(`${this.url()}/run`, {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
value: args,
api_host : this.wskProps.apihost,
api_key : this.wskProps.api_key,
namespace : this.wskProps.namespace,
action_name : `/${this.wskProps.namespace}/${this.actionName}`,
activation_id : activationId,
deadline : `${Date.now() + this.timeout()}`,
allow_concurrent: "true"
})
});
return response.json();
}
async stop() {
if (this.container) {
// log this here for VS Code, will be the last visible log message since
// we will be killed by VS code after the container is gone after the kill()
log.log(`Stopping container ${this.name()}.`);
try {
await this.container.remove({ force: true});
} catch (e) {
// if we get a 404 the container is already gone (our goal), no need to log this error
if (e.statusCode !== 404) {
log.exception(e, "Error while removing container");
}
}
delete this.container;
log.debug(`docker - stopped container ${this.name()}`);
}
}
}
module.exports = OpenWhiskInvoker;