├── .circleci └── config.yml ├── .gitignore ├── bin └── now-compose ├── examples └── cluster │ ├── .gitignore │ ├── .now │ └── docker-compose.yml │ ├── locations_api │ ├── Dockerfile │ ├── index.js │ └── package.json │ ├── makefile │ ├── now-compose.yml │ ├── people_api │ ├── Dockerfile │ ├── index.js │ └── package.json │ ├── readme.md │ └── web │ ├── Dockerfile │ ├── index.js │ └── package.json ├── lib ├── cli │ ├── commands │ │ ├── deploy.js │ │ ├── deploy.test.js │ │ ├── generic.js │ │ └── up.js │ ├── exec.js │ ├── exec.test.js │ ├── index.js │ └── interceptor.js ├── config │ ├── docker-compose.js │ ├── docker-compose.test.js │ └── index.js ├── directory-tree │ └── index.js ├── errors │ └── index.js └── now │ ├── api │ └── index.js │ └── deployment │ ├── index.js │ └── upload.js ├── license.md ├── makefile ├── package.json └── readme.md /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:latest 6 | working_directory: ~/repo 7 | steps: 8 | - checkout 9 | - restore_cache: 10 | keys: 11 | - v1-dependencies-{{ checksum "package.json" }} 12 | - v1-dependencies- 13 | - run: npm install 14 | - save_cache: 15 | paths: 16 | - node_modules 17 | key: v1-dependencies-{{ checksum "package.json" }} 18 | - run: npm test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | package-lock.json 4 | 5 | # logs 6 | *.log 7 | 8 | # coverage 9 | .nyc_output 10 | coverage 11 | 12 | # misc 13 | .DS_Store 14 | *.code-workspace 15 | *.yaml 16 | 17 | # editor settings 18 | *.code-workspace 19 | 20 | # env file for now api key (dev / test purposes only) 21 | key.env 22 | 23 | # ignore releases folder that hold pkg'd binaries 24 | releases -------------------------------------------------------------------------------- /bin/now-compose: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // App 4 | const run = require('../lib/cli') 5 | const pkg = require('../package') 6 | const errors = require('../lib/errors') 7 | 8 | // Deps 9 | const chalk = require('chalk') 10 | const arg = require('arg') 11 | const commandExists = require('command-exists') 12 | const checkForUpdate = require('update-check') 13 | 14 | const processArgs = process.argv.slice(2) 15 | 16 | // Check for updates 17 | const updateCheck = async isDebugging => { 18 | const warning = message => chalk`{yellow WARNING:} ${message}` 19 | let update = null 20 | 21 | try { 22 | update = await checkForUpdate(pkg) 23 | } catch (err) { 24 | console.error(warning(`Checking for updates failed.`)) 25 | } 26 | 27 | if (!update) { 28 | return 29 | } 30 | 31 | console.log( 32 | `${chalk.bgRed( 33 | ' UPDATE AVAILABLE ' 34 | )} The latest version of \`now-compose\` is ${update.latest}` 35 | ) 36 | } 37 | 38 | const args = arg( 39 | { 40 | '--help': Boolean, 41 | '--file': String, 42 | '--version': Boolean, 43 | '--public': Boolean, 44 | '--apiKey': String, 45 | 46 | '-h': '--help', 47 | '-f': '--file', 48 | '-v': '--version', 49 | '-p': '--public', 50 | '-k': '--apiKey' 51 | }, 52 | { 53 | argv: processArgs, 54 | permissive: true 55 | } 56 | ) 57 | 58 | // the first argument passed is the command to run 59 | const command = processArgs.shift() 60 | const { '--help': showHelp, '--version': showVersion } = args 61 | 62 | // if no command is passed in show help screen 63 | if (showHelp || !command) { 64 | console.log(chalk` 65 | {bold.cyan now-compose} - docker compose for zeit/now. 66 | 67 | {bold USAGE} 68 | 69 | {bold $} {cyan now-compose} --help 70 | {bold $} {cyan now-compose} --version 71 | {bold $} {cyan now-compose} 72 | 73 | {bold NOW COMPOSE COMMANDS} 74 | 75 | up Run docker-compose "up" command. Host names for linked 76 | services will be passed in environment variables to 77 | docker containers. 78 | 79 | {bold *NOTE* When developing locally and containers are not being updated 80 | you can rebuild the containers without cache by using the "--no-cache" 81 | flag.} 82 | 83 | deploy Deploy your project to zeit/now. 84 | 85 | {bold *NOTE* If your account is under the zeit OSS plan, you must 86 | set the {cyan -p} flag to deploy your project as public.} 87 | 88 | {bold DOCKER COMPOSE COMMANDS} 89 | 90 | {cyan now-compose} forwards all other commands to docker-compose with any arguments 91 | you have defined. Please refer to https://docs.docker.com/compose/reference/overview/. 92 | 93 | {bold OPTIONS} 94 | 95 | --file, -f A .yaml file not in the current directory which contains 96 | the now-compose configuration. 97 | 98 | --apiKey, -k Your account's Zeit API token. 99 | 100 | --public, -p A boolean indicating if the deployment's code is public. 101 | For every deployment done under the OSS plan, this needs 102 | to be set to true. The default will be false. 103 | 104 | --version, -v Displays app version. 105 | 106 | --help, -h Displays this message. 107 | 108 | --no-cache Rebuild docker containers when using the "up" command. 109 | `) 110 | 111 | process.exit(0) 112 | } 113 | 114 | if (showVersion) { 115 | console.log(errors.info(`version: ${pkg.version}`)) 116 | 117 | process.exit(0) 118 | } 119 | 120 | if (args[command]) { 121 | const warning = errors.warning(`No commands specified to run. Exiting...`) 122 | 123 | errors.warn(warning) 124 | 125 | process.exit(0) 126 | } 127 | 128 | ;(async () => { 129 | try { 130 | await updateCheck() 131 | 132 | try { 133 | await commandExists('docker-compose') 134 | } catch (_) { 135 | errors.exit(errors.dockerComposeNotInstalled()) 136 | } 137 | 138 | await run(command, args) 139 | } catch (err) { 140 | errors.exit(errors.general(err)) 141 | } 142 | })() 143 | -------------------------------------------------------------------------------- /examples/cluster/.gitignore: -------------------------------------------------------------------------------- 1 | build -------------------------------------------------------------------------------- /examples/cluster/.now/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | web: 4 | build: ../web 5 | ports: 6 | - '3000:3000' 7 | restart: on-failure 8 | links: 9 | - people_api 10 | - locations_api 11 | depends_on: 12 | - people_api 13 | - locations_api 14 | environment: 15 | PORT: '3000' 16 | NOW_HOST_PEOPLE_API: host.docker.internal 17 | NOW_PORT_PEOPLE_API: '3001' 18 | NOW_HOST_LOCATIONS_API: host.docker.internal 19 | NOW_PORT_LOCATIONS_API: '3002' 20 | people_api: 21 | build: ../people_api 22 | ports: 23 | - '3001:3001' 24 | restart: on-failure 25 | environment: 26 | PORT: '3001' 27 | locations_api: 28 | build: ../locations_api 29 | ports: 30 | - '3002:3002' 31 | restart: on-failure 32 | environment: 33 | PORT: '3002' 34 | -------------------------------------------------------------------------------- /examples/cluster/locations_api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:jessie-slim 2 | COPY ./build/api ./ 3 | EXPOSE 3002 4 | CMD ["./api"] -------------------------------------------------------------------------------- /examples/cluster/locations_api/index.js: -------------------------------------------------------------------------------- 1 | const micro = require('micro') 2 | const { send } = require('micro') 3 | 4 | // ENV VARS 5 | const port = process.env.PORT || 3002 6 | 7 | const server = micro(async (req, res) => { 8 | const data = [ 9 | { 10 | location_id: 1, 11 | city: 'Tampa', 12 | state: 'FL', 13 | created: '2018-06-12T02:50:27.000Z', 14 | deleted: 0 15 | }, 16 | { 17 | location_id: 2, 18 | city: 'Miami', 19 | state: 'FL', 20 | created: '2018-06-12T02:50:27.000Z', 21 | deleted: 0 22 | } 23 | ] 24 | 25 | send(res, 200, data) 26 | }) 27 | 28 | console.log(`Server started on port :${port}`) 29 | server.listen(port) 30 | -------------------------------------------------------------------------------- /examples/cluster/locations_api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "Danny Navarro", 6 | "license": "MIT", 7 | "bin": "index.js", 8 | "dependencies": { 9 | "micro": "^9.3.2" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/cluster/makefile: -------------------------------------------------------------------------------- 1 | PHONY: pkg 2 | SHELL := /bin/bash 3 | 4 | install_deps: 5 | npm i pkg -g 6 | cd ./locations_api && npm i 7 | cd ./people_api && npm i 8 | cd ./web && npm i 9 | 10 | build_locations: 11 | rm -rf ./locations_api/build 12 | pkg ./locations_api --targets latest-linux-x64 --output ./locations_api/build/api 13 | 14 | build_people: 15 | rm -rf ./people_api/build 16 | pkg ./people_api --targets latest-linux-x64 --output ./people_api/build/api 17 | 18 | build_web: 19 | rm -rf ./web/build 20 | pkg ./web --targets latest-linux-x64 --output ./web/build/web 21 | 22 | # install dependencies, pkg node apps 23 | pkg: install_deps build_locations build_people build_web 24 | @echo -e "\ndone :-)\n" -------------------------------------------------------------------------------- /examples/cluster/now-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | web: 4 | build: ./web 5 | ports: 6 | - 3000:3000 7 | restart: on-failure 8 | links: 9 | - people_api 10 | - locations_api 11 | depends_on: 12 | - people_api 13 | - locations_api 14 | environment: 15 | PORT: "3000" 16 | people_api: 17 | build: ./people_api 18 | ports: 19 | - 3001:3001 20 | restart: on-failure 21 | environment: 22 | PORT: "3001" 23 | locations_api: 24 | build: ./locations_api 25 | ports: 26 | - 3002:3002 27 | restart: on-failure 28 | environment: 29 | PORT: "3002" -------------------------------------------------------------------------------- /examples/cluster/people_api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:jessie-slim 2 | COPY ./build/api ./ 3 | EXPOSE 3001 4 | CMD ["./api"] -------------------------------------------------------------------------------- /examples/cluster/people_api/index.js: -------------------------------------------------------------------------------- 1 | const micro = require('micro') 2 | const { send } = require('micro') 3 | 4 | // ENV VARS 5 | const port = process.env.PORT || 3000 6 | 7 | const server = micro(async (req, res) => { 8 | const data = [ 9 | { 10 | people_id: 1, 11 | firstname: 'Danny', 12 | lastname: 'Navarro', 13 | created: '2018-06-12T02:50:27.000Z', 14 | deleted: 0 15 | }, 16 | { 17 | people_id: 2, 18 | firstname: 'Jane', 19 | lastname: 'Doe', 20 | created: '2018-06-12T02:50:27.000Z', 21 | deleted: 0 22 | }, 23 | { 24 | people_id: 3, 25 | firstname: 'Kanye', 26 | lastname: 'West', 27 | created: '2018-06-12T02:50:27.000Z', 28 | deleted: 0 29 | }, 30 | { 31 | people_id: 4, 32 | firstname: 'Takeoff', 33 | lastname: '', 34 | created: '2018-06-12T02:50:27.000Z', 35 | deleted: 0 36 | }, 37 | { 38 | people_id: 5, 39 | firstname: 'Quavo', 40 | lastname: '', 41 | created: '2018-06-12T02:50:27.000Z', 42 | deleted: 0 43 | }, 44 | { 45 | people_id: 6, 46 | firstname: 'Billy', 47 | lastname: 'Joel', 48 | created: '2018-06-12T02:50:27.000Z', 49 | deleted: 0 50 | } 51 | ] 52 | 53 | send(res, 200, data) 54 | }) 55 | 56 | console.log(`Server started on port :${port}`) 57 | server.listen(port) 58 | -------------------------------------------------------------------------------- /examples/cluster/people_api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "Danny Navarro", 6 | "license": "MIT", 7 | "bin": "index.js", 8 | "dependencies": { 9 | "micro": "^9.3.2" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/cluster/readme.md: -------------------------------------------------------------------------------- 1 | This project is an example of how you can setup a microservice architecture 2 | locally using now-compose. 3 | 4 | For demo purposes this example is hosted on zeit now at [https://web-mrmyxtbfxg.now.sh](https://web-mrmyxtbfxg.now.sh) 5 | 6 | ## Architecture of the example project 7 | 8 | This project is broken up into 3 microservices: 9 | 10 | - people_api 11 | - a microservice that returns a list of people as json 12 | - locations_api 13 | - a microservice that returns a list of locations as json 14 | - web 15 | - a microservice that makes http requests to the people and locations api and responds with the merged json. 16 | 17 | ## Preparing and running the project 18 | 19 | For this example [zeit/pkg](https://github.com/zeit/pkg) is used to compile all 20 | Node.js services into a binary for use on linux. We will ultimately copy it into our 21 | Docker containers during the container building process. 22 | 23 | Checkout the [makefile](./makefile) for more setup information. 24 | 25 | The following steps will need to be run from a terminal in this directory: 26 | 27 | 1. run `make` to generate all binaries in their project's `build` folder. 28 | 2. run `now-compose up` to spin up the microservices locally and get a stream of logs to stdout. 29 | 30 | ## Configuration of each service 31 | 32 | The following are the container configurations of each service: 33 | 34 | ### people_api 35 | 36 | - bound to: `localhost:3001` 37 | 38 | ### locations_api 39 | 40 | - bound to: `localhost:3002` 41 | 42 | ### web 43 | 44 | - bound to: `localhost:3000` 45 | 46 | ## Deploying to zeit now 47 | 48 | Make sure that you generate a new API token for now-compose to use. 49 | 50 | Generate an api token for `now-compose` to use [in the zeit dashboard](https://zeit.co/account/tokens) 51 | 52 | After running `make` you can deploy to zeit now with: 53 | 54 | ``` 55 | now-compose deploy --apiKey= 56 | ``` 57 | 58 | When the services are finished deploying you should get three urls back for each service deployed. 59 | 60 | --- 61 | 62 | ## Modifications 63 | 64 | When developing locally with `now-compose` builds of containers are cached by default. 65 | If you make modifications and changes aren't seen with `now-compose up` you can force a 66 | rebuild of all containers by passing the `--no-cache` flag to the up command: 67 | 68 | ``` 69 | now-compose up --no-cache 70 | ``` 71 | -------------------------------------------------------------------------------- /examples/cluster/web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:jessie-slim 2 | COPY ./build/web ./ 3 | EXPOSE 3000 4 | CMD ["./web"] -------------------------------------------------------------------------------- /examples/cluster/web/index.js: -------------------------------------------------------------------------------- 1 | const micro = require('micro') 2 | const { send } = require('micro') 3 | const axios = require('axios') 4 | 5 | // ENV VARS 6 | const port = process.env.port || 3000 7 | const isDeployed = process.env.NOW_DEPLOYED ? true : false 8 | const peopleAPIPort = process.env['NOW_PORT_PEOPLE_API'] 9 | const peopleAPIHost = process.env['NOW_HOST_PEOPLE_API'] 10 | 11 | const locationsAPIHost = process.env['NOW_HOST_LOCATIONS_API'] 12 | const locationsAPIPort = process.env['NOW_PORT_LOCATIONS_API'] 13 | 14 | const readme = ` 15 | now-compose is a docker-compose integration for use with https://zeit.co/now 16 | for more information on now-compose visit: https://github.com/dannav/now-compose/ 17 | ` 18 | 19 | const peopleAPIURL = isDeployed 20 | ? `https://${peopleAPIHost}` 21 | : `http://${peopleAPIHost}:${peopleAPIPort}` 22 | 23 | const locationsAPIURL = isDeployed 24 | ? `https://${locationsAPIHost}` 25 | : `http://${locationsAPIHost}:${locationsAPIPort}` 26 | 27 | const getData = async url => { 28 | const r = await axios.get(url) 29 | 30 | if (r.status == 200) { 31 | return r.data 32 | } 33 | 34 | throw new Error(r.status) 35 | } 36 | 37 | const server = micro(async (req, res) => { 38 | try { 39 | const people = await getData(peopleAPIURL) 40 | const locations = await getData(locationsAPIURL) 41 | 42 | return send(res, 200, { 43 | readme, 44 | people, 45 | locations 46 | }) 47 | } catch (err) { 48 | return send(res, 500, 'Internal Server Error') 49 | } 50 | }) 51 | 52 | console.log(`Server started on port :${port}`) 53 | server.listen(port) 54 | -------------------------------------------------------------------------------- /examples/cluster/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "Danny Navarro", 6 | "license": "MIT", 7 | "bin": "index.js", 8 | "dependencies": { 9 | "axios": "^0.18.0", 10 | "micro": "^9.3.2" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /lib/cli/commands/deploy.js: -------------------------------------------------------------------------------- 1 | const nowDeploy = require('../../now/deployment') 2 | const errors = require('../../errors') 3 | 4 | class DeployCommand { 5 | constructor(config, args) { 6 | this.config = config 7 | this.args = args 8 | } 9 | 10 | async shutdown() { 11 | process.exit(0) 12 | } 13 | 14 | async run() { 15 | const apiKey = this.args['--apiKey'] || process.env.NOW_API_KEY 16 | if (!apiKey) { 17 | errors.exit(errors.deployAPIKeyNotSet()) 18 | } 19 | 20 | // set in global for use by deployment api 21 | global.NOW_API_KEY = apiKey 22 | global.NOW_DEPLOY_PUBLIC = this.args['--public'] ? true : false 23 | 24 | // upload each projects files 25 | try { 26 | await nowDeploy(this.config.dockerComposeConfig) 27 | } catch (err) { 28 | let message = err 29 | if (err.message) { 30 | message = err.message 31 | } 32 | 33 | errors.exit(errors.general(message)) 34 | } 35 | } 36 | } 37 | 38 | module.exports = DeployCommand 39 | -------------------------------------------------------------------------------- /lib/cli/commands/deploy.test.js: -------------------------------------------------------------------------------- 1 | const Deploy = require('./deploy') 2 | 3 | jest.unmock('../../errors') 4 | const errors = require('../../errors') 5 | errors.exit = jest.fn() 6 | 7 | describe('command - deploy', () => { 8 | it('should exit if apiKey is not defined in args', async () => { 9 | const cmd = new Deploy({}, []) 10 | await cmd.run() 11 | expect(errors.exit).toBeCalled() 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /lib/cli/commands/generic.js: -------------------------------------------------------------------------------- 1 | const { exec } = require('../exec') 2 | 3 | class GenericCommand { 4 | constructor(config, args) { 5 | this.config = config 6 | this.args = args 7 | } 8 | 9 | async shutdown() { 10 | process.exit(0) 11 | } 12 | 13 | async run() { 14 | await exec('docker-compose', this.args) 15 | } 16 | } 17 | 18 | module.exports = GenericCommand 19 | -------------------------------------------------------------------------------- /lib/cli/commands/up.js: -------------------------------------------------------------------------------- 1 | const { exec } = require('../exec') 2 | 3 | class UpCommand { 4 | constructor(config, args) { 5 | this.config = config 6 | this.args = args 7 | } 8 | 9 | // TODO - when micro-proxy is implemented shutting it down will be handled here 10 | async shutdown() { 11 | process.exit(0) 12 | } 13 | 14 | async run() { 15 | /* 16 | TODO - handle path aliases and spinning up micro-proxy server if is a node or static build 17 | */ 18 | 19 | // build without cache first (fixes issue where container isn't updated) 20 | const noCacheIdx = this.args['_'].indexOf('--no-cache') 21 | if (noCacheIdx > -1) { 22 | let buildArgs = {} 23 | buildArgs['--file'] = this.args['--file'] 24 | buildArgs['build'] = '' 25 | buildArgs['--no-cache'] = '' 26 | 27 | await exec('docker-compose', buildArgs) 28 | this.args['_'].splice(noCacheIdx, 1) 29 | } 30 | 31 | await exec('docker-compose', this.args) 32 | } 33 | } 34 | 35 | module.exports = UpCommand 36 | -------------------------------------------------------------------------------- /lib/cli/exec.js: -------------------------------------------------------------------------------- 1 | const { spawn } = require('child_process') 2 | 3 | // flattenArgs takes a zeit/arg obj and reduces to flat array 4 | // https://github.com/zeit/arg 5 | const flattenArgs = args => { 6 | return Object.keys(args).reduce((acc, key) => { 7 | let a = [] 8 | 9 | if (key === '_') { 10 | a = a.concat(args[key]) 11 | } else { 12 | a = a.concat([key], args[key]) 13 | } 14 | 15 | return acc.concat(a) 16 | }, []) 17 | } 18 | 19 | // shiftFlag takes arguments and a flag, and moves the flag to the front of an array 20 | const shiftFlag = (args, flag) => { 21 | if (args.indexOf(flag) > -1) { 22 | const flagAndVal = args.splice(args.indexOf(flag), 2) 23 | return [...flagAndVal, ...args] 24 | } 25 | 26 | return args 27 | } 28 | 29 | // exec forwards commands and args passed to now-compose to docker-compose 30 | // cmd inherits now-compose's stdio 31 | const exec = (command = 'docker-compose', args) => 32 | new Promise((res, rej) => { 33 | const cmd = spawn(command, shiftFlag(flattenArgs(args), '--file'), { 34 | cwd: process.cwd(), 35 | shell: true, 36 | stdio: 'inherit' 37 | }) 38 | 39 | cmd.on('close', code => { 40 | if (code > 0) { 41 | return rej(code) 42 | } 43 | 44 | return res(code) 45 | }) 46 | }) 47 | 48 | module.exports = { 49 | exec, 50 | flattenArgs, 51 | shiftFlag 52 | } 53 | -------------------------------------------------------------------------------- /lib/cli/exec.test.js: -------------------------------------------------------------------------------- 1 | const { shiftFlag, flattenArgs } = require('./exec') 2 | 3 | describe('exec', () => { 4 | describe('shiftFlag', () => { 5 | it('should move defined flag and value to front of array', () => { 6 | const args = ['command', '--help', '--file', 'file.txt'] 7 | const expected = ['--file', 'file.txt', 'command', '--help'] 8 | 9 | const shifted = shiftFlag(args, '--file') 10 | expect(shifted).toEqual(expected) 11 | }) 12 | 13 | it('should return args unmodified if flag passed does not exist', () => { 14 | const args = ['command', '--help', '--file', 'file.txt'] 15 | const expected = args 16 | 17 | const shifted = shiftFlag(args, '--test') 18 | expect(shifted).toEqual(expected) 19 | }) 20 | }) 21 | 22 | describe('flattenArgs', () => { 23 | it('should flatten a zeit/args object to an array', () => { 24 | const args = { 25 | _: ['hello', 'world'], 26 | '--file': 'test.txt' 27 | } 28 | 29 | const expected = ['hello', 'world', '--file', 'test.txt'] 30 | const flattened = flattenArgs(args) 31 | expect(flattened).toEqual(expected) 32 | }) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /lib/cli/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | 4 | const { parseConfig, readConfig, mkdir, writeFile } = require('../config') 5 | const Interceptor = require('./interceptor') 6 | const errors = require('../errors') 7 | 8 | // configFilePath returns the absolute path of now-compose.yaml in the current 9 | // directory or point to the file defined in the "--file" flag argument relative to cwd 10 | const configFilePath = args => { 11 | const currentDir = process.cwd() 12 | 13 | if (args['--file']) { 14 | return path.join(currentDir, args['--file']) 15 | } 16 | 17 | // handle .yaml or .yml default file extensions 18 | const filePath = path.join(currentDir, 'now-compose.yaml') 19 | if (fs.existsSync(filePath)) { 20 | return filePath 21 | } 22 | 23 | return path.join(currentDir, 'now-compose.yml') 24 | } 25 | 26 | // gracefulShutdown will call a commands shutdown method if this process receives 27 | // a sigint or sigterm signal 28 | const gracefulShutdown = interceptor => { 29 | process.on('SIGINT', async () => { 30 | await interceptor.shutdown() 31 | process.exit(0) 32 | }) 33 | 34 | process.on('SIGTERM', async () => { 35 | await interceptor.shutdown() 36 | process.exit(0) 37 | }) 38 | } 39 | 40 | // createComposeConfig creates a docker-compose.yml file and returns that path to it 41 | const createComposeConfig = async config => { 42 | const cwd = process.cwd() 43 | const nowFolder = path.join(cwd, '.now') 44 | const dockerComposePath = path.join(nowFolder, 'docker-compose.yml') 45 | 46 | try { 47 | await mkdir(nowFolder) 48 | await writeFile(dockerComposePath, config.dockerComposeConfig, true) 49 | } catch (err) { 50 | errors.exit(errors.general(err)) 51 | } 52 | 53 | return dockerComposePath 54 | } 55 | 56 | // core application flow 57 | module.exports = async (command, args) => { 58 | try { 59 | const cPath = configFilePath(args) 60 | const cData = await readConfig(cPath) 61 | const config = await parseConfig(cData, cPath) 62 | 63 | // module for command to be run 64 | let mod = {} 65 | 66 | /* 67 | TODO - before using require check command existence 68 | If a require is not resolved in command it can throw MODULE_NOT_FOUND too 69 | */ 70 | try { 71 | mod = require(`./commands/${command}`) 72 | } catch (err) { 73 | if (err.code === 'MODULE_NOT_FOUND') { 74 | mod = require('./commands/generic') // use generic command module 75 | } else { 76 | errors.exit(errors.general(err)) 77 | } 78 | } 79 | 80 | // create docker-compose.yml and add it to command args 81 | const dockerComposePath = await createComposeConfig(config) 82 | const cArgs = { 83 | ...args, 84 | '--file': dockerComposePath 85 | } 86 | 87 | const i = new Interceptor(mod, config, cArgs) 88 | 89 | // attach shutdown listeners 90 | gracefulShutdown(i) 91 | 92 | await i.run() 93 | } catch (err) { 94 | errors.exit(errors.general(err)) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /lib/cli/interceptor.js: -------------------------------------------------------------------------------- 1 | const errors = require('../errors') 2 | 3 | /* 4 | README 5 | 6 | Interceptor hooks into lifecycle events defined for each command. 7 | 8 | TODO - add before / after lifecycle method support 9 | 10 | */ 11 | 12 | class Interceptor { 13 | constructor(mod, config, args) { 14 | this.command = new mod(config, args) 15 | } 16 | 17 | async shutdown() { 18 | this.command.shutdown() 19 | } 20 | 21 | async run() { 22 | try { 23 | await this.command.run() 24 | } catch (err) { 25 | errors.exit(errors.general(err)) 26 | } 27 | } 28 | } 29 | 30 | module.exports = Interceptor 31 | -------------------------------------------------------------------------------- /lib/config/docker-compose.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const excludeServiceKeys = ['deploy', 'command'] 3 | const errors = require('../errors') 4 | 5 | /* 6 | README 7 | 8 | This file creates a docker-compose compatible config object. The following 9 | commands are excluded because now-compose will be managing them: 10 | 11 | - "deploy" now-compose will handle deployment to now. No other deployment stories 12 | will be supported. 13 | 14 | - "command" docker containers should be self containing. To ensure that your 15 | container will function the same locally during development and on now, any 16 | commands executed by the docker container should be contained there. 17 | 18 | ... 19 | 20 | In the future it is planned that now-compose would also handle managing an 21 | instance of microproxy for node and static deployments. Which is why here we 22 | only manage services with build or image properties defined. 23 | */ 24 | 25 | const excludeKeys = (o, exclude) => { 26 | return Object.keys(o).reduce((accumulator, key) => { 27 | if (exclude.indexOf(key) > -1) { 28 | return accumulator 29 | } 30 | 31 | accumulator[key] = o[key] 32 | 33 | return accumulator 34 | }, {}) 35 | } 36 | 37 | // linkServices adds hostname:hostport to an env var for each container a docker 38 | // compose service is linked to. Each env var is labeled by "NOW_HOST_SERVICENAME" 39 | const linkServices = (service, linkedServices, servicePorts) => { 40 | const c = { ...service.config } 41 | 42 | if (linkedServices[service.name]) { 43 | linkedServices[service.name].forEach(link => { 44 | const hostEnvKey = `NOW_HOST_${link.toUpperCase()}` 45 | const hostEnvValue = `host.docker.internal` 46 | 47 | // host env var 48 | if (c.environment) { 49 | c.environment[hostEnvKey] = hostEnvValue 50 | } else { 51 | c.environment = {} 52 | c.environment[hostEnvKey] = hostEnvValue 53 | } 54 | 55 | if (servicePorts[link]) { 56 | const ports = servicePorts[link].split(':') 57 | const hostPort = ports[0] 58 | 59 | // note port is for local dev only since now only supports ssl (443) 60 | const portEnvKey = `NOW_PORT_${link.toUpperCase()}` 61 | const portEnvValue = `${hostPort}` 62 | 63 | // set port env var 64 | c.environment[portEnvKey] = portEnvValue 65 | } 66 | }) 67 | } 68 | 69 | return c 70 | } 71 | 72 | const toDockerCompose = config => { 73 | const configVersion = parseInt(config.version, 10) 74 | if (configVersion < 3) { 75 | errors.exit(errors.general('now-compose.yml must be at least version 3')) 76 | } 77 | 78 | const services = [] 79 | const linkedServices = {} 80 | const servicePorts = {} 81 | 82 | for (const serviceName in config.services) { 83 | if (!config.services.hasOwnProperty(serviceName)) { 84 | continue 85 | } 86 | 87 | // validate service names (since we use them for linking) 88 | const isValidName = RegExp(/^[\w\d_]+$/, 'g') 89 | if (!isValidName.test(serviceName)) { 90 | errors.exit( 91 | errors.general( 92 | `Invalid configuration. Service name '${serviceName}' contains invalid characters. Only letters, digits and '_' are allowed` 93 | ) 94 | ) 95 | } 96 | 97 | let foundContainerTag = false 98 | const definition = config.services[serviceName] 99 | 100 | // warn that we can't deploy service if there is no dockerfile to build 101 | if (Object.keys(definition).indexOf('build') === -1) { 102 | errors.warn(errors.composeNoBuildTag(serviceName)) 103 | } 104 | 105 | for (const configOption in definition) { 106 | if (!definition.hasOwnProperty(configOption)) { 107 | continue 108 | } 109 | 110 | // a build or image property is required for the service 111 | // do config validation 112 | if (configOption === 'build' || configOption === 'image') { 113 | foundContainerTag = true 114 | let serviceConfig = excludeKeys(definition, excludeServiceKeys) 115 | 116 | /* 117 | TODO - temporary.. do this better 118 | 119 | Since we generate docker-compose.yml to a `.now` dir in a projects root 120 | we need to resolve any `build` and `env` file properties in docker-compose.yml 121 | to be one directory up (located where now-compose.yml is) 122 | */ 123 | if (serviceConfig.build) { 124 | serviceConfig.build = path.join('../', serviceConfig.build) 125 | } 126 | 127 | // env_file validation and path fix 128 | if (serviceConfig['env_file']) { 129 | if (Array.isArray(serviceConfig['env_file'])) { 130 | // loop through env_files and adjust path 131 | serviceConfig['env_file'] = serviceConfig['env_file'].map(file => { 132 | return path.join('../', file) 133 | }) 134 | } else { 135 | if (typeof serviceConfig['env_file'] === 'string') { 136 | serviceConfig['env_file'] = path.join( 137 | '../', 138 | serviceConfig['env_file'] 139 | ) 140 | } else { 141 | errors.exit( 142 | errors.composeTypeNotSupported('env_file', serviceName, [ 143 | 'array', 144 | 'string' 145 | ]) 146 | ) 147 | } 148 | } 149 | } 150 | 151 | // TODO - support port long syntax 152 | // port validation 153 | if (Array.isArray(serviceConfig.ports)) { 154 | if (serviceConfig.ports.length > 1) { 155 | errors.warn(errors.composeSupportOnePort(serviceName)) 156 | } 157 | 158 | if (!serviceConfig.ports.length) { 159 | errors.exit(errors.composeNoPortExposed(serviceName)) 160 | } else { 161 | const ports = serviceConfig.ports.slice(0, 1) 162 | if (ports[0].indexOf(':') === -1) { 163 | errors.exit(errors.composeNoHostPort(serviceName)) 164 | } 165 | 166 | servicePorts[serviceName] = ports[0] 167 | } 168 | } else { 169 | if (serviceConfig.ports) { 170 | errors.exit(errors.composePortLongSyntaxNotSupported(serviceName)) 171 | } 172 | } 173 | 174 | // store links so we can link all services after parsing 175 | linkedServices[serviceName] = serviceConfig.links || [] 176 | 177 | // service config is good 178 | services.push({ name: serviceName, config: serviceConfig }) 179 | break 180 | } 181 | } 182 | 183 | if (!foundContainerTag) { 184 | errors.warn(errors.composeDockerContainerNotDefined(serviceName)) 185 | } 186 | } 187 | 188 | // delete from linkedServices those services which we skipped (no build or image props) 189 | // they aren't in docker-compose.yml so they shouldn't be added to env vars 190 | const validServiceNames = services.map(s => s.name) 191 | for (let i = 0; i < Object.keys(linkedServices).length; i++) { 192 | const service = Object.keys(linkedServices)[i] 193 | 194 | const validLinked = Object.values(linkedServices[service]).filter(l => { 195 | return validServiceNames.indexOf(l) > -1 196 | }) 197 | 198 | linkedServices[service] = validLinked 199 | } 200 | 201 | // convert services arr to obj and link them (networking) 202 | const dockerServices = services.reduce((o, service) => { 203 | o[service.name] = linkServices(service, linkedServices, servicePorts) 204 | return o 205 | }, {}) 206 | 207 | return { 208 | ...config, 209 | ...{ services: dockerServices } 210 | } 211 | } 212 | 213 | module.exports = { 214 | toDockerCompose: toDockerCompose, 215 | excludeKeys: excludeKeys, 216 | linkServices: linkServices 217 | } 218 | -------------------------------------------------------------------------------- /lib/config/docker-compose.test.js: -------------------------------------------------------------------------------- 1 | const { 2 | toDockerCompose, 3 | excludeKeys, 4 | linkServices 5 | } = require('./docker-compose') 6 | 7 | jest.unmock('../errors') 8 | const errors = require('../errors') 9 | errors.exit = () => { 10 | throw new Error() 11 | } 12 | 13 | const exampleParsedConfig = { 14 | version: '3', 15 | services: { 16 | api: { 17 | build: './api', 18 | ports: ['8081:8080'], 19 | restart: 'on-failure', 20 | links: ['auth', 'db'], 21 | depends_on: ['auth', 'db'], 22 | environment: { 23 | PORT: ':8080', 24 | MYSQL_DB: 'test', 25 | MYSQL_USER: 'test', 26 | MYSQL_PASSWORD: 'test', 27 | MYSQL_HOST: 'test' 28 | }, 29 | deploy: 'Should not be in config' 30 | }, 31 | web: { 32 | ports: ['8080:3000'], 33 | restart: 'on-failure', 34 | links: ['auth'], 35 | depends_on: ['auth'], 36 | volumes: ['./web:/usr/src/app', '/usr/src/app/node_modules'], 37 | command: 'npm run dev' 38 | }, 39 | auth: { 40 | ports: ['8080:8080'], 41 | restart: 'on-failure', 42 | volumes: ['./auth:/usr/src/app', '/usr/src/app/node_modules'], 43 | environment: { 44 | PORT: '8080', 45 | MYSQL_DB: 'test', 46 | MYSQL_USER: 'test', 47 | MYSQL_PASSWORD: 'test', 48 | MYSQL_HOST: 'test', 49 | NODE_ENV: 'production' 50 | }, 51 | links: ['db'], 52 | depends_on: ['db'] 53 | }, 54 | db: { 55 | image: 'mysql:5.7', 56 | ports: ['3306:3306', '5555:5555'], 57 | restart: 'on-failure', 58 | volumes: [ 59 | './setup/db:/docker-entrypoint-initdb.d', 60 | 'mysql-data:/var/lib/mysql' 61 | ], 62 | environment: { 63 | MYSQL_DATABASE: 'test', 64 | MYSQL_USER: 'test', 65 | MYSQL_PASSWORD: 'test', 66 | MYSQL_ROOT_PASSWORD: 'test', 67 | MYSQL_ALLOW_EMPTY_PASSWORD: 'yes', 68 | MYSQL_ROOT_HOST: '%' 69 | }, 70 | env_file: 'env.txt' 71 | } 72 | }, 73 | volumes: { 74 | 'mysql-data': null 75 | } 76 | } 77 | 78 | describe('toDockerCompose', () => { 79 | it('excludeKeys should exclude defined keys from objects', () => { 80 | const o = excludeKeys({ hello: 'world', excludeMe: '' }, ['excludeMe']) 81 | expect(o.excludeMe).not.toBeDefined() 82 | }) 83 | 84 | it('linkServices should add linked services NOW_HOST_* and NOW_PORT_* to env vars', () => { 85 | const links = { api: ['db'], db: [] } 86 | const ports = { api: '8081:8080', db: '3306:3306' } 87 | const service = { name: 'api', config: { environment: {} } } 88 | 89 | const linked = linkServices(service, links, ports) 90 | 91 | expect(linked).toEqual({ 92 | environment: { NOW_HOST_DB: 'host.docker.internal', NOW_PORT_DB: '3306' } 93 | }) 94 | }) 95 | 96 | it('should exclude services that do not contain build or image config options', () => { 97 | const dockerConfig = toDockerCompose(exampleParsedConfig) 98 | 99 | expect(dockerConfig.services.web).not.toBeDefined() 100 | expect(dockerConfig.services.db).toBeDefined() 101 | expect(dockerConfig.services.auth).not.toBeDefined() 102 | expect(dockerConfig.services.api).toBeDefined() 103 | }) 104 | 105 | it('should exclude "deploy" config options for a service', () => { 106 | const dockerConfig = toDockerCompose(exampleParsedConfig) 107 | 108 | expect(dockerConfig.services.api.deploy).not.toBeDefined() 109 | }) 110 | 111 | it('should set host env vars on linked services correctly', () => { 112 | const dockerConfig = toDockerCompose(exampleParsedConfig) 113 | const apiEnvVars = dockerConfig.services.api.environment 114 | 115 | expect(apiEnvVars['NOW_HOST_AUTH']).not.toBeDefined() 116 | expect(apiEnvVars['NOW_HOST_DB']).toEqual('host.docker.internal') 117 | expect(apiEnvVars['NOW_PORT_DB']).toEqual('3306') 118 | }) 119 | 120 | it('should only set the first port defined when linking services', () => { 121 | const dockerConfig = toDockerCompose(exampleParsedConfig) 122 | const apiEnvVars = dockerConfig.services.api.environment 123 | 124 | expect(apiEnvVars['NOW_PORT_DB']).toEqual('3306') 125 | }) 126 | 127 | it('should fix path of env files to be at project root', () => { 128 | const dockerConfig = toDockerCompose(exampleParsedConfig) 129 | 130 | expect(dockerConfig.services.db['env_file']).toEqual('../env.txt') 131 | }) 132 | 133 | it('should fix path of build tag to be at project root', () => { 134 | const dockerConfig = toDockerCompose(exampleParsedConfig) 135 | 136 | expect(dockerConfig.services.api.build).toEqual('../api') 137 | }) 138 | 139 | it('should require at least docker-compose config version 3', () => { 140 | let oldVersionConfig = exampleParsedConfig 141 | oldVersionConfig.version = '2' 142 | 143 | expect(() => { 144 | toDockerCompose(oldVersionConfig) 145 | }).toThrow() 146 | }) 147 | }) 148 | -------------------------------------------------------------------------------- /lib/config/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const errors = require('../errors') 3 | const { safeLoad, safeDump } = require('js-yaml') 4 | 5 | // parsing methods 6 | const { toDockerCompose } = require('./docker-compose') 7 | 8 | // parse now-compose.yaml file at given path. each service with a "build" or "image" 9 | // property is considered a docker-compose service. 10 | exports.parseConfig = async (config, filePath) => { 11 | try { 12 | const configYAML = await safeLoad(config, { filename: filePath }) 13 | 14 | if (!configYAML.services) { 15 | errors.exit(errors.invalidComposeYML()) 16 | } 17 | 18 | return { 19 | dockerComposeConfig: toDockerCompose(configYAML) 20 | } 21 | } catch (err) { 22 | errors.exit(errors.general(err)) 23 | } 24 | } 25 | 26 | // read handles reading a file and returning it's data 27 | exports.readConfig = path => 28 | new Promise((res, rej) => { 29 | fs.readFile(path, 'utf8', (err, data) => { 30 | if (err) { 31 | if (err.code === 'ENOENT') { 32 | errors.exit(errors.configNotFound(path)) 33 | } 34 | 35 | return rej(err) 36 | } 37 | 38 | return res(data) 39 | }) 40 | }) 41 | 42 | exports.mkdir = path => 43 | new Promise((res, rej) => { 44 | fs.mkdir(path, err => { 45 | if (err) { 46 | switch (err.code) { 47 | case 'EACCES': 48 | errors.exit(errors.configDirNoPerm(path)) 49 | case 'EEXIST': 50 | // path already exists so don't throw 51 | return res(path) 52 | default: 53 | return rej(err) 54 | } 55 | } 56 | 57 | return res(path) 58 | }) 59 | }) 60 | 61 | exports.writeFile = (path, data, yaml = false) => 62 | new Promise((res, rej) => { 63 | if (yaml) { 64 | data = safeDump(data) 65 | } 66 | 67 | fs.writeFile(path, data, err => { 68 | if (err) { 69 | return rej(errors.cannotWriteFile(path)) 70 | } 71 | 72 | return res(path) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /lib/directory-tree/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra') 2 | const nPath = require('path') 3 | 4 | const itemType = stat => { 5 | if (stat.isFile()) { 6 | return 'file' 7 | } 8 | 9 | if (stat.isDirectory()) { 10 | return 'dir' 11 | } 12 | 13 | return '' 14 | } 15 | 16 | const directoryTree = async (path = __dirname, onEachFile) => { 17 | const name = nPath.basename(path) 18 | let item = { path, name } 19 | 20 | try { 21 | const stat = await fs.stat(path) 22 | 23 | switch (itemType(stat)) { 24 | case 'file': 25 | item.extension = nPath.extname(item.path).toLowerCase() 26 | item.size = stat.size 27 | item.isFile = true 28 | 29 | if (onEachFile && typeof onEachFile === 'function') { 30 | item = await onEachFile(item) 31 | } 32 | break 33 | case 'dir': 34 | try { 35 | item.children = await fs.readdir(path) 36 | item.isFile = false 37 | 38 | const recurse = child => { 39 | return new Promise(async (res, rej) => { 40 | try { 41 | const i = await directoryTree( 42 | nPath.join(path, child), 43 | onEachFile 44 | ) 45 | res(i) 46 | } catch (e) { 47 | res({}) 48 | } 49 | }) 50 | } 51 | 52 | const setChildren = item.children.map(child => { 53 | return recurse(child).then(c => c) 54 | }) 55 | 56 | item.children = await Promise.all(setChildren) 57 | item.children = item.children.filter(i => Object.keys(i).length) // don't include empty items 58 | item.size = item.children.reduce((prev, cur) => prev + cur.size, 0) 59 | } catch (e) { 60 | return {} 61 | } 62 | 63 | break 64 | default: 65 | return {} 66 | } 67 | } catch (e) { 68 | return {} 69 | } 70 | 71 | return item 72 | } 73 | 74 | module.exports = directoryTree 75 | -------------------------------------------------------------------------------- /lib/errors/index.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk') 2 | 3 | const warning = message => chalk`{yellow WARNING:} ${message}` 4 | const info = message => chalk`{magenta INFO:} ${message}` 5 | const error = message => chalk`{red ERROR:} ${message}` 6 | 7 | exports.info = info 8 | exports.warning = warning 9 | 10 | exports.dockerComposeNotInstalled = () => { 11 | return warning(`docker-compose must be installed to use this program. 12 | Visit https://docs.docker.com/compose/install/ for installation instructions. 13 | `) 14 | } 15 | 16 | exports.configNotFound = path => { 17 | return error(`Config file not found. 18 | Path: ${path} 19 | Solution: Specify a file to use with "now-compose -f " 20 | `) 21 | } 22 | 23 | exports.invalidComposeYML = () => { 24 | return error(`No services defined in now-compose.yml.`) 25 | } 26 | 27 | exports.general = err => { 28 | return error(`${err}`) 29 | } 30 | 31 | exports.configDirNoPerm = path => { 32 | return error(`Cannot create directory ${path}. 33 | Please ensure that you have proper write permissions for the parent directory.`) 34 | } 35 | 36 | exports.cannotWriteFile = path => { 37 | return error(`Cannot write file ${path}`) 38 | } 39 | 40 | exports.composeSupportOnePort = serviceName => { 41 | return warning(`Only one exposed port is currently supported. Found 42 | multiple for service ${serviceName}. 43 | 44 | Skipping all but the first defined port for this service. 45 | `) 46 | } 47 | 48 | exports.composeNoPortExposed = serviceName => { 49 | return error(`No ports exposed for service ${serviceName}.`) 50 | } 51 | 52 | exports.composeNoHostPort = serviceName => { 53 | return error(`Port for service ${serviceName} is not exposed to the host 54 | machine. Networking is not possible. 55 | 56 | Solution: Ports should be bound with the host port and the port exposed 57 | in the container. i.e. "8080:3000". 58 | `) 59 | } 60 | 61 | exports.composePortLongSyntaxNotSupported = serviceName => { 62 | return error(`Only docker-compose ports short syntax is supported. 63 | Unsupported syntax found for service ${serviceName}. 64 | `) 65 | } 66 | 67 | exports.composeDockerContainerNotDefined = serviceName => { 68 | return warning(`Skipping ${serviceName}, build or image property not found for service. 69 | A docker container must be defined. 70 | `) 71 | } 72 | 73 | exports.composeNoBuildTag = serviceName => { 74 | return warning(`No build tag defined for service ${serviceName}. A Dockerfile is 75 | required to deploy this service. If this service is for local use only then this 76 | message can be diregarded. 77 | `) 78 | } 79 | 80 | exports.composeTypeNotSupported = (prop, serviceName, supportedTypes) => { 81 | return error(`Type of property '${prop}' for service '${serviceName}' not supported. 82 | Supported types are ${supportedTypes.join(',')}. 83 | `) 84 | } 85 | 86 | exports.deployAPIKeyNotSet = () => { 87 | return error(`API key not set. 88 | 89 | Please provide the zeit API token for the account you want to use for 90 | deployments. It can be set using the '--apiKey' flag or by setting a 91 | 'NOW_API_KEY' environment variable before running this command. 92 | 93 | You can create an API token for now-compose at: https://zeit.co/account/tokens 94 | `) 95 | } 96 | 97 | exports.apiForbidden = () => { 98 | return error(`Upload request returned 403 Forbidden. 99 | Is your api key correct? 100 | `) 101 | } 102 | 103 | exports.apiUploadingFile = () => { 104 | return error(`Uploading file ${file.path}. 105 | Message: Status ${r.statusCode} - ${r.statusMessage} 106 | `) 107 | } 108 | 109 | exports.apiOperationTimeout = () => { 110 | return error(`Operation timeout 111 | 112 | You tried to upload a file that was too big. You might be able to 113 | create it during build on zeit now. 114 | `) 115 | } 116 | 117 | exports.deployDependsOnType = s => { 118 | return error( 119 | `depends_on property for service ${s} should be an Array or String.` 120 | ) 121 | } 122 | 123 | exports.deployNoBuildProperty = s => { 124 | return warning(`Service ${s} will not be deployed because it contains no build 125 | property in its service config. 126 | `) 127 | } 128 | 129 | exports.deployLinkNotFound = (s, l) => { 130 | return warning(`Found link "${l}" for service "${s}". "${l}" is not a service 131 | configured for deployment. 132 | `) 133 | } 134 | 135 | exports.uploadErrorUploadingFile = (file, serviceName, err) => { 136 | return error(`There was an issue parsing file ${file.path} while 137 | uploading service ${serviceName} 138 | 139 | ${err} 140 | `) 141 | } 142 | 143 | exports.uploadErrorUploadingService = (serviceName, err) => { 144 | return error(`There was an issue encountered uploading service ${serviceName} 145 | 146 | ${err} 147 | `) 148 | } 149 | 150 | exports.exit = message => { 151 | console.log(message) 152 | process.exit(1) 153 | } 154 | 155 | exports.warn = message => { 156 | console.log(message) 157 | } 158 | -------------------------------------------------------------------------------- /lib/now/api/index.js: -------------------------------------------------------------------------------- 1 | const request = require('got') 2 | const fs = require('fs') 3 | const errors = require('../../errors') 4 | 5 | const nowAPIURLV2 = 'https://api.zeit.co/v2/now/' 6 | const nowAPIURLV3 = 'https://api.zeit.co/v3/now/' 7 | const timeout = 300 * 1000 // 5 minute 8 | 9 | // write file upload progress on same line in console 10 | const printUploadProgress = (message, progress) => { 11 | const percent = (progress * 100).toFixed(2) 12 | process.stdout.clearLine() 13 | process.stdout.cursorTo(0) 14 | 15 | if (percent >= 99.9) { 16 | process.stdout.write( 17 | `${message.replace('Uploading', 'Uploaded')} - ${percent}%` 18 | ) 19 | } else { 20 | process.stdout.write(`${message} - ${percent}%`) 21 | } 22 | } 23 | 24 | // uploadFile reads file content and pushes it to zeit/now 25 | // this method returns a fn you must run to start the request 26 | // so that we can queue all requests and ratelimit 27 | const defaultFile = { sha: '', size: 0, path: '' } 28 | exports.prepareUploadFile = (file = defaultFile) => () => 29 | new Promise((res, rej) => { 30 | const nowAPIKey = global.NOW_API_KEY 31 | const url = `${nowAPIURLV2}files` 32 | 33 | const options = { 34 | method: 'POST', 35 | throwHttpErrors: false, 36 | timeout, 37 | headers: { 38 | Authorization: `Bearer ${nowAPIKey}`, 39 | 'Content-Type': 'application/octet-stream', 40 | 'Content-Length': file.size, 41 | 'x-now-digest': file.sha, 42 | 'x-now-size': file.size 43 | } 44 | } 45 | 46 | try { 47 | const stream = fs 48 | .createReadStream(file.path) 49 | .pipe(request.stream(url, options)) 50 | 51 | stream.on('response', r => { 52 | // create new line after uploaded files 53 | process.stdout.write('\n') 54 | 55 | if (r.statusCode === 403) { 56 | return rej(errors.apiForbidden()) 57 | } 58 | 59 | if (r.statusCode !== 200) { 60 | return rej(errors.apiUploadingFile()) 61 | } 62 | 63 | return res(r) 64 | }) 65 | 66 | stream.on('uploadProgress', progress => { 67 | const relativeFilePath = file.path.replace(process.cwd(), '.') 68 | printUploadProgress( 69 | errors.info(`Uploading ${relativeFilePath}`), 70 | progress.percent 71 | ) 72 | }) 73 | 74 | stream.on('error', err => { 75 | if (err.message === timedOutError) { 76 | return rej(errors.apiOperationTimeout()) 77 | } 78 | }) 79 | } catch (err) { 80 | return rej(errors.general(err)) 81 | } 82 | }) 83 | 84 | // DeploymentError represents an error thrown during deployment 85 | function DeploymentError(name, body, statusCode) { 86 | this.message = errors.general(`An error occured deploying service ${name}`) 87 | this.body = body 88 | this.name = 'DeploymentError' 89 | this.statusCode = statusCode 90 | } 91 | 92 | // createDeployment creates a new zeit/now service deployment 93 | exports.createDeployment = async body => { 94 | const nowAPIKey = global.NOW_API_KEY 95 | const url = `${nowAPIURLV2}deployments` 96 | 97 | const options = { 98 | throwHttpErrors: false, 99 | timeout, 100 | headers: { 101 | Authorization: `Bearer ${nowAPIKey}`, 102 | 'Content-Type': 'application/json' 103 | }, 104 | body: JSON.stringify(body) 105 | } 106 | 107 | const resp = await request.post(url, options) 108 | if (resp.statusCode == 200) { 109 | return JSON.parse(resp.body) 110 | } 111 | 112 | throw new DeploymentError(body.name, resp.body, resp.statusCode) 113 | } 114 | -------------------------------------------------------------------------------- /lib/now/deployment/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const toposort = require('toposort') 3 | const upload = require('./upload') 4 | const api = require('../api') 5 | const errors = require('../../errors') 6 | 7 | // getDeployOrder topologically sorts services based on dependant services 8 | const getDeployOrder = services => { 9 | let o = [] 10 | 11 | Object.keys(services).forEach(s => { 12 | const service = services[s] 13 | if (!service.build) { 14 | return null 15 | } 16 | 17 | if (service['depends_on']) { 18 | if (Array.isArray(service['depends_on'])) { 19 | if (service['depends_on'].length) { 20 | service['depends_on'].forEach(d => { 21 | o.push([s, d]) 22 | }) 23 | } 24 | } else { 25 | if (typeof service['depends_on'] === 'string') { 26 | o.push([s, service['depends_on']]) 27 | } else { 28 | errors.exit(errors.deployDependsOnType(s)) 29 | } 30 | } 31 | } 32 | }) 33 | 34 | // get rid of any possible falsey values 35 | o = o.filter(f => f) 36 | 37 | try { 38 | const sorted = toposort(o).reverse() 39 | return sorted 40 | } catch (err) { 41 | errors.exit(errors.general(err.message)) 42 | } 43 | } 44 | 45 | // warnOnNonExistingLinks displays a warning if a service has links that aren't being deployed 46 | const warnOnNonExistingLinks = (services, deploying) => { 47 | Object.keys(services).forEach(s => { 48 | const service = services[s] 49 | 50 | // disregard services without build tag (we can't deploy those because there is no dockerfile) 51 | if (!service.build) { 52 | errors.warn(errors.deployNoBuildProperty(s)) 53 | return 54 | } 55 | 56 | if (service['links'] && Array.isArray(service['links'])) { 57 | service['links'].forEach(l => { 58 | if (deploying.indexOf(l) == -1) { 59 | errors.warn(errors.deployLinkNotFound(s, l)) 60 | } 61 | }) 62 | } 63 | }) 64 | } 65 | 66 | // flattenTree takes a directory tree and returns it as flat array filtering 67 | // out unprocessed files and dirs 68 | const flattenTree = (node, flat = []) => { 69 | if (node.children) { 70 | if (!node.children.length) { 71 | if (node.processed) { 72 | flat.push(node) 73 | } 74 | } 75 | 76 | for (const child of node.children) { 77 | flattenTree(child, flat) 78 | } 79 | } else { 80 | if (node.processed) { 81 | flat.push(node) 82 | } 83 | } 84 | 85 | return flat 86 | } 87 | 88 | // prepareDeployment builds the request body for deploying to zeit now 89 | const prepareDeployment = (files, serviceCfg, name, deployed) => 90 | new Promise((res, rej) => { 91 | if (!files.length) { 92 | return rej() 93 | } 94 | 95 | // set service link environment variables. NOW_PORT_* is always 443 because zeit 96 | // deployments always work over ssl 97 | let serviceLinks = {} 98 | if (serviceCfg.links) { 99 | serviceLinks = serviceCfg.links.reduce((env, v) => { 100 | if (deployed[v]) { 101 | env[`NOW_HOST_${v.toUpperCase()}`] = deployed[v].url 102 | env[`NOW_PORT_${v.toUpperCase()}`] = 443 103 | } 104 | 105 | return env 106 | }, {}) 107 | } 108 | 109 | // set NOW_DEPLOYED so apps can handle specifics of deployed environment 110 | serviceCfg.environment['NOW_DEPLOYED'] = 'true' 111 | 112 | const body = { 113 | env: { ...serviceCfg.environment, ...serviceLinks }, 114 | public: global.NOW_DEPLOY_PUBLIC, 115 | forceNew: true, 116 | name: name, 117 | deploymentType: 'DOCKER', 118 | files: files.map(f => { 119 | const relativePath = f.path.replace( 120 | `${process.cwd()}${path.sep}${name}${path.sep}`, 121 | '' 122 | ) 123 | 124 | return { 125 | file: relativePath, 126 | sha: f.sha, 127 | size: f.size 128 | } 129 | }) 130 | } 131 | 132 | res(body) 133 | }) 134 | 135 | // deploy determines order to deploy services, sets links in environment variables 136 | // and pushes to now 137 | const deploy = async config => { 138 | const deployOrder = getDeployOrder(config.services) 139 | 140 | warnOnNonExistingLinks(config.services, deployOrder) 141 | 142 | const services = Object.keys(config.services) 143 | .map(s => { 144 | if (!config.services[s].build) { 145 | return null 146 | } 147 | 148 | // TODO - do this better. We are not in the context of the `.now` dir so we 149 | // need to adjust the build dir to be relative to the current directory 150 | const serviceDir = config.services[s].build.replace('../', '') 151 | const dir = path.join(process.cwd(), serviceDir) 152 | return { dir, name: s } 153 | }) 154 | .filter(f => f) // disregard nulls (services without build tag) 155 | .sort((a, b) => { 156 | const aIdx = deployOrder.indexOf(a.name) 157 | const bIdx = deployOrder.indexOf(b.name) 158 | 159 | if (aIdx < bIdx) { 160 | return -1 161 | } 162 | 163 | if (aIdx > bIdx) { 164 | return 1 165 | } 166 | 167 | return 0 168 | }) 169 | 170 | const serviceFiles = [] 171 | for (const s of services) { 172 | const processed = await upload(s.dir, s.name) 173 | const flat = flattenTree(processed.files) 174 | serviceFiles.push(flat) 175 | } 176 | 177 | let i = 0 178 | let deployed = {} 179 | for (const f of serviceFiles) { 180 | const serviceName = deployOrder[i] 181 | const serviceCfg = config.services[serviceName] 182 | const deployReq = await prepareDeployment( 183 | f, 184 | serviceCfg, 185 | serviceName, 186 | deployed 187 | ) 188 | 189 | const resp = await api.createDeployment(deployReq) 190 | 191 | deployed[serviceName] = resp 192 | i++ 193 | } 194 | 195 | Object.keys(deployed).forEach(sName => { 196 | const publicURL = deployed[sName].url 197 | console.log(errors.info(`[${sName}] Available at url: ${publicURL}`)) 198 | }) 199 | 200 | process.exit(0) 201 | } 202 | 203 | module.exports = deploy 204 | -------------------------------------------------------------------------------- /lib/now/deployment/upload.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const crypto = require('crypto') 3 | const dTree = require('../../directory-tree') 4 | const PromiseThrottle = require('promise-throttle') 5 | const nowAPI = require('../api') 6 | const errors = require('../../errors') 7 | 8 | // UploadQueue ratelimits upload file requests to zeit now api 9 | class UploadQueue { 10 | constructor() { 11 | this.queue = [] 12 | } 13 | 14 | push(p) { 15 | this.queue.push(p) 16 | } 17 | 18 | sleep(ms) { 19 | return new Promise(resolve => { 20 | setTimeout(resolve, ms) 21 | }) 22 | } 23 | 24 | secondsTillStart(startAtSeconds) { 25 | const now = new Date().getTime() 26 | const end = new Date(startAtSeconds * 1000).getTime() // convert to ms 27 | 28 | return Math.abs(end - now) 29 | } 30 | 31 | // run upload file requests. run first to get rate limit and then return promise 32 | // of all requests ratelimited. If we don't get rate limits use sane defaults 33 | // (3 req/ second) 34 | async process() { 35 | try { 36 | const first = this.queue.shift() 37 | const firstResp = await first() 38 | const rateLimit = firstResp.headers['X-Rate-Limit-Limit'] || 60 * 3 39 | const rateLimitResetsInSecs = 40 | parseInt(firstResp.headers['X-Rate-Limit-Reset'], 10) || 41 | new Date().getTime() / 1000 42 | 43 | // wait till we have a constant rate limit / minute 44 | await this.sleep(this.secondsTillStart(rateLimitResetsInSecs)) 45 | 46 | // make at most rateLimit requests per minute 47 | const promiseThrottle = new PromiseThrottle({ 48 | requestsPerSecond: rateLimit / 60, 49 | promiseImplementation: Promise 50 | }) 51 | 52 | const requests = this.queue.map(r => { 53 | return promiseThrottle.add(r) 54 | }) 55 | 56 | return Promise.all(requests) 57 | } catch (err) { 58 | process.exit(0) 59 | } 60 | } 61 | } 62 | 63 | // getFileSHA gets the sha1 hash of a file 64 | const getFileSHA = file => 65 | new Promise((res, rej) => { 66 | const shasum = crypto.createHash('sha1') 67 | const stream = fs.createReadStream(file.path) 68 | 69 | shasum.setEncoding('hex') 70 | stream.pipe(shasum) 71 | 72 | stream.on('error', err => { 73 | return rej(err) 74 | }) 75 | 76 | shasum.on('finish', () => { 77 | return res(shasum.read()) 78 | }) 79 | }) 80 | 81 | // upload iterates through a projects directory and uploads files to now 82 | const upload = async (dir, serviceName) => { 83 | const queue = new UploadQueue() 84 | 85 | try { 86 | const dirFiles = await dTree(dir, async n => { 87 | try { 88 | // TODO :- use https://github.com/kaelzhang/node-ignore to ignore anything in .gitignore 89 | const isNodeModules = n.path.indexOf('node_modules') > -1 90 | if (isNodeModules) { 91 | n.processed = false 92 | return n 93 | } 94 | 95 | if (n.isFile) { 96 | const file = n 97 | file.sha = await getFileSHA(file) 98 | 99 | // if we can't upload all of a services files we should exit 100 | try { 101 | queue.push(nowAPI.prepareUploadFile(file)) 102 | } catch (err) { 103 | errors.exit(errors.general(err)) 104 | } 105 | 106 | file.processed = true 107 | return file 108 | } 109 | 110 | return n 111 | } catch (err) { 112 | errors.exit(errors.uploadErrorUploadingFile(file, serviceName, err)) 113 | } 114 | }) 115 | 116 | // run upload requests 117 | return queue.process().then(() => { 118 | return { files: dirFiles } 119 | }) 120 | } catch (err) { 121 | errors.exit(errors.uploadErrorUploadingService(serviceName, err)) 122 | } 123 | } 124 | 125 | module.exports = upload 126 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Danny Navarro. (dannav.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | .PHONY: releases 3 | 4 | all: releases 5 | 6 | install_pkg: 7 | npm i pkg -g 8 | 9 | releases: install_pkg 10 | rm -rf ./releases 11 | mkdir -p ./releases 12 | pkg . -t latest-linux,latest-win,latest-macos --out-path ./releases/ 13 | mkdir -p ./releases/linux 14 | mkdir -p ./releases/mac 15 | mkdir -p ./releases/win 16 | mv ./releases/now-compose-linux ./releases/linux/now-compose 17 | zip ./releases/linux/now-compose-linux.zip ./releases/linux/now-compose 18 | mv ./releases/now-compose-macos ./releases/mac/now-compose 19 | zip ./releases/mac/now-compose-mac.zip ./releases/mac/now-compose 20 | mv ./releases/now-compose-win.exe ./releases/win/now-compose.exe 21 | zip ./releases/win/now-compose-win.zip ./releases/win/now-compose.exe 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "now-compose", 3 | "version": "1.0.1", 4 | "description": "A command line interface for developing and deploying applications with docker-compose for zeit now.", 5 | "main": "index.js", 6 | "files": [ 7 | "bin", 8 | "lib" 9 | ], 10 | "scripts": { 11 | "test": "jest --silent", 12 | "testandlog": "jest" 13 | }, 14 | "keywords": [ 15 | "now", 16 | "microservice", 17 | "docker-compose", 18 | "now-compose", 19 | "zeit" 20 | ], 21 | "bin": { 22 | "now-compose": "./bin/now-compose" 23 | }, 24 | "author": "", 25 | "license": "MIT", 26 | "dependencies": { 27 | "arg": "^2.0.0", 28 | "chalk": "^2.4.1", 29 | "command-exists": "^1.2.6", 30 | "fs-extra": "^6.0.1", 31 | "got": "^8.3.1", 32 | "ignore": "^3.3.8", 33 | "js-yaml": "^3.12.0", 34 | "promise-throttle": "^1.0.0", 35 | "toposort": "^2.0.2", 36 | "update-check": "^1.5.2" 37 | }, 38 | "devDependencies": { 39 | "jest": "^23.1.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

now has fundamentally changed as of 2.0 so development of this project has stopped. Please read the now release here for more info. Thanks for everyone who has tried this out!

2 |
3 |

4 | now-compose 5 |

6 | 7 | [![CircleCI](https://circleci.com/gh/dannav/now-compose.svg?style=svg&circle-token=a204b7c6925f4014b03ffed857005beb2b98b97e)](https://circleci.com/gh/dannav/now-compose) 8 | 9 | `now-compose` is a command line interface for developing and deploying applications with docker-compose for [zeit now](https://zeit.co/now). 10 | 11 | ## Setup 12 | 13 | `now-compose` behaves as a wrapper around docker-compose. To get started, you will need docker and docker-compose setup on your machine. To install these dependencies visit [the docker-compose install guide](https://docs.docker.com/compose/install/). 14 | 15 | Install `now-compose`: 16 | 17 | ``` 18 | npm i -g now-compose 19 | ``` 20 | 21 | now-compose is also available as an executable. Check the [releases](https://github.com/dannav/now-compose/releases) to find one for your system. You will then want to move that executable to a folder in your `PATH` environment variable to use from the command line. 22 | 23 | ## Usage 24 | 25 | If you're already working on a project using docker-compose you can tell `now-compose` to use your `docker-compose.yml` file with the `-f` flag. Otherwise, rename `docker-compose.yml` to `now-compose.yml`. `now-compose` only supports the docker-compose version 3 config syntax. You can learn more about the syntax at the [config reference here](https://docs.docker.com/compose/compose-file/) if you need to upgrade. 26 | 27 | > By default, `now-compose` will look for a `now-compose.yml` file in the current working directory. 28 | 29 | You can then use `now-compose` as you would use docker-compose. For instance, in a directory with a `now-compose.yml` file run the following to start all containers defined in your config: 30 | 31 | ``` 32 | now-compose up -d 33 | ``` 34 | 35 | or if you want to use a `docker-compose.yml` file 36 | 37 | ``` 38 | now-compose -f docker-compose.yml up -d 39 | ``` 40 | 41 | You can view other commands that `now-compose` supports by running: 42 | 43 | ``` 44 | now-compose --help 45 | ``` 46 | 47 | ## Example project 48 | 49 | To view an example project built with `now-compose` take a look at the [cluster example](./examples/cluster). 50 | 51 | ## Differences when developing with now-compose compared to docker-compose 52 | 53 | There are a couple of small differences to keep in mind when using `now-compose` vs docker-compose. 54 | 55 | The first is networking between containers. Usually for services defined in a `docker-compose.yml` file you would make requests to another service by requesting a url that has the service's name in the url. 56 | 57 | ```yaml 58 | version: "3" 59 | services: 60 | web: 61 | build: ./web 62 | links: 63 | - api 64 | ports: 65 | - 3000:3000 66 | api: 67 | build: ./api 68 | ports: 69 | - 3001:3001 70 | ``` 71 | 72 | i.e. in the above example `web` can make a request to `api` by requesting `http://api`. `now-compose` will handle this for you, however you will want to reference the urls defined in environment variables that `now-compose` will provide to your application. 73 | 74 | | Environment Var | Description | Example | 75 | | ----------------- | --------------------------------------------------------------- | -------------- | 76 | | `NOW_HOST_` | The url of the service | `NOW_HOST_API` | 77 | | `NOW_PORT_` | The first port defined in your services `ports` config property | `NOW_PORT_API` | 78 | 79 | --- 80 | 81 | The second difference from docker-compose, is that all services defined in `now-compose.yml` must have a `build` property defined that points to the location of that service's `Dockerfile`. Since a `Dockerfile` must be defined for deployments to [zeit now](https://zeit.co/now). Any services that do not contain a `build` property (i.e. reference a docker image) will run locally, but they will be skipped during deployment. 82 | 83 | > This allows you to setup a database locally for development purposes. But skip the deployment to zeit now. 84 | 85 | --- 86 | 87 | The third difference is that service names defined in `now-compose.yml` must not contain special characters. Only letters, digits and '_' are allowed. 88 | 89 | ## Deploying to zeit now 90 | 91 | Before you can deploy a project using `now-compose` to [zeit now](https://zeit.co/now), you need to provide 92 | an API token generated to make requests to the now API on behalf of your account. 93 | 94 | Visit the [token creation screen](https://zeit.co/account/tokens) and generate a new one for use by `now-compose`. 95 | 96 | You can then deploy your application with: 97 | 98 | ``` 99 | now-compose deploy --apiKey= 100 | ``` 101 | 102 | > You can also provide `now-compose` an api token by setting the environment variable `NOW_API_KEY` with the value of your token. 103 | 104 | 105 | ### Deployment order 106 | 107 | A deployment will be created for each service defined in `now-compose.yml` in order of the `depends_on` property set for each service in `now-compose.yml`. 108 | 109 | Any services that are `linked` (using the "links" property in your application's config) will have the environment variables `NOW_HOST_` set to the deployment url of linked services. `NOW_PORT_` will be `443` since zeit only serves requests over https. 110 | 111 | Happy Developing 🎉 112 | 113 | ## Contributing 114 | 115 | All contributions are welcome. 116 | 117 | * Fork this repository to your own GitHub account and then clone it to your local device. 118 | * Uninstall `now-compose` if it's already installed: `npm uninstall -g now-compose` 119 | * Link it to the global module directory: `npm link` 120 | 121 | ## Roadmap 122 | 123 | `now-compose` is still a work in progress and is considered in an "experimental" phase. However, don't let that deter you from actually using it. Once the codebase matures expect support for `static` and `Node.js` projects for local development and deployment. 124 | 125 | ## Author 126 | 127 | Danny Navarro ([@danny_nav](https://twitter.com/danny_nav)) 128 | --------------------------------------------------------------------------------