| /* |
| * 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; |