diff --git a/.gitignore b/.gitignore index 1e364bf..2d79772 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,7 @@ node_modules project.sublime-workspace lib test-results.xml -playground.js \ No newline at end of file +playground.js + +# Ides +.idea \ No newline at end of file diff --git a/README.md b/README.md index a24a82e..32ca669 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,307 @@ Node.js library for Docker Cloud API + +## Installation + +``` +npm install dockercloud +``` + +## Usage + +### Initilization + +To use this class firstly you need to instantiate a new instance: +```js +import DockerCloud from 'dockercloud' + +const dockerCloud = new DockerCloud('username', 'password') +``` + +### Methods + +All methods return promises. + +#### connect + +Connect to DockerCloud and listen for events. +This method is required to be called before using the waitUntil* methods. + +Example: +```js +await dockerCloud.connect() +const stack = await dockerCloud.findStackById('stackId') +await dockerCloud.waitUntilStackIsTerminated(stack) + +``` + +#### disconnect + +Disconnect from DockerCloud. +This method returns nothing. + +#### queryStacks + +The value returned by the promise will be a list of the stacks in your account. + +```js +const stacks = await dockerCloud.queryStacks() +``` + +#### findStackById + +The value returned by the promise will be the stack with the id passed. + +```js +try { + const stack = await dockerCloud.findStackById('stackId') + // Do something with the stack +} catch (error) { + // If the error is empty the stack isn't found (404) +} +``` + + +#### findStackByName + +Same as findStackById, but if the stack isn't found instead of rejection the promise resolves returning nothing + +#### createStack + +Create a new stack + +```js +await dockerCloud.createStack({ + name: 'human readable name', + nickname: 'optional nickname', + services: [{ + "name": "hello-word", + "image": "tutum/hello-world", + "target_num_containers": 2 + }] +}) +``` + +#### removeStack + +Remove a stack; the parameter is the stack object. + +```js +const stack = await dockerCloud.findStackById('uuid') +dockerCloud.removeStack(stack) +``` + +#### startStack + +Start a stack; the parameter is the stack object. + +```js +const stack = await dockerCloud.findStackById('uuid') +dockerCloud.startStack(stack) +``` + +#### getStackServices + +Get the services connected to a stack; the parameter is the stack object. + +```js +const stack = await dockerCloud.findStackById('uuid') +const services = dockerCloud.getStackServices(stack) +``` + +#### waitUntilStackIsTerminated + +Wait until a stack is terminated + +```js +await dockerCloud.connect() +const stack = await dockerCloud.findStackById('stackId') +await dockerCloud.waitUntilStackIsTerminated(stack) +``` + +#### waitUntilStackIsRunning + +Wait until a stack is running + +```js +await dockerCloud.connect() +const stack = await dockerCloud.findStackById('stackId') +await dockerCloud.waitUntilStackIsRunning(stack) +``` + +#### findServiceById + +The value returned by the promise will be the service with the id passed. + +```js +try { + const stack = await dockerCloud.findServiceById('serviceId') +} catch (error) { + // If the error is empty the service isn't found (404) +} +``` + +#### findServiceByName + +Same as findServiceById, but if the service isn't found instead of rejection the promise resolves returning nothing + +#### createService + +Create a new service + +```js +await dockerCloud.createService({ + image: "tutum/hello-world", + name: "Optional name", + target_num_containers: 2, // Optional, default 1 + run_command: "/run.sh", // Optional, The command used to start the containers of this service, overriding the value specified in the image. (default: null) + entrypoint: "/usr/sbin/sshd", // Optional, The command prefix used to start the containers of this service, overriding the value specified in the image. (default: null) + container_ports: [{ // Optional, An array of objects with port information to be published in the containers for this service, which will be added to the image port information. (default: []) + protocol: "tcp", + inner_port: 80, + outer_port: 80, + published: false + }], + container_envvars: [{ // Optional, An array of objects with environment variables to be added in the service containers on launch (overriding any image-defined environment variables). (default: []) + key: "DB_PASSWORD", + value: "mypass" + }], + linked_to_service: [{ // Optional, An array of service resource URIs to link this service to, including the link name. (default: []) + to_service: "/api/app/v1/service/80ff1635-2d56-478d-a97f-9b59c720e513/", + name: "db" + }], + bindings: [{ // Optional, An array of bindings this service has to mount. (default: []) + volumes_from: "/api/app/v1/service/80ff1635-2d56-478d-a97f-9b59c720e513/", + container_path: "", + rewritable: true, + host_path: "" + }], + autorestart: "OFF", // Optional, Whether the containers for this service should be restarted if they stop, i.e. ALWAYS (default: OFF, possible values: OFF, ON_FAILURE, ALWAYS) + autodestroy: "OFF", // Optional, Whether the containers should be terminated if they stop, i.e. OFF (default: OFF, possible values: OFF, ON_SUCCESS, ALWAYS) + sequential_deployment: false, // Optional, Whether the containers should be launched and scaled in sequence. (default: false) + roles: ["global"], // Optional, A list of Docker Cloud API roles to grant the service + privileged: false, // Optional, Whether to start the containers with Docker’s privileged flag set or not + deployment_strategy: "EMPTIEST_NODE", // Optional, Container distribution among nodes + tags: ["tag1","tag2"], + autoredeploy: false, // Optional, Whether to redeploy the containers of the service when its image is updated in Docker Cloud registry. (default: false) + net: "bridge", // Optional, possible values: bridge, host + pid: "host", // Optional, Set the PID (Process) Namespace mode for the containers. (default: none) + working_dir: "/var/app/", // Optional, Working directory for running binaries within a container of this service. (default: /) + nickname: "Optional nickname" +}) +``` + +#### removeService + +Remove a service; the parameter is the service object. + +```js +const service = await dockerCloud.findServiceById('uuid') +dockerCloud.removeService(service) +``` + +#### startService + +Start a service; the parameter is the service object. + +```js +const service = await dockerCloud.findServiceById('uuid') +dockerCloud.startService(service) +``` + +#### redeployService + +Redeploy a service; the parameter is the service object. + +```js +const service = await dockerCloud.findServiceById('uuid') +dockerCloud.redeployService(service) +``` + +#### getServiceContainers + +Get the containers connected to a service; the parameter is the service object. + +```js +const service = await dockerCloud.findServiceById('uuid') +const containers = dockerCloud.getServiceContainers(service) +``` + + +#### waitUntilServiceIsStopped + +Wait until a service is stopped + +```js +await dockerCloud.connect() +const service = await dockerCloud.findServiceById('serviceId') +await dockerCloud.waitUntilServiceIsStopped(service) +``` + +#### waitUntilServiceIsRunning + +Wait until a service is running + +```js +await dockerCloud.connect() +const service = await dockerCloud.findServiceById('serviceId') +await dockerCloud.waitUntilServiceIsRunning(service) +``` + + +#### findContainerById + +The value returned by the promise will be the container with the id passed. + +```js +try { + const stack = await dockerCloud.findContainerById('containerId') +} catch (error) { + // If the error is empty the container isn't found (404) +} +``` + + +#### waitUntilContainerIsStopped + +Wait until a container is stopped + +```js +await dockerCloud.connect() +const container = await dockerCloud.findContainerById('containerId') +await dockerCloud.waitUntilContainerIsStopped(container) +``` + + +#### findActionById + +The value returned by the promise will be the action with the id passed. + +```js +try { + const stack = await dockerCloud.findActionById('actionId') +} catch (error) { + // If the error is empty the action isn't found (404) +} +``` + + +#### waitUntilActionIsSuccess + +Wait until an action is successful + +```js +await dockerCloud.connect() +const action = await dockerCloud.findActionById('actionId') +await dockerCloud.waitUntilActionIsSuccess(action) +``` + +#### extractUuid + +Extract the UUID from an URL + +```js +const uuuid = dockerCloud.extractUuid('/api/app/v1/action/7c42003e-eb39-4adc-b5b9-cbb7607fc698/') +uuuid === '7c42003e-eb39-4adc-b5b9-cbb7607fc698' +``` diff --git a/package.json b/package.json index cbfe72b..df6dde1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dockercloud", - "version": "0.2.2", + "version": "0.2.3", "description": "Node.js library for Docker Cloud API", "main": "lib/dockercloud.js", "scripts": { diff --git a/src/dockercloud.js b/src/dockercloud.js index dd219ee..af98063 100644 --- a/src/dockercloud.js +++ b/src/dockercloud.js @@ -19,12 +19,15 @@ const EVENT_TYPES = { ACTION: 'action', } +const DockerCloudSubscribers = new WeakMap() + class DockerCloud { constructor(username, password) { this.credentials = { username, password, } + this.checkInterval = 5000 this.appRequest = request.defaults({ baseUrl: 'https://cloud.docker.com/api/app/v1', @@ -40,6 +43,7 @@ class DockerCloud { }, auth: { username, password }, }) + DockerCloudSubscribers.set(this, []) } stacks = { @@ -89,6 +93,40 @@ class DockerCloud { }) this.ws.on('open', () => resolve()) + + this.ws.on('message', (data) => { + const message = JSON.parse(data) + const subscribers = DockerCloudSubscribers.get(this) + for (const subscriber of subscribers) { + subscriber(message) + } + }) + }) + } + + subscribeToAllMessages(func) { + DockerCloudSubscribers.get(this).push(func) + } + + unSubscribeFromAllMessages(func) { + const subscribers = DockerCloudSubscribers.get(this) + const index = subscribers.indexOf(func) + if (~index) { + subscribers.splice(index, 1) + } + } + + subscribe({ type, state, resourceUri }) { + return new Promise((resolve) => { + const subscribeFunction = (message) => { + if (message.type === type && + message.state === state && + message.resource_uri.includes(resourceUri)) { + this.unSubscribeFromAllMessages(subscribeFunction) + resolve() + } + } + this.subscribeToAllMessages(subscribeFunction) }) } @@ -195,14 +233,16 @@ class DockerCloud { return new Promise((resolve) => { if (stack.state === STATES.TERMINATED) return resolve() - this.ws.on('message', (data) => { - const message = JSON.parse(data) - if (message.type === EVENT_TYPES.STACK && - message.state === STATES.TERMINATED && - message.resource_uri.includes(stack.uuid)) { - resolve() - } - }) + return Promise.race([ + this.subscribe({ + type: EVENT_TYPES.STACK, + state: STATES.TERMINATED, + resourceUri: stack.uuid, + }), + this.checkPeriodically('findStackById', stack.uuid, { + state: STATES.TERMINATED, + }, this.checkInterval), + ]).then(resolve) }) } @@ -210,14 +250,16 @@ class DockerCloud { return new Promise((resolve) => { if (stack.state === STATES.RUNNING) return resolve() - this.ws.on('message', (data) => { - const message = JSON.parse(data) - if (message.type === EVENT_TYPES.STACK && - message.state === STATES.RUNNING && - message.resource_uri.includes(stack.uuid)) { - resolve() - } - }) + return Promise.race([ + this.subscribe({ + type: EVENT_TYPES.STACK, + state: STATES.RUNNING, + resourceUri: stack.uuid, + }), + this.checkPeriodically('findStackById', stack.uuid, { + state: STATES.RUNNING, + }, this.checkInterval), + ]).then(resolve) }) } @@ -296,6 +338,37 @@ class DockerCloud { }) } + stopService(service) { + return new Promise((resolve, reject) => { + this.appRequest.post(`/service/${service.uuid}/stop/`, async (error, response, body) => { + if (error) return reject(error) + if (response.statusCode >= 300) return reject(body) + + const actionId = this.extractUuid(response.headers['x-dockercloud-action-uri']) + const action = await this.findActionById(actionId) + + return resolve(action) + }) + }) + } + + updateService(service, props) { + return new Promise((resolve, reject) => { + this.appRequest.patch({ + url: `/service/${service.uuid}/`, + body: JSON.stringify(props), + }, async (error, response, body) => { + if (error) return reject(error) + if (response.statusCode >= 300) return reject(body) + + const actionId = this.extractUuid(response.headers['x-dockercloud-action-uri']) + const action = await this.findActionById(actionId) + + return resolve(action) + }) + }) + } + redeployService(service) { return new Promise((resolve, reject) => { this.appRequest.post(`/service/${service.uuid}/redeploy/`, async (error, response, body) => { @@ -310,6 +383,44 @@ class DockerCloud { }) } + waitUntilServiceIsStopped(service) { + return new Promise((resolve) => { + if (service.state === STATES.STOPPED) return resolve() + + return Promise.race([ + // Subscribe to the websocket to get warned when the service is stopped + this.subscribe({ + type: EVENT_TYPES.CONTAINER, + state: STATES.STOPPED, + resourceUri: service.uuid, + }), + // Sometimes the websocket miss some message, check periodically if the service is stopped + this.checkPeriodically('findServiceById', service.uuid, { + state: STATES.STOPPED, + }, this.checkInterval), + ]).then(resolve) + }) + } + + waitUntilServiceIsRunning(service) { + return new Promise((resolve) => { + if (service.state === STATES.RUNNING) return resolve() + + return Promise.race([ + // Subscribe to the websocket to get warned when the service is stopped + this.subscribe({ + type: EVENT_TYPES.CONTAINER, + state: STATES.RUNNING, + resourceUri: service.uuid, + }), + // Sometimes the websocket miss some message, check periodically if the service is stopped + this.checkPeriodically('findServiceById', service.uuid, { + state: STATES.RUNNING, + }, this.checkInterval), + ]).then(resolve) + }) + } + getServiceContainers(service) { const promises = service.containers.map(container => { const tokens = container.split('/') @@ -334,18 +445,44 @@ class DockerCloud { }) } + checkPeriodically(action, uuid, until, interval) { + return new Promise((resolve) => { + const checkStatus = async () => { + const currentStatus = await this[action](uuid) + let waitedStatus = true + for (const key in until) { + if (until.hasOwnProperty(key)) { + if (!currentStatus.hasOwnProperty(key) || currentStatus[key] !== until[key]) { + waitedStatus = false + break + } + } + } + if (waitedStatus) { + return resolve() + } + return setTimeout(checkStatus, interval) + } + checkStatus() + }) + } + waitUntilContainerIsStopped(container) { return new Promise((resolve) => { if (container.state === STATES.STOPPED) return resolve() - this.ws.on('message', (data) => { - const message = JSON.parse(data) - if (message.type === EVENT_TYPES.CONTAINER && - message.state === STATES.STOPPED && - message.resource_uri.includes(container.uuid)) { - resolve() - } - }) + return Promise.race([ + // Subscribe to the websocket to get warned when the container is stopped + this.subscribe({ + type: EVENT_TYPES.CONTAINER, + state: STATES.STOPPED, + resourceUri: container.uuid, + }), + // Sometimes the websocket miss some message, check periodically if the container is stopped + this.checkPeriodically('findContainerById', container.uuid, { + state: STATES.STOPPED, + }, 5000), + ]).then(resolve) }) } @@ -366,14 +503,16 @@ class DockerCloud { return new Promise((resolve) => { if (action.state === STATES.SUCCESS) return resolve() - this.ws.on('message', (data) => { - const message = JSON.parse(data) - if (message.type === EVENT_TYPES.ACTION && - message.state === STATES.SUCCESS && - message.resource_uri.includes(action.uuid)) { - resolve() - } - }) + return Promise.race([ + this.subscribe({ + type: EVENT_TYPES.ACTION, + state: STATES.SUCCESS, + resourceUri: action.uuid, + }), + this.checkPeriodically('findActionById', action.uuid, { + state: STATES.SUCCESS, + }, this.checkInterval), + ]).then(resolve) }) }