├── index.js ├── bin └── index.js ├── test ├── fixtures │ ├── test_ignore_project │ │ ├── ignore.me │ │ ├── .exoframeignore │ │ ├── exoframe.json │ │ ├── index.js │ │ └── package.json │ ├── test_compose_project │ │ ├── Dockerfile │ │ ├── exoframe.json │ │ ├── index.html │ │ └── docker-compose.yml │ ├── test_docker_project │ │ ├── Dockerfile │ │ ├── exoframe.json │ │ └── index.html │ ├── test_html_project │ │ ├── exoframe.json │ │ └── index.html │ ├── test_custom_config_project │ │ ├── exoframe-custom.json │ │ └── index.html │ ├── cli.config.yml │ ├── test_node_project │ │ ├── index.js │ │ └── package.json │ ├── id_rsa_keyphrase │ ├── id_rsa │ └── id_rsa_b ├── __snapshots__ │ ├── prune.test.js.snap │ ├── logs.test.js.snap │ ├── setup.test.js.snap │ ├── config.test.js.snap │ ├── login.test.js.snap │ ├── template.test.js.snap │ ├── remove.test.js.snap │ ├── list.test.js.snap │ ├── endpoint.test.js.snap │ ├── token.test.js.snap │ ├── update.test.js.snap │ ├── secrets.test.js.snap │ └── deploy.test.js.snap ├── prune.test.js ├── __mocks__ │ └── config.js ├── logs.test.js ├── setup.test.js ├── list.test.js ├── remove.test.js ├── config.test.js ├── template.test.js ├── token.test.js ├── update.test.js ├── login.test.js └── secrets.test.js ├── logo ├── .DS_Store ├── png │ ├── exo_blue.png │ ├── exo_black.png │ ├── exo_white.png │ ├── logo_black.png │ ├── logo_blue.png │ └── logo_white.png ├── jpeg │ ├── exo_black.jpg │ ├── exo_blue.jpg │ ├── exo_white.jpg │ ├── logo_blue.jpg │ ├── logo_black.jpg │ └── logo_white.jpg ├── ai │ └── exoframe_logo.ai ├── README.md └── svg │ ├── logo_black.svg │ ├── logo_blue.svg │ ├── logo_white.svg │ ├── exo_black.svg │ ├── exo_blue.svg │ └── exo_white.svg ├── .prettierrc ├── .gitignore ├── src ├── commands │ ├── completion.js │ ├── system.js │ ├── remove.js │ ├── endpoint.js │ ├── list.js │ ├── endpoint-rm.js │ ├── logs.js │ ├── setup.js │ ├── login.js │ ├── token.js │ ├── update.js │ ├── template.js │ └── secrets.js ├── config │ ├── table.js │ └── index.js ├── util │ ├── checkUpdate.js │ ├── renderServices.js │ └── formatServices.js └── index.js ├── docs ├── README.md ├── Nightly.md ├── FAQ.md ├── Links.md ├── Contributing.md ├── PluginsGuide.md ├── Development.md ├── ServerConfiguration.md ├── Functions.md ├── Advanced.md ├── ServerInstallation.md └── TemplatesGuide.md ├── .eslintrc ├── .github └── workflows │ ├── test.yml │ ├── prerelease.yml │ └── release.yml ├── package.json └── README.md /index.js: -------------------------------------------------------------------------------- 1 | require('./src'); 2 | -------------------------------------------------------------------------------- /bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../index'); 3 | -------------------------------------------------------------------------------- /test/fixtures/test_ignore_project/ignore.me: -------------------------------------------------------------------------------- 1 | I should be ignored 2 | -------------------------------------------------------------------------------- /test/fixtures/test_ignore_project/.exoframeignore: -------------------------------------------------------------------------------- 1 | yarn.lock 2 | ignore.me 3 | -------------------------------------------------------------------------------- /test/fixtures/test_ignore_project/exoframe.json: -------------------------------------------------------------------------------- 1 | {"name":"test_ignore_project"} -------------------------------------------------------------------------------- /logo/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melvincarvalho/exoframe/master/logo/.DS_Store -------------------------------------------------------------------------------- /test/fixtures/test_compose_project/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx 2 | COPY . /usr/share/nginx/html 3 | -------------------------------------------------------------------------------- /test/fixtures/test_docker_project/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx 2 | COPY . /usr/share/nginx/html 3 | -------------------------------------------------------------------------------- /test/fixtures/test_compose_project/exoframe.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test_compose_project" 3 | } 4 | -------------------------------------------------------------------------------- /logo/png/exo_blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melvincarvalho/exoframe/master/logo/png/exo_blue.png -------------------------------------------------------------------------------- /logo/jpeg/exo_black.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melvincarvalho/exoframe/master/logo/jpeg/exo_black.jpg -------------------------------------------------------------------------------- /logo/jpeg/exo_blue.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melvincarvalho/exoframe/master/logo/jpeg/exo_blue.jpg -------------------------------------------------------------------------------- /logo/jpeg/exo_white.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melvincarvalho/exoframe/master/logo/jpeg/exo_white.jpg -------------------------------------------------------------------------------- /logo/jpeg/logo_blue.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melvincarvalho/exoframe/master/logo/jpeg/logo_blue.jpg -------------------------------------------------------------------------------- /logo/png/exo_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melvincarvalho/exoframe/master/logo/png/exo_black.png -------------------------------------------------------------------------------- /logo/png/exo_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melvincarvalho/exoframe/master/logo/png/exo_white.png -------------------------------------------------------------------------------- /logo/png/logo_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melvincarvalho/exoframe/master/logo/png/logo_black.png -------------------------------------------------------------------------------- /logo/png/logo_blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melvincarvalho/exoframe/master/logo/png/logo_blue.png -------------------------------------------------------------------------------- /logo/png/logo_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melvincarvalho/exoframe/master/logo/png/logo_white.png -------------------------------------------------------------------------------- /logo/ai/exoframe_logo.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melvincarvalho/exoframe/master/logo/ai/exoframe_logo.ai -------------------------------------------------------------------------------- /logo/jpeg/logo_black.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melvincarvalho/exoframe/master/logo/jpeg/logo_black.jpg -------------------------------------------------------------------------------- /logo/jpeg/logo_white.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melvincarvalho/exoframe/master/logo/jpeg/logo_white.jpg -------------------------------------------------------------------------------- /test/fixtures/test_html_project/exoframe.json: -------------------------------------------------------------------------------- 1 | { 2 | "name":"test_html_project", 3 | "domain": "html.dev" 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/test_custom_config_project/exoframe-custom.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test_custom_config_project", 3 | "domain": "customconf.dev" 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true, 4 | "bracketSpacing": false, 5 | "printWidth": 120, 6 | "arrowParens": "avoid" 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/test_docker_project/exoframe.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test_docker_project", 3 | "domain": "test.dev", 4 | "env": { 5 | "TEST_VAR": 1 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/cli.config.yml: -------------------------------------------------------------------------------- 1 | endpoint: 'http://localhost:8080' 2 | endpoints: 3 | - endpoint: 'http://test.endpoint' 4 | user: null 5 | token: null 6 | token: test-token 7 | user: 8 | username: admin 9 | -------------------------------------------------------------------------------- /test/fixtures/test_docker_project/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Docker test. 4 | 5 | 6 |

This is docker test

7 |

Test test test.

8 | 9 | 10 | -------------------------------------------------------------------------------- /test/fixtures/test_html_project/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | hello world. 4 | 5 | 6 |

This is hello world

7 |

Test test test.

8 | 9 | 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | test_project/ 3 | coverage/ 4 | .nyc_output/ 5 | exoframe-linux 6 | exoframe-macos 7 | exoframe-win.exe 8 | .idea/ 9 | .DS_Store 10 | dist/ 11 | 12 | package-lock.json 13 | exoframe.json 14 | yarn.lock 15 | -------------------------------------------------------------------------------- /test/fixtures/test_custom_config_project/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | hello world. 4 | 5 | 6 |

This is hello world

7 |

Test test test.

8 | 9 | 10 | -------------------------------------------------------------------------------- /test/fixtures/test_compose_project/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | hello docker-compose world. 4 | 5 | 6 |

This is docker-compose hello world

7 |

Test test test.

8 | 9 | 10 | -------------------------------------------------------------------------------- /test/fixtures/test_node_project/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | 3 | const app = express(); 4 | 5 | app.get('/', (req, res) => { 6 | res.send('Hello World'); 7 | }); 8 | 9 | app.listen(80); 10 | 11 | console.log('Listening on port 80'); 12 | -------------------------------------------------------------------------------- /test/fixtures/test_compose_project/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | web: 4 | build: . 5 | labels: 6 | exoframe.deployment: web 7 | traefik.http.routers.web.rule: 'Host(`test.dev`)' 8 | redis: 9 | image: 'redis:alpine' 10 | -------------------------------------------------------------------------------- /test/fixtures/test_ignore_project/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | 3 | const app = express(); 4 | 5 | app.get('/', (req, res) => { 6 | res.send('Hello World'); 7 | }); 8 | 9 | app.listen(80); 10 | 11 | console.log('Listening on port 80'); 12 | -------------------------------------------------------------------------------- /test/__snapshots__/prune.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should execute prune 1`] = ` 4 | Array [ 5 | Array [ 6 | "Data prune successful!", 7 | ], 8 | Array [ 9 | "", 10 | ], 11 | Array [ 12 | "Reclaimed:", 13 | "1.02 kB", 14 | ], 15 | ] 16 | `; 17 | -------------------------------------------------------------------------------- /test/__snapshots__/logs.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should get logs 1`] = ` 4 | Array [ 5 | Array [ 6 | "Getting logs for deployment:", 7 | "test-id", 8 | " 9 | ", 10 | ], 11 | Array [ 12 | " yarn start v0.24.42017-05-18T15:16:40.21259101", 13 | ], 14 | ] 15 | `; 16 | -------------------------------------------------------------------------------- /src/commands/completion.js: -------------------------------------------------------------------------------- 1 | // custom completion target 2 | // fixes completion not working with catch-all command 3 | // see https://github.com/yargs/yargs/issues/1261 4 | module.exports = yargs => ({ 5 | command: ['completion'], 6 | describe: 'generate completion script', 7 | builder: {}, 8 | handler: () => yargs.showCompletionScript(), 9 | }); 10 | -------------------------------------------------------------------------------- /test/fixtures/test_node_project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test_project", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js" 8 | }, 9 | "keywords": [], 10 | "author": "Tim Ermilov (http://codezen.net)", 11 | "license": "ISC", 12 | "dependencies": { 13 | "express": "^4.15.2", 14 | "got": "^6.7.1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/config/table.js: -------------------------------------------------------------------------------- 1 | exports.tableBorder = { 2 | top: '', 3 | 'top-mid': '', 4 | 'top-left': '', 5 | 'top-right': '', 6 | bottom: '', 7 | 'bottom-mid': '', 8 | 'bottom-left': '', 9 | 'bottom-right': '', 10 | left: '', 11 | 'left-mid': '', 12 | mid: '', 13 | 'mid-mid': '', 14 | right: '', 15 | 'right-mid': '', 16 | middle: ' ', 17 | }; 18 | 19 | exports.tableStyle = {'padding-left': 3, 'padding-right': 3}; 20 | -------------------------------------------------------------------------------- /test/fixtures/test_ignore_project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test_ignore_project", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js" 8 | }, 9 | "keywords": [], 10 | "author": "Tim Ermilov (http://codezen.net)", 11 | "license": "ISC", 12 | "dependencies": { 13 | "express": "^4.15.2", 14 | "got": "^6.7.1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | ## Table of Contents 2 | 3 | - [Basics](Basics.md) 4 | - [Server Installation](ServerInstallation.md) 5 | - [Server Configuration](ServerConfiguration.md) 6 | - [Advanced topics](Advanced.md) 7 | - [Function deployments](Functions.md) 8 | - [FAQ](FAQ.md) 9 | - [Contribution Guidelines](Contributing.md) 10 | - [Templates guide](TemplatesGuide.md) 11 | - [Recipes guide](RecipesGuide.md) 12 | - [Using nightly versions](Nightly.md) 13 | - [Tutorials, articles, video and related links](Links.md) 14 | - [Change Log](../CHANGELOG.md) 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": ["standard", "prettier"], 4 | "plugins": ["prettier"], 5 | "rules": { 6 | "max-len": ["error", 120, 4], 7 | "camelcase": "off", 8 | "promise/param-names": "off", 9 | "prefer-promise-reject-errors": "off", 10 | "no-control-regex": "off", 11 | "prettier/prettier": [ 12 | "error", 13 | { 14 | "trailingComma": "es5", 15 | "singleQuote": true, 16 | "bracketSpacing": false, 17 | "printWidth": 120, 18 | "arrowParens": "avoid" 19 | } 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /docs/Nightly.md: -------------------------------------------------------------------------------- 1 | # Using Nightly Versions 2 | 3 | You can optionally use nightly version of Exoframe CLI and Server. 4 | While they do provide latest features, it is not recommended to use them for production. 5 | 6 | ## Nightly CLI builds 7 | 8 | You can install nightly CLI builds using the following command: 9 | 10 | ``` 11 | npm install -g exoframe@next 12 | ``` 13 | 14 | ## Nightly Exoframe-server builds 15 | 16 | You can install nightly server builds by following two steps. 17 | First, you need to modify server config and add the following line: 18 | 19 | ```yaml 20 | updateChannel: nightly 21 | ``` 22 | 23 | Then, you need to run `exoframe update server` against the endpoint you've configured to update to latest nightly build of server. 24 | -------------------------------------------------------------------------------- /logo/README.md: -------------------------------------------------------------------------------- 1 | # Logo 2 | 3 | This folder contains Exoframe logo and its variations 4 | 5 | ## Logo variations 6 | 7 | Black: 8 | 9 | Exoframe 10 | 11 | Blue: 12 | 13 | Exoframe 14 | 15 | White: 16 | 17 | Exoframe 18 | 19 | Black (logo only): 20 | 21 | Exoframe 22 | 23 | Blue (logo only): 24 | 25 | Exoframe 26 | 27 | White (logo only): 28 | 29 | Exoframe 30 | 31 | ## Made by 32 | 33 | Logo was made by [Ivan Semenov](https://www.behance.net/ivan_semenov). 34 | -------------------------------------------------------------------------------- /docs/FAQ.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## Is it ready for production? 4 | 5 | Yes. We've been using it to deploy our project since May 2017 without any issues. 6 | 7 | ## Why do I need to enter username during login? 8 | 9 | Username is just your ID that is used to distinguish your deployments from others. 10 | Right now you have to enter it yourself. And you will only see deployments done with that username. 11 | Currently, more than one user can use same username (so, all users with that username will see same deployments). 12 | 13 | ## How does it work? 14 | 15 | Exoframe uses [Docker](https://www.docker.com/) to deploy your project and [Traefik](https://traefik.io/) to proxy requested domains and/or paths to deployed projects. 16 | All the Docker configuration of your projects happens automatically. So after running the command, the only thing you need to do is wait a few seconds until your project have been built and deployed! 17 | -------------------------------------------------------------------------------- /docs/Links.md: -------------------------------------------------------------------------------- 1 | # Tutorials, articles, video and related links 2 | 3 | ## Tutorials 4 | 5 | - [Tutorial: Deploy to AWS-based Swarm cluster with Exoframe](./TutorialSwarmAWS.md) 6 | 7 | ## Articles 8 | 9 | - [Introducing Exoframe (beta) — self-hosted alternative to Now.sh](https://hackernoon.com/introducing-exoframe-beta-self-hosted-alternative-to-now-sh-80643f96b84b) 10 | - [Continuous deployment for your Node.js projects in 10 minutes with Exoframe](https://hackernoon.com/continuous-deployment-for-your-node-js-projects-in-10-minutes-with-exoframe-bdf48340c1be) 11 | - [Simplifying Docker management with Exoframe](https://hackernoon.com/simplifying-docker-management-with-exoframe-9275e92c7406) 12 | 13 | ## Videos 14 | 15 | - [Introducing Exoframe - self-hosted Now.sh alternative](https://www.youtube.com/watch?v=VZnYKIoh5oA) 16 | - [Continuous Deployment for Node.js projects in 10 mins using Exoframe](https://www.youtube.com/watch?v=AEwLt5hmKYo) 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | # test on every push and on PRs 4 | on: 5 | push: 6 | branches: '*' 7 | pull_request: 8 | 9 | env: 10 | YARN_CACHE_FOLDER: ~/.yarn 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/cache@v1 18 | with: 19 | path: ${{ env.YARN_CACHE_FOLDER }} 20 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 21 | restore-keys: | 22 | ${{ runner.os }}-yarn- 23 | - uses: actions/setup-node@v1 24 | with: 25 | node-version: '12.x' 26 | - name: install 27 | run: yarn --frozen-lockfile 28 | - name: lint 29 | run: yarn lint 30 | - name: test 31 | run: yarn test 32 | - name: coveralls 33 | uses: coverallsapp/github-action@master 34 | with: 35 | github-token: ${{ secrets.GITHUB_TOKEN }} 36 | -------------------------------------------------------------------------------- /docs/Contributing.md: -------------------------------------------------------------------------------- 1 | ## I want to contribute 2 | 3 | Awesome! All contributions are welcome. 4 | If you want to add new feature or implement a significant change that hasn't been discussed yet - please _open an issue first_! 5 | 6 | ## How to send pull requests 7 | 8 | 1. Fork this repository to your own GitHub account 9 | 2. Create new branch that is named accordingly to the issue you are working on (e.g. `feature/new-thing` or `fix/bug-name`) 10 | 3. Make sure tests are passing (if you are adding new feature - add tests to cover basics of that feature) 11 | 4. Make sure your branch is up to date with `develop` branch 12 | 5. Open pull request towards `develop` branch 13 | 6. Wait for feedback 14 | 15 | ## How to run Exoframe CLI locally 16 | 17 | 1. Fork this repository to your own GitHub account and then clone it to your local device. 18 | 2. Uninstall exoframe if it's already installed: `npm uninstall exoframe -g` 19 | 3. Link it to the global module directory: `npm link` 20 | 21 | Now can use the `exoframe` command everywhere. 22 | -------------------------------------------------------------------------------- /test/__snapshots__/setup.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should deauth on 401 on list 1`] = ` 4 | Array [ 5 | Array [ 6 | "Setting new deployment using recipe at:", 7 | "http://localhost:8080", 8 | ], 9 | Array [ 10 | "Error: authorization expired!", 11 | "Please, relogin and try again.", 12 | ], 13 | ] 14 | `; 15 | 16 | exports[`Should deauth on 401 on questions list 1`] = ` 17 | Array [ 18 | Array [ 19 | "Setting new deployment using recipe at:", 20 | "http://localhost:8080", 21 | ], 22 | Array [ 23 | "Error: authorization expired!", 24 | "Please, relogin and try again.", 25 | ], 26 | ] 27 | `; 28 | 29 | exports[`Should execute new setup 1`] = ` 30 | Array [ 31 | Array [ 32 | "Setting new deployment using recipe at:", 33 | "http://localhost:8080", 34 | ], 35 | Array [ 36 | "", 37 | ], 38 | Array [ 39 | "", 40 | ], 41 | Array [ 42 | "1", 43 | ], 44 | Array [ 45 | "2", 46 | ], 47 | Array [ 48 | "", 49 | ], 50 | ] 51 | `; 52 | -------------------------------------------------------------------------------- /test/prune.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | // mock config for testing 3 | jest.mock('../src/config', () => require('./__mocks__/config')); 4 | 5 | // npm packages 6 | const nock = require('nock'); 7 | const sinon = require('sinon'); 8 | 9 | // our packages 10 | const {handler: system} = require('../src/commands/system'); 11 | 12 | // test update 13 | test('Should execute prune', done => { 14 | // handle correct request 15 | const pruneServer = nock('http://localhost:8080') 16 | .post('/system/prune') 17 | .reply(200, {pruned: true, data: [{SpaceReclaimed: 1024}]}); 18 | // spy on console 19 | const consoleSpy = sinon.spy(console, 'log'); 20 | // execute login 21 | system({cmd: 'prune'}).then(() => { 22 | // make sure log in was successful 23 | // check that server was called 24 | expect(pruneServer.isDone()).toBeTruthy(); 25 | // first check console output 26 | expect(consoleSpy.args).toMatchSnapshot(); 27 | // restore console 28 | console.log.restore(); 29 | pruneServer.done(); 30 | done(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /logo/svg/logo_black.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /logo/svg/logo_blue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /logo/svg/logo_white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /test/__mocks__/config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | // mock config module 3 | const cfg = jest.genMockFromModule('../../src/config/index.js'); 4 | 5 | // test config 6 | const testConfig = { 7 | endpoint: 'http://localhost:8080', 8 | endpoints: [ 9 | { 10 | endpoint: 'http://test.endpoint', 11 | user: null, 12 | token: null, 13 | }, 14 | ], 15 | token: 'test-token', 16 | user: { 17 | username: 'admin', 18 | }, 19 | }; 20 | 21 | // saved configs for re-use 22 | const savedConfigs = {}; 23 | 24 | // mock config 25 | let mockConfig = Object.assign({}, testConfig); 26 | 27 | cfg.__save = key => { 28 | savedConfigs[key] = Object.assign({}, mockConfig); 29 | }; 30 | cfg.__restore = key => { 31 | mockConfig = Object.assign({}, savedConfigs[key]); 32 | }; 33 | cfg.updateConfig = newCfg => { 34 | mockConfig = Object.assign(mockConfig, newCfg); 35 | }; 36 | cfg.isLoggedIn = () => { 37 | if (!mockConfig.user || !mockConfig.user.username) { 38 | return false; 39 | } 40 | return true; 41 | }; 42 | cfg.logout = newCfg => { 43 | delete newCfg.user; 44 | delete newCfg.token; 45 | mockConfig = Object.assign({}, newCfg); 46 | }; 47 | cfg.userConfig = mockConfig; 48 | 49 | module.exports = cfg; 50 | -------------------------------------------------------------------------------- /src/util/checkUpdate.js: -------------------------------------------------------------------------------- 1 | // npm packages 2 | const chalk = require('chalk'); 3 | const latestVersion = require('latest-version'); 4 | const semverDiff = require('semver-diff'); 5 | 6 | // packaged script path 7 | const pkgPath = '/snapshot/exoframe-cli/src/util'; 8 | 9 | // check function 10 | module.exports = async pkg => { 11 | const current = pkg.version; 12 | // Checks for available update and returns an instance 13 | const latest = await latestVersion('exoframe').then(r => r.trim()); 14 | // show message if update is available 15 | if (semverDiff(current, latest)) { 16 | const isPackaged = __dirname === pkgPath; 17 | const upNpmMsg = `Run ${chalk.cyan('npm i -g exoframe')} to update`; 18 | const upPkgMsg = `Download from ${chalk.cyan('https://github.com/exoframejs/exoframe/releases')}`; 19 | const upmsg = isPackaged ? upPkgMsg : upNpmMsg; 20 | const message = `Update available ${chalk.dim(current)} ${chalk.reset('→')} ${chalk.green(latest)}`; 21 | console.log(` 22 | ┌───────────────────────────────────────┐ 23 | │ │ 24 | │ ${message} │ 25 | │ ${upmsg} │ 26 | │ │ 27 | └───────────────────────────────────────┘ 28 | `); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /test/__snapshots__/config.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should generate config file 1`] = ` 4 | Array [ 5 | Array [ 6 | "Creating new config..", 7 | ], 8 | Array [ 9 | "Config created!", 10 | ], 11 | ] 12 | `; 13 | 14 | exports[`Should generate config file for functions 1`] = ` 15 | Array [ 16 | Array [ 17 | "Creating new config..", 18 | ], 19 | Array [ 20 | "Creating new config for function deployment..", 21 | ], 22 | Array [ 23 | "Config created!", 24 | ], 25 | ] 26 | `; 27 | 28 | exports[`Should generate the config with parameters 1`] = ` 29 | Array [ 30 | Array [ 31 | "Creating new config..", 32 | ], 33 | Array [ 34 | "Mode changed to", 35 | "non-interactive", 36 | ], 37 | Array [ 38 | "Setting", 39 | "domain", 40 | "to", 41 | "test123.dev", 42 | ], 43 | Array [ 44 | "Setting", 45 | "port", 46 | "to", 47 | "1234", 48 | ], 49 | Array [ 50 | "Setting", 51 | "name", 52 | "to", 53 | "test name 123", 54 | ], 55 | Array [ 56 | "Setting", 57 | "project", 58 | "to", 59 | "give-project-name", 60 | ], 61 | Array [ 62 | "Setting", 63 | "restart", 64 | "to", 65 | "unless-stopped", 66 | ], 67 | Array [ 68 | "Setting", 69 | "hostname", 70 | "to", 71 | "test123.dev", 72 | ], 73 | Array [ 74 | "Config created!", 75 | ], 76 | ] 77 | `; 78 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-expressions: off */ 2 | // npm packages 3 | const yargs = require('yargs'); 4 | 5 | // our packages 6 | const checkUpdate = require('./util/checkUpdate'); 7 | 8 | // version 9 | const pkg = require('../package.json'); 10 | 11 | // check for updates on start 12 | checkUpdate(pkg); 13 | 14 | // our packages 15 | const login = require('./commands/login'); 16 | const deploy = require('./commands/deploy'); 17 | const list = require('./commands/list'); 18 | const logs = require('./commands/logs'); 19 | const remove = require('./commands/remove'); 20 | const endpoint = require('./commands/endpoint'); 21 | const endpointRm = require('./commands/endpoint-rm'); 22 | const config = require('./commands/config'); 23 | const token = require('./commands/token'); 24 | const update = require('./commands/update'); 25 | const template = require('./commands/template'); 26 | const setup = require('./commands/setup'); 27 | const secrets = require('./commands/secrets'); 28 | const completion = require('./commands/completion'); 29 | const system = require('./commands/system'); 30 | 31 | // init program 32 | yargs 33 | .version(pkg.version) 34 | .demand(1) 35 | .help() 36 | .command(deploy) 37 | .command(login) 38 | .command(endpoint) 39 | .command(endpointRm) 40 | .command(list) 41 | .command(logs) 42 | .command(remove) 43 | .command(token) 44 | .command(config) 45 | .command(update) 46 | .command(template) 47 | .command(setup) 48 | .command(secrets) 49 | .command(system) 50 | .command(completion(yargs)).argv; 51 | -------------------------------------------------------------------------------- /test/__snapshots__/login.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should fail to login with broken private key 1`] = ` 4 | Array [ 5 | Array [ 6 | "Logging in to:", 7 | "http://localhost:8080", 8 | ], 9 | Array [ 10 | "Error logging in!", 11 | "Error generating login token! Make sure your private key password is correct", 12 | "Error: ENOENT: no such file or directory, open 'asd'", 13 | ], 14 | ] 15 | `; 16 | 17 | exports[`Should login 1`] = ` 18 | Array [ 19 | Array [ 20 | "Logging in to:", 21 | "http://localhost:8080", 22 | ], 23 | Array [ 24 | "Successfully logged in!", 25 | ], 26 | ] 27 | `; 28 | 29 | exports[`Should login and update endpoint when endpoint was provided 1`] = ` 30 | Array [ 31 | Array [ 32 | "Updating endpoint URL to:", 33 | "http://my-awesome-endpoint", 34 | ], 35 | Array [ 36 | "Endpoint URL updated!", 37 | ], 38 | Array [ 39 | "Logging in to:", 40 | "http://my-awesome-endpoint", 41 | ], 42 | Array [ 43 | "Successfully logged in!", 44 | ], 45 | ] 46 | `; 47 | 48 | exports[`Should login using key with passphrase 1`] = ` 49 | Array [ 50 | Array [ 51 | "Logging in to:", 52 | "http://localhost:8080", 53 | ], 54 | Array [ 55 | "Successfully logged in!", 56 | ], 57 | ] 58 | `; 59 | 60 | exports[`Should not login with wrong certificate 1`] = ` 61 | Array [ 62 | Array [ 63 | "Logging in to:", 64 | "http://localhost:8080", 65 | ], 66 | Array [ 67 | "Error logging in!", 68 | "Check your username and password and try again.", 69 | "HTTPError: Response code 401 (Unauthorized)", 70 | ], 71 | ] 72 | `; 73 | -------------------------------------------------------------------------------- /src/config/index.js: -------------------------------------------------------------------------------- 1 | // npm packages 2 | const os = require('os'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const yaml = require('js-yaml'); 6 | const chalk = require('chalk'); 7 | 8 | // construct paths 9 | const baseFolder = path.join(os.homedir(), '.exoframe'); 10 | const configPath = path.join(baseFolder, 'cli.config.yml'); 11 | 12 | const defaultConfig = { 13 | endpoint: 'http://localhost:8080', 14 | }; 15 | 16 | // default config 17 | let userConfig = defaultConfig; 18 | 19 | // create config folder if doesn't exist 20 | try { 21 | fs.statSync(baseFolder); 22 | } catch (e) { 23 | fs.mkdirSync(baseFolder); 24 | } 25 | 26 | // create user config if doesn't exist 27 | try { 28 | fs.statSync(configPath); 29 | } catch (e) { 30 | fs.writeFileSync(configPath, yaml.safeDump(defaultConfig), 'utf8'); 31 | } 32 | 33 | // load 34 | try { 35 | const newCfg = yaml.safeLoad(fs.readFileSync(configPath, 'utf8')); 36 | // assign new config and clean endpoint url 37 | userConfig = Object.assign(newCfg, { 38 | endpoint: newCfg.endpoint.replace(/\/$/, ''), 39 | }); 40 | } catch (e) { 41 | console.error('Error parsing user config:', e); 42 | } 43 | 44 | exports.updateConfig = newCfg => { 45 | const cfg = Object.assign(userConfig, newCfg); 46 | fs.writeFileSync(configPath, yaml.safeDump(cfg), 'utf8'); 47 | }; 48 | 49 | exports.isLoggedIn = () => { 50 | if (!userConfig.user || !userConfig.user.username) { 51 | console.log(chalk.red('Error: not logged in!'), 'Please, login first!'); 52 | return false; 53 | } 54 | 55 | return true; 56 | }; 57 | 58 | exports.logout = cfg => { 59 | delete cfg.user; 60 | delete cfg.token; 61 | exports.updateConfig(cfg); 62 | }; 63 | 64 | // latest config from file 65 | exports.userConfig = userConfig; 66 | -------------------------------------------------------------------------------- /test/__snapshots__/template.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should deauth on 401 on creation 1`] = ` 4 | Array [ 5 | Array [ 6 | "Adding new deployment template for:", 7 | "http://localhost:8080", 8 | ], 9 | Array [ 10 | "Error: authorization expired!", 11 | "Please, relogin and try again.", 12 | ], 13 | ] 14 | `; 15 | 16 | exports[`Should deauth on 401 on list 1`] = ` 17 | Array [ 18 | Array [ 19 | "Listing deployment templates for:", 20 | "http://localhost:8080", 21 | ], 22 | Array [ 23 | "Error: authorization expired!", 24 | "Please, relogin and try again.", 25 | ], 26 | ] 27 | `; 28 | 29 | exports[`Should install new template 1`] = ` 30 | Array [ 31 | Array [ 32 | "Adding new deployment template for:", 33 | "http://localhost:8080", 34 | ], 35 | ] 36 | `; 37 | 38 | exports[`Should list templates 1`] = ` 39 | Array [ 40 | Array [ 41 | "Listing deployment templates for:", 42 | "http://localhost:8080", 43 | ], 44 | Array [ 45 | "2 templates found on http://localhost:8080: 46 | ", 47 | ], 48 | Array [ 49 | " Template Version 50 | template ^0.0.1 51 | otherTemplate ^1.0.0 ", 52 | ], 53 | ] 54 | `; 55 | 56 | exports[`Should list zero templates 1`] = ` 57 | Array [ 58 | Array [ 59 | "Listing deployment templates for:", 60 | "http://localhost:8080", 61 | ], 62 | Array [ 63 | "0 templates found on http://localhost:8080: 64 | ", 65 | ], 66 | Array [ 67 | " Template Version ", 68 | ], 69 | ] 70 | `; 71 | 72 | exports[`Should remove template 1`] = ` 73 | Array [ 74 | Array [ 75 | "Removing deployment template for:", 76 | "http://localhost:8080", 77 | ], 78 | Array [ 79 | "Template successfully removed!", 80 | ], 81 | ] 82 | `; 83 | -------------------------------------------------------------------------------- /src/commands/system.js: -------------------------------------------------------------------------------- 1 | // npm packages 2 | const got = require('got'); 3 | const chalk = require('chalk'); 4 | const prettyBytes = require('pretty-bytes'); 5 | 6 | // our packages 7 | const {userConfig, isLoggedIn, logout} = require('../config'); 8 | 9 | exports.command = ['system [cmd]']; 10 | exports.describe = 'execute system commands (prune to remove unused data)'; 11 | exports.builder = { 12 | cmd: { 13 | default: '', 14 | description: 'command to execute [prune]', 15 | }, 16 | }; 17 | exports.handler = async args => { 18 | if (!isLoggedIn()) { 19 | return; 20 | } 21 | 22 | // get command 23 | const {cmd} = args; 24 | 25 | if (cmd !== 'prune') { 26 | console.log('Only "prune" command is currently supported!'); 27 | return; 28 | } 29 | 30 | // services request url 31 | const remoteUrl = `${userConfig.endpoint}/system/prune`; 32 | 33 | // construct shared request params 34 | const options = { 35 | method: 'POST', 36 | headers: { 37 | Authorization: `Bearer ${userConfig.token}`, 38 | }, 39 | responseType: 'json', 40 | json: {}, 41 | }; 42 | // try sending request 43 | try { 44 | const {body} = await got(remoteUrl, options); 45 | console.log(chalk.bold('Data prune successful!')); 46 | console.log(''); 47 | console.log( 48 | chalk.bold('Reclaimed:'), 49 | prettyBytes(body.data.map(item => item.SpaceReclaimed).reduce((acc, val) => acc + val, 0)) 50 | ); 51 | } catch (e) { 52 | // if authorization is expired/broken/etc 53 | if (e.response.statusCode === 401) { 54 | logout(userConfig); 55 | console.log(chalk.red('Error: authorization expired!'), 'Please, relogin and try again.'); 56 | return; 57 | } 58 | 59 | console.log(chalk.red(`Error executing ${cmd} command:`), e.toString()); 60 | console.error(e); 61 | } 62 | }; 63 | -------------------------------------------------------------------------------- /test/__snapshots__/remove.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should deauth on 401 1`] = ` 4 | Array [ 5 | Array [ 6 | "Removing deployment:", 7 | "test-id", 8 | ], 9 | Array [ 10 | "Error: authorization expired!", 11 | "Please, relogin and try again.", 12 | ], 13 | ] 14 | `; 15 | 16 | exports[`Should remove 1`] = ` 17 | Array [ 18 | Array [ 19 | "Removing deployment:", 20 | "test-id", 21 | ], 22 | Array [ 23 | "Deployment removed!", 24 | ], 25 | ] 26 | `; 27 | 28 | exports[`Should remove by token instead of default auth 1`] = ` 29 | Array [ 30 | Array [ 31 | "Removing deployment:", 32 | "test-id", 33 | ], 34 | Array [ 35 | " 36 | Removing using given token..", 37 | ], 38 | Array [ 39 | "Deployment removed!", 40 | ], 41 | ] 42 | `; 43 | 44 | exports[`Should remove by url 1`] = ` 45 | Array [ 46 | Array [ 47 | "Removing deployment:", 48 | "test.example.com", 49 | ], 50 | Array [ 51 | "Deployment removed!", 52 | ], 53 | ] 54 | `; 55 | 56 | exports[`Should show not found error 1`] = ` 57 | Array [ 58 | Array [ 59 | "Removing deployment:", 60 | "test-id", 61 | ], 62 | Array [ 63 | "Error: container or function was not found!", 64 | "Please, check deployment ID and try again.", 65 | ], 66 | ] 67 | `; 68 | 69 | exports[`Should show not found error 2`] = ` 70 | Array [ 71 | Array [ 72 | "Removing deployment:", 73 | "test-id", 74 | ], 75 | Array [ 76 | "Error!", 77 | "Could not remove the deployment.", 78 | ], 79 | ] 80 | `; 81 | 82 | exports[`Should show remove error 1`] = ` 83 | Array [ 84 | Array [ 85 | "Removing deployment:", 86 | "test-id", 87 | ], 88 | Array [ 89 | "Error removing project:", 90 | "HTTPError: Response code 500 (Internal Server Error)", 91 | ], 92 | ] 93 | `; 94 | -------------------------------------------------------------------------------- /test/fixtures/id_rsa_keyphrase: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | Proc-Type: 4,ENCRYPTED 3 | DEK-Info: AES-128-CBC,E7426FE8EB7DBCBD6BB2650FE32CD7D5 4 | 5 | R18BO71BfqhrikOKAWEf2oFhqfs2UYoV9XlrWI0ZrgKbvDP42kq19baETasEfCRQ 6 | JMWlfUy105uIsnc6oC+htfnaqcAO5WgxZKz0Tfz8jJXrRk2uLP8m9Lt95q1EbpAD 7 | NApJ8YNwOwdrF2KwfZPc6LaUVY5oS6xVgTZxmSxcw9LGhBneyPt1et9fgx/Qwq1h 8 | 5JoKR68twACJmaoMLiqXcd9XcBRg31wjJ04ZEkTlyGJOlZd4qEaNCSyVeiWPV7eW 9 | pxDX5yAYomP4hvyYAmK/ywAQk6CpltlM0G+7BlvBqwMag3ib84Wz5sNLYNvZD+jj 10 | TZ9LISBqd1pXROLMPfC8OGz45wLbtN643uDZC9Vu3kJNoCFYbfwV9CL5Mo/vLcmp 11 | is3Lx/3upDTRcQ8Xm5kGDtMrNyraI/y63v5Uyc0LX0FPX0K20/uHI9P1o6HwVXrk 12 | CV4vcj2RnI+0a+ggOjjHX3AIZXjji9vf9coL93IoA/27drvC2kUZL4bGOgjWAzxe 13 | BaSD5xeUILRcden38PLjvRnUPkqSlaSQw06R7OFA9dsQMmCmTz4Iiv469qCePlab 14 | L8vq8uycYPwiPj5GeqRTXsgTi2iJo9OloUUG/47XPDhB2/UsTy/be0F/x3DINGYI 15 | lJgo05lnhcts8r6J3ZGikfL5lP/V6e4emLb7LiQ5fgNolFk36dixuJnNIoYjP0kM 16 | M3Bz0CXOVrzKt9WMDvDTGjjGeRHQNAZy+5AqV/68s8cRkeIaBEMvD9umYm3BPCov 17 | EqAjHfzNmVniQWVlFK9H/c4rpHkyS9+56SyokkDlA1J0w2QOR6/caJRXGSlwP5d5 18 | dQ2CZ8U2MRJo21hCAbWEkoTG7ygzunCkE0ajdv00gAr0vTF1vnzt2cqLnVGNgDQT 19 | NwdNyNiE1R5K7NWj+0nqHrqEgqwOsD94FT18bVNN2Bb3Q0/pjT1s9uvz48IUXfGW 20 | pJ7yLpgNGz766Q9MKcoN5vQa5xmT1FOxvpJi+NlDNCgUfu4kpwKG1yKEg6DHnXdi 21 | ITQHBt58HGeEL4Vcyb/hRfMa2DfOlm/9NIkKq2VWRBJojkfZgYhjI99utjbNWHJE 22 | COF5cznr4lnCK+v9ErQDxeUmR5pR/qV/pbW2TxDeZ8ESEEqxUTn6wg9Y26pBZkGY 23 | qOVQ9S5KHc8Zj6SEi/brsd7zxqfDXI85/fJFQIjZAcdkBVNPEcsZ0ThdSUfMB9lU 24 | 97IMTucu1TC9vAoUtsF3DhgSY7upsGra1qAWtuyi7A5bX22Rr3IBne29mxcsSg+Q 25 | ZiMuHa8sSckyw1kiW5WNX0RgN8y0BwY3ndFud91dQANraF+jNKpkNrqggSQABvi4 26 | rUZySK41ezJ3XULb0QL6j/7CVhh1/lGgE48zyvZrgJMfFUV5wAsMOIj/36/EILNv 27 | I0zwoGvihuXyN641/b12bRG5EkyuLynjTBm8dyyhlJAtfRyvOc7F2nzypu31cuXm 28 | 9izYsTHqp0HMgBBeFgdKLWPawCvsSkMhzx/jKyvgC2mMnUAXJfw1PHQPXsbaY1Mv 29 | 1eYxyl2YkDMhnicoNxHFDYJBlPbb9HQuO1HTHWT9iZYO75msNN/BFf9xuunperSt 30 | -----END RSA PRIVATE KEY----- 31 | -------------------------------------------------------------------------------- /test/__snapshots__/list.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should get list of deployments 1`] = ` 4 | Array [ 5 | Array [ 6 | "4 deployments found on http://localhost:8080: 7 | ", 8 | ], 9 | Array [ 10 | "> Normal deployments: 11 | ", 12 | ], 13 | Array [ 14 | "Deployments for test:", 15 | ], 16 | Array [], 17 | Array [ 18 | " ID URL Hostname Status 19 | test test.host Not set Up 10 minutes Container 20 | test2 Not set Not set Up 12 minutes Container ", 21 | ], 22 | Array [], 23 | Array [ 24 | "Other deployments:", 25 | ], 26 | Array [], 27 | Array [ 28 | " ID URL Hostname Status Type 29 | test3 Not set Not set Up 13 minutes Container 30 | test4 Not set alias4 Up 10 minutes Container ", 31 | ], 32 | ] 33 | `; 34 | 35 | exports[`Should get list of swarm deployments 1`] = ` 36 | Array [ 37 | Array [ 38 | "3 deployments found on http://localhost:8080: 39 | ", 40 | ], 41 | Array [ 42 | "> Swarm mode deployments: 43 | ", 44 | ], 45 | Array [ 46 | "Deployments for test-service:", 47 | ], 48 | Array [], 49 | Array [ 50 | " ID URL Hostname Status 51 | test-service-one Not set Not set 52 | test-service-two test.host test.host ", 53 | ], 54 | Array [], 55 | Array [ 56 | "Other deployments:", 57 | ], 58 | Array [], 59 | Array [ 60 | " ID URL Hostname Status Type 61 | test-service-three other.domain Not set ", 62 | ], 63 | ] 64 | `; 65 | -------------------------------------------------------------------------------- /test/__snapshots__/endpoint.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should add new endpoint 1`] = ` 4 | Array [ 5 | Array [ 6 | "Updating endpoint URL to:", 7 | "http://test.endpoint", 8 | ], 9 | Array [ 10 | "Endpoint URL updated!", 11 | ], 12 | ] 13 | `; 14 | 15 | exports[`Should add second new endpoint 1`] = ` 16 | Array [ 17 | Array [ 18 | "Updating endpoint URL to:", 19 | "http://test", 20 | ], 21 | Array [ 22 | "Endpoint URL updated!", 23 | ], 24 | ] 25 | `; 26 | 27 | exports[`Should not remove only endpoint 1`] = ` 28 | Array [ 29 | Array [ 30 | "Error!", 31 | "Cannot remove the only endpoint URL:", 32 | "http://localhost:8080", 33 | ], 34 | ] 35 | `; 36 | 37 | exports[`Should remove current endpoint using inquirer 1`] = ` 38 | Array [ 39 | Array [ 40 | "Removing endpoint:", 41 | "http://test.endpoint", 42 | ], 43 | Array [ 44 | "Endpoint removed!", 45 | ], 46 | ] 47 | `; 48 | 49 | exports[`Should remove existing endpoint using param 1`] = ` 50 | Array [ 51 | Array [ 52 | "Removing endpoint:", 53 | "http://test", 54 | ], 55 | Array [ 56 | "Endpoint removed!", 57 | ], 58 | ] 59 | `; 60 | 61 | exports[`Should select old endpoint 1`] = ` 62 | Array [ 63 | Array [ 64 | "Updating endpoint URL to:", 65 | "http://localhost:8080", 66 | ], 67 | Array [ 68 | "Endpoint URL updated!", 69 | ], 70 | ] 71 | `; 72 | 73 | exports[`Should select old endpoint using URL param 1`] = ` 74 | Array [ 75 | Array [ 76 | "Updating endpoint URL to:", 77 | "http://test.endpoint", 78 | ], 79 | Array [ 80 | "Endpoint URL updated!", 81 | ], 82 | ] 83 | `; 84 | 85 | exports[`Should show error on remove of non-existent endpoint 1`] = ` 86 | Array [ 87 | Array [ 88 | "Error!", 89 | "Couldn't find endpoint with URL:", 90 | "do-not-exist", 91 | ], 92 | ] 93 | `; 94 | -------------------------------------------------------------------------------- /src/util/renderServices.js: -------------------------------------------------------------------------------- 1 | // npm packages 2 | const _ = require('lodash'); 3 | const chalk = require('chalk'); 4 | const Table = require('cli-table3'); 5 | 6 | // our packages 7 | const {tableBorder, tableStyle} = require('../config/table'); 8 | const formatServices = require('./formatServices'); 9 | 10 | module.exports = containers => { 11 | // populate table 12 | const formattedContainers = formatServices(containers); 13 | 14 | // create table 15 | const resultTable = new Table({ 16 | head: ['ID', 'URL', 'Hostname', 'Status', 'Type'], 17 | chars: tableBorder, 18 | style: tableStyle, 19 | }); 20 | 21 | // whether there are any group deployments 22 | let hasGroupedDeployments = false; 23 | 24 | // group by project 25 | const groupedServices = _.groupBy(formattedContainers, 'project'); 26 | // populate tables 27 | Object.keys(groupedServices).forEach(svcKey => { 28 | const svcList = groupedServices[svcKey]; 29 | // if there's only one deployment in project - add it to global table 30 | if (svcList.length === 1) { 31 | const {name, domain, host, status, type} = svcList.pop(); 32 | resultTable.push([name, domain, host, status, type]); 33 | return; 34 | } 35 | 36 | console.log(`Deployments for ${chalk.bold(svcKey)}:`); 37 | console.log(); 38 | const projectTable = new Table({ 39 | head: ['ID', 'URL', 'Hostname', 'Status'], 40 | chars: tableBorder, 41 | style: tableStyle, 42 | }); 43 | svcList.forEach(({name, domain, host, status, type}) => { 44 | projectTable.push([name, domain, host, status, type]); 45 | }); 46 | hasGroupedDeployments = true; 47 | console.log(projectTable.toString()); 48 | console.log(); 49 | }); 50 | 51 | // draw table 52 | if (resultTable.length > 0) { 53 | if (hasGroupedDeployments) { 54 | console.log(`Other deployments:`); 55 | console.log(); 56 | } 57 | console.log(resultTable.toString()); 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /docs/PluginsGuide.md: -------------------------------------------------------------------------------- 1 | # Plugins guide 2 | 3 | Exoframe allows extending the core functionality of Exoframe-Server using third party plugins. 4 | This guide aims to explain basics you need to know to create your own plugins. 5 | If you are looking for plugins usage - please see [Advanced](Advanced.md) part of the docs. 6 | 7 | ## Basics 8 | 9 | Exoframe uses [yarn](https://yarnpkg.com/) to install and remove third-party plugins. 10 | The plugins then are added to Exoframe-Server using Node.js `require()` method. 11 | So, make sure that your plugin's `package.json` has correct `main` attribute. 12 | 13 | Your plugins main script needs to export the following variables and methods: 14 | 15 | ```js 16 | module.exports = { 17 | // plugin default config 18 | config: { 19 | // plugin name 20 | name: 'exoframe-plugin-swarm', 21 | // whether plugin requires exclusive hooks to exoframe methods 22 | // exclusive hooks are the only ones being executed 23 | // make sure you only run ONE exclusive plugin at a time 24 | exclusive: true, 25 | }, 26 | 27 | /* plugin functions that hook into Exoframe-Server methods */ 28 | // server init function hook 29 | // should initialize traefik, setup networks, etc 30 | init, 31 | // exoframe start function hook 32 | // should start a deployment from files 33 | start, 34 | // exoframe startFromParams function hook 35 | // should start a deployment from given set of params 36 | startFromParams, 37 | // exoframe list function hook 38 | // should list currently active deployments 39 | list, 40 | // exoframe logs function hook 41 | // should get logs for a given deployment 42 | logs, 43 | // exoframe remove function hook 44 | // should remove a given deployment 45 | remove, 46 | // exoframe compose template extension 47 | // can affect how docker-compose template is executed 48 | compose, 49 | }; 50 | ``` 51 | 52 | ## Examples 53 | 54 | - [Swarm plugin](https://github.com/exoframejs/exoframe-plugin-swarm) 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "exoframe", 3 | "version": "6.2.0", 4 | "description": "Exoframe is a self-hosted tool that allows simple one-command deployments using Docker", 5 | "main": "dist/index.js", 6 | "repository": "git@github.com:exoframejs/exoframe.git", 7 | "author": "Tim Ermilov ", 8 | "license": "MIT", 9 | "bin": { 10 | "exoframe": "dist/index.js" 11 | }, 12 | "files": [ 13 | "dist/locales/*", 14 | "dist/index.js", 15 | "dist/xdg-open" 16 | ], 17 | "scripts": { 18 | "lint": "eslint src/ test/", 19 | "test": "TZ=Greenland NODE_ENV=testing jest --coverage --silent --maxWorkers=2 --ci", 20 | "coveralls": "cat ./coverage/lcov.info | coveralls", 21 | "build": "ncc build bin/index.js -o dist", 22 | "package": "pkg --targets node12.2.0-linux-x64,node12.2.0-win-x64,node12.2.0-macos-x64 -o exoframe dist/index.js" 23 | }, 24 | "dependencies": {}, 25 | "devDependencies": { 26 | "@zeit/ncc": "^0.21.0", 27 | "apache-md5": "^1.1.2", 28 | "babel-eslint": "^10.0.3", 29 | "chalk": "^4.0.0", 30 | "cli-table3": "^0.5.1", 31 | "coveralls": "^3.0.7", 32 | "eslint": "^6.6.0", 33 | "eslint-config-prettier": "^6.5.0", 34 | "eslint-config-standard": "^14.1.0", 35 | "eslint-plugin-import": "^2.18.2", 36 | "eslint-plugin-node": "^11.0.0", 37 | "eslint-plugin-prettier": "^3.1.1", 38 | "eslint-plugin-promise": "^4.2.1", 39 | "eslint-plugin-standard": "^4.0.1", 40 | "got": "^10.4.0", 41 | "highland": "^2.13.5", 42 | "inquirer": "^7.0.0", 43 | "jest": "^25.1.0", 44 | "js-yaml": "^3.13.1", 45 | "jsonwebtoken": "^8.5.1", 46 | "latest-version": "^5.1.0", 47 | "lodash": "^4.17.15", 48 | "multimatch": "^4.0.0", 49 | "nock": "^12.0.3", 50 | "open": "^7.0.0", 51 | "ora": "^4.0.2", 52 | "pkg": "^4.4.0", 53 | "prettier": "^2.0.4", 54 | "pretty-bytes": "^5.3.0", 55 | "semver-diff": "^3.1.1", 56 | "sinon": "^9.0.2", 57 | "tar-fs": "^2.0.0", 58 | "yargs": "^15.1.0" 59 | }, 60 | "jest": { 61 | "testEnvironment": "node" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /test/__snapshots__/token.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should deauth on 401 on creation 1`] = ` 4 | Array [ 5 | Array [ 6 | "Generating new deployment token for:", 7 | "http://localhost:8080", 8 | ], 9 | Array [ 10 | "Error: authorization expired!", 11 | "Please, relogin and try again.", 12 | ], 13 | ] 14 | `; 15 | 16 | exports[`Should deauth on 401 on list 1`] = ` 17 | Array [ 18 | Array [ 19 | "Listing deployment tokens for:", 20 | "http://localhost:8080", 21 | ], 22 | Array [ 23 | "Error: authorization expired!", 24 | "Please, relogin and try again.", 25 | ], 26 | ] 27 | `; 28 | 29 | exports[`Should generate token 1`] = ` 30 | Array [ 31 | Array [ 32 | "Generating new deployment token for:", 33 | "http://localhost:8080", 34 | ], 35 | Array [ 36 | "New token generated:", 37 | ], 38 | Array [ 39 | "", 40 | ], 41 | Array [ 42 | "test", 43 | ], 44 | Array [ 45 | "", 46 | ], 47 | Array [ 48 | "WARNING!", 49 | "Make sure to write it down, you will not be able to get it again!", 50 | ], 51 | ] 52 | `; 53 | 54 | exports[`Should list tokens 1`] = ` 55 | Array [ 56 | Array [ 57 | "Listing deployment tokens for:", 58 | "http://localhost:8080", 59 | ], 60 | Array [ 61 | "Got generated tokens:", 62 | ], 63 | Array [ 64 | "", 65 | ], 66 | Array [ 67 | " > test []", 68 | ], 69 | ] 70 | `; 71 | 72 | exports[`Should list zero tokens 1`] = ` 73 | Array [ 74 | Array [ 75 | "Listing deployment tokens for:", 76 | "http://localhost:8080", 77 | ], 78 | Array [ 79 | "Got generated tokens:", 80 | ], 81 | Array [ 82 | "", 83 | ], 84 | Array [ 85 | " > No deployment tokens available!", 86 | ], 87 | ] 88 | `; 89 | 90 | exports[`Should remove token 1`] = ` 91 | Array [ 92 | Array [ 93 | "Removing deployment token for:", 94 | "http://localhost:8080", 95 | ], 96 | Array [ 97 | "Deployment token successfully removed!", 98 | ], 99 | ] 100 | `; 101 | -------------------------------------------------------------------------------- /test/logs.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | // mock config for testing 3 | jest.mock('../src/config', () => require('./__mocks__/config')); 4 | 5 | // npm packages 6 | const nock = require('nock'); 7 | const sinon = require('sinon'); 8 | const Stream = require('stream'); 9 | 10 | // our packages 11 | const {handler: logs} = require('../src/commands/logs'); 12 | 13 | const id = 'test-id'; 14 | const date1 = '2017-05-18T15:16:40.120990460Z'; 15 | const date2 = '2017-05-18T15:16:40.212591019Z'; 16 | const date3 = '2017-05-18T15:16:40.375554362Z'; 17 | const dateToLocaleDate = str => `${new Date(str).toLocaleDateString()} ${new Date(str).toLocaleTimeString()}`; 18 | const localeDate1 = dateToLocaleDate(date1); 19 | const localeDate2 = dateToLocaleDate(date2); 20 | const localeDate3 = dateToLocaleDate(date3); 21 | const dirtyLogs = [ 22 | `\u0001\u0000\u0000\u0000\u0000\u0000\u0000g${date1} yarn start v0.24.4`, 23 | `\u0001\u0000\u0000\u0000\u0000\u0000\u0000${date2} $ node index.js `, 24 | `\u0001\u0000\u0000\u0000\u0000\u0000\u0000${date3} Listening on port 80`, 25 | '', 26 | ]; 27 | 28 | // test removal 29 | test.only('Should get logs', done => { 30 | const readable = new Stream.Readable(); 31 | const emitLogs = () => { 32 | dirtyLogs.forEach(item => readable.push(item)); 33 | // no more data 34 | readable.push(null); 35 | }; 36 | // handle correct request 37 | const logServer = nock('http://localhost:8080') 38 | .get(`/logs/${id}`) 39 | .reply(200, () => { 40 | emitLogs(); 41 | return readable; 42 | }); 43 | // spy on console 44 | const consoleSpy = sinon.spy(console, 'log'); 45 | // execute login 46 | logs({id}).then(() => { 47 | // make sure log in was successful 48 | // check that server was called 49 | expect(logServer.isDone()).toBeTruthy(); 50 | // first check console output 51 | const logsWithoutDates = consoleSpy.args.map(lines => 52 | lines.map(l => l.replace(localeDate1, '').replace(localeDate2, '').replace(localeDate3, '')) 53 | ); 54 | expect(logsWithoutDates).toMatchSnapshot(); 55 | // restore console 56 | console.log.restore(); 57 | logServer.done(); 58 | done(); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/commands/remove.js: -------------------------------------------------------------------------------- 1 | // npm packages 2 | const got = require('got'); 3 | const chalk = require('chalk'); 4 | 5 | // our packages 6 | const {userConfig, isLoggedIn, logout} = require('../config'); 7 | 8 | exports.command = ['remove ', 'rm ']; 9 | exports.describe = 'remove active deployment'; 10 | exports.builder = { 11 | token: { 12 | alias: 't', 13 | description: 'Deployment token to be used for authentication', 14 | }, 15 | }; 16 | exports.handler = async (args = {}) => { 17 | const deployToken = args.token; 18 | const id = args.id; 19 | 20 | if (!deployToken && !isLoggedIn()) { 21 | console.log(chalk.red('Error!'), '\nYou need to sign in first or supply a authentication token.'); 22 | return; 23 | } 24 | 25 | console.log(chalk.bold('Removing deployment:'), id); 26 | 27 | // services request url 28 | const remoteUrl = `${userConfig.endpoint}/remove/${encodeURIComponent(id)}`; 29 | let authToken = userConfig.token; 30 | 31 | if (deployToken) { 32 | authToken = deployToken; 33 | console.log('\nRemoving using given token..'); 34 | } 35 | // construct shared request params 36 | const options = { 37 | headers: { 38 | Authorization: `Bearer ${authToken}`, 39 | }, 40 | json: {}, 41 | }; 42 | // try sending request 43 | try { 44 | const {statusCode} = await got.post(remoteUrl, options); 45 | if (statusCode === 204) { 46 | console.log(chalk.green('Deployment removed!')); 47 | } else { 48 | console.log(chalk.red('Error!'), 'Could not remove the deployment.'); 49 | } 50 | } catch (e) { 51 | // if authorization is expired/broken/etc 52 | if (e.response.statusCode === 401) { 53 | logout(userConfig); 54 | console.log(chalk.red('Error: authorization expired!'), 'Please, relogin and try again.'); 55 | return; 56 | } 57 | 58 | // if container was not found 59 | if (e.response.statusCode === 404) { 60 | console.log( 61 | chalk.red('Error: container or function was not found!'), 62 | 'Please, check deployment ID and try again.' 63 | ); 64 | return; 65 | } 66 | 67 | console.log(chalk.red('Error removing project:'), e.toString()); 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /src/util/formatServices.js: -------------------------------------------------------------------------------- 1 | const ruleRegex = /^Host\(`(.+?)`\)$/; 2 | const formatTraefikRule = rule => { 3 | const match = ruleRegex.exec(rule); 4 | if (match) { 5 | return match[1]; 6 | } 7 | return rule; 8 | }; 9 | 10 | module.exports = services => 11 | services.map(svc => { 12 | const isSwarm = !!svc.Spec; 13 | 14 | // handle non-swarm deployments 15 | if (!isSwarm) { 16 | const name = svc.Name.slice(1); 17 | const deploymentName = svc.Config.Labels['exoframe.deployment']; 18 | const domain = svc.Config.Labels[`traefik.http.routers.${deploymentName}.rule`] 19 | ? formatTraefikRule(svc.Config.Labels[`traefik.http.routers.${deploymentName}.rule`]) 20 | : 'Not set'; 21 | const networks = svc.NetworkSettings.Networks; 22 | const aliases = Object.keys(networks) 23 | .map(networkName => networks[networkName]) 24 | .filter(net => net.Aliases && net.Aliases.length > 0) 25 | .map(net => net.Aliases.filter(alias => !svc.Id.startsWith(alias))) 26 | .reduce((acc, val) => acc.concat(val), []); 27 | const project = svc.Config.Labels['exoframe.project']; 28 | const host = aliases.shift() || 'Not set'; 29 | const status = svc.State ? svc.State.Status : ''; 30 | const type = svc.Config.Labels['exoframe.type'] ? svc.Config.Labels['exoframe.type'] : 'Container'; 31 | return {name, domain, host, status, project, type}; 32 | } 33 | 34 | // handle swarm deployments 35 | const name = svc.Spec.Name; 36 | const deploymentName = svc.Spec.Labels['exoframe.deployment']; 37 | const domain = svc.Spec.Labels[`traefik.http.routers.${deploymentName}.rule`] 38 | ? formatTraefikRule(svc.Spec.Labels[`traefik.http.routers.${deploymentName}.rule`]) 39 | : 'Not set'; 40 | const networks = svc.Spec.Networks || svc.Spec.TaskTemplate.Networks; 41 | const aliases = networks 42 | .filter(net => net.Aliases && net.Aliases.length > 0) 43 | .map(net => net.Aliases.filter(alias => !svc.ID.startsWith(alias))) 44 | .reduce((acc, val) => acc.concat(val), []); 45 | const project = svc.Spec.Labels['exoframe.project']; 46 | const host = aliases.shift() || 'Not set'; 47 | const status = svc.State ? svc.State.Status : ''; 48 | return {name, domain, host, status, project}; 49 | }); 50 | -------------------------------------------------------------------------------- /src/commands/endpoint.js: -------------------------------------------------------------------------------- 1 | // npm packages 2 | const chalk = require('chalk'); 3 | const inquirer = require('inquirer'); 4 | 5 | // our packages 6 | const {userConfig, updateConfig} = require('../config'); 7 | 8 | exports.command = 'endpoint [url]'; 9 | exports.describe = 'switch or add exoframe server URL'; 10 | exports.builder = { 11 | url: { 12 | alias: 'u', 13 | default: '', 14 | description: 'URL of a new endpoint', 15 | }, 16 | }; 17 | exports.handler = async ({url}) => { 18 | let endpoint = url; 19 | if (!endpoint || !endpoint.length) { 20 | // if one endpoint only - show this 21 | if (!userConfig.endpoints || !userConfig.endpoints.length) { 22 | console.log(chalk.bold('Current endpoint URL:'), userConfig.endpoint); 23 | return; 24 | } 25 | 26 | // if multiple - show selector 27 | const prompts = []; 28 | prompts.push({ 29 | type: 'list', 30 | name: 'newEndpoint', 31 | message: 'Choose endpoint:', 32 | default: userConfig.endpoint, 33 | choices: [userConfig.endpoint].concat(userConfig.endpoints.map(entry => entry.endpoint)), 34 | }); 35 | const {newEndpoint} = await inquirer.prompt(prompts); 36 | // if user selected current - just exit 37 | if (newEndpoint === userConfig.endpoint) { 38 | return; 39 | } 40 | // assign new selected as entered endpoint 41 | endpoint = newEndpoint; 42 | } 43 | 44 | // if current endpoint set - move it to endpoints 45 | if (userConfig.endpoint) { 46 | // init array if needed 47 | if (!userConfig.endpoints) { 48 | userConfig.endpoints = []; 49 | } 50 | // push data 51 | userConfig.endpoints.push({ 52 | endpoint: userConfig.endpoint, 53 | user: userConfig.user || null, 54 | token: userConfig.token || null, 55 | }); 56 | } 57 | // then write new endpoint to current one and remove user/token 58 | console.log(chalk.bold('Updating endpoint URL to:'), endpoint); 59 | const newData = userConfig.endpoints.find(e => e.endpoint === endpoint); 60 | const user = newData ? newData.user : null; 61 | const token = newData ? newData.token : null; 62 | const endpoints = userConfig.endpoints.filter(e => e.endpoint !== endpoint); 63 | updateConfig({endpoint, user, token, endpoints}); 64 | console.log(chalk.green('Endpoint URL updated!')); 65 | }; 66 | -------------------------------------------------------------------------------- /docs/Development.md: -------------------------------------------------------------------------------- 1 | # Using Development and Debug Versions 2 | 3 | You might need to run Exoframe CLI and Server in development mode. 4 | There is currently three ways to do so. 5 | They are described in more detail below. 6 | 7 | ## Using development versions from source 8 | 9 | Primary way of running Exoframe CLI and Server in development mode is by using source code available in github. 10 | 11 | ### Exoframe CLI 12 | 13 | Exoframe CLI requires you to have Node.js and yarn installed. 14 | To run Exoframe CLI in development follow this steps: 15 | 16 | 1. Make sure you don't have `exoframe` installed globally - if you do, remove it 17 | 2. Clone the Exoframe CLI repo: `git clone git@github.com:exoframejs/exoframe.git && cd exoframe` 18 | 3. Install dependencies: `yarn install` 19 | 4. Link Exoframe CLI to your global packages to expose it as a command: `npm link` 20 | 5. You can now run `exoframe --version` which should execute your dev version of Exoframe CLI 21 | 22 | ### Exoframe-Server 23 | 24 | Exoframe-Server requires you to have Node.js, yarn, Docker and docker-compose installed. 25 | To run Exoframe-Server in development follow this steps: 26 | 27 | 1. Clone the Exoframe-Server repo: `git clone git@github.com:exoframejs/exoframe-server.git && cd exoframe-server` 28 | 2. Install dependencies: `yarn install` 29 | 3. You can now run the server by executing: `yarn start` 30 | 4. Point your Exoframe CLI to `http://localhost:8080` to access your server 31 | 32 | ## Using Exoframe-Server debug version from npm 33 | 34 | It is also possible to run Exoframe-Server in development mode by using package available in npm. 35 | Exoframe-Server can be installed by running `npm install -g exoframe-server`. 36 | This will add `exoframe-server` binary to your system - executing it will start Exoframe-Server in development mode. 37 | This way also requires you to have Node.js, yarn, Docker and docker-compose installed. 38 | 39 | ## Using Exoframe-Server debug version from docker hub 40 | 41 | It is also possible to run Exoframe-Server in development mode by using docker image available in docker hub. 42 | Exoframe-Server can be started by running `docker run -v ... exoframe/server:debug` (see [readme](../README.md) for full command). 43 | This will start Exoframe-Server in development mode. 44 | This way requires you to have Docker installed. 45 | -------------------------------------------------------------------------------- /src/commands/list.js: -------------------------------------------------------------------------------- 1 | // npm packages 2 | const got = require('got'); 3 | const chalk = require('chalk'); 4 | 5 | // our packages 6 | const {userConfig, isLoggedIn, logout} = require('../config'); 7 | const renderServices = require('../util/renderServices'); 8 | 9 | exports.command = ['list', 'ls']; 10 | exports.describe = 'list deployments'; 11 | exports.builder = {}; 12 | exports.handler = async () => { 13 | if (!isLoggedIn()) { 14 | return; 15 | } 16 | 17 | // services request url 18 | const remoteUrl = `${userConfig.endpoint}/list`; 19 | // construct shared request params 20 | const options = { 21 | headers: { 22 | Authorization: `Bearer ${userConfig.token}`, 23 | }, 24 | responseType: 'json', 25 | }; 26 | // try sending request 27 | let containers = []; 28 | let services = []; 29 | try { 30 | const {body} = await got(remoteUrl, options); 31 | if (!body) { 32 | services = undefined; 33 | containers = undefined; 34 | } else { 35 | const {containers: userContainers, services: userServices} = body; 36 | containers = userContainers || []; 37 | services = userServices || []; 38 | } 39 | } catch (e) { 40 | // if authorization is expired/broken/etc 41 | if (e.statusCode === 401) { 42 | logout(userConfig); 43 | console.log(chalk.red('Error: authorization expired!'), 'Please, relogin and try again.'); 44 | return; 45 | } 46 | 47 | console.log(chalk.red('Error while getting list:'), e.toString()); 48 | return; 49 | } 50 | // check for errors 51 | if (!containers && !services) { 52 | throw new Error('Server returned empty response!'); 53 | } 54 | if (containers.length > 0 || services.length > 0) { 55 | // print count 56 | console.log(chalk.green(`${containers.length + services.length} deployments found on ${userConfig.endpoint}:\n`)); 57 | 58 | // render containers 59 | if (containers.length > 0) { 60 | console.log(`> ${chalk.blue.bold.underline('Normal')} deployments:\n`); 61 | renderServices(containers); 62 | } 63 | 64 | // render services 65 | if (services.length > 0) { 66 | console.log(`> ${chalk.blue.bold.underline('Swarm mode')} deployments:\n`); 67 | renderServices(services); 68 | } 69 | } else { 70 | console.log(chalk.green(`No deployments found on ${userConfig.endpoint}!`)); 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /test/__snapshots__/update.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should deauth on 401 1`] = ` 4 | Array [ 5 | Array [ 6 | "Updating traefik on:", 7 | "http://localhost:8080", 8 | ], 9 | Array [ 10 | "Error: authorization expired!", 11 | "Please, relogin and try again.", 12 | ], 13 | ] 14 | `; 15 | 16 | exports[`Should display update error 1`] = ` 17 | Array [ 18 | Array [ 19 | "Updating traefik on:", 20 | "http://localhost:8080", 21 | ], 22 | Array [ 23 | "Error updating traefik:", 24 | "Test error", 25 | ], 26 | Array [ 27 | "Update log: 28 | ", 29 | ], 30 | Array [ 31 | "log", 32 | ], 33 | ] 34 | `; 35 | 36 | exports[`Should display versions 1`] = ` 37 | Array [ 38 | Array [], 39 | Array [ 40 | "Exoframe Server:", 41 | ], 42 | Array [ 43 | " current: 0.18.0", 44 | ], 45 | Array [ 46 | " latest: 0.19.1", 47 | ], 48 | Array [], 49 | Array [ 50 | "Traefik:", 51 | ], 52 | Array [ 53 | " current: v1.3.0", 54 | ], 55 | Array [ 56 | " latest: v1.3.2", 57 | ], 58 | Array [], 59 | ] 60 | `; 61 | 62 | exports[`Should update all on user prompt 1`] = ` 63 | Array [ 64 | Array [], 65 | Array [ 66 | "Exoframe Server:", 67 | ], 68 | Array [ 69 | " current: 0.18.0", 70 | ], 71 | Array [ 72 | " latest: 0.19.1", 73 | ], 74 | Array [], 75 | Array [ 76 | "Traefik:", 77 | ], 78 | Array [ 79 | " current: v1.3.0", 80 | ], 81 | Array [ 82 | " latest: v1.3.2", 83 | ], 84 | Array [], 85 | Array [ 86 | "Updating traefik on:", 87 | "http://localhost:8080", 88 | ], 89 | Array [ 90 | "Successfully updated traefik!", 91 | ], 92 | Array [ 93 | "Updating server on:", 94 | "http://localhost:8080", 95 | ], 96 | Array [ 97 | "Successfully updated server!", 98 | ], 99 | ] 100 | `; 101 | 102 | exports[`Should update server 1`] = ` 103 | Array [ 104 | Array [ 105 | "Updating server on:", 106 | "http://localhost:8080", 107 | ], 108 | Array [ 109 | "Successfully updated server!", 110 | ], 111 | ] 112 | `; 113 | 114 | exports[`Should update traefik 1`] = ` 115 | Array [ 116 | Array [ 117 | "Updating traefik on:", 118 | "http://localhost:8080", 119 | ], 120 | Array [ 121 | "Successfully updated traefik!", 122 | ], 123 | ] 124 | `; 125 | -------------------------------------------------------------------------------- /docs/ServerConfiguration.md: -------------------------------------------------------------------------------- 1 | ## Server Configuration 2 | 3 | Exoframe stores its config in `~/.exoframe/server.config.yml`. 4 | Currently it contains the following settings: 5 | 6 | ```yaml 7 | # whether debug mode is enabled, default "false" 8 | debug: false 9 | 10 | # whether to enable letsencrypt, default "false" 11 | letsencrypt: false 12 | 13 | # email used for letsencrypt 14 | letsencryptEmail: your@email.com 15 | 16 | # whether to apply gzip compression, default "true" 17 | compress: true 18 | 19 | # whether to execute docker prune for images and volumes, default "false" 20 | autoprune: false 21 | 22 | # base top-level domain to use for deployments without domains specified, default "false" 23 | # used as postfix, e.g. if you specify ".example.com" (dot is auto-prepended if not present) 24 | # all your deployments will be autodeployed as "deployment-id.example.com" 25 | baseDomain: false 26 | 27 | # CORS support; can be "true" ("*" header) or object with "origin" property, default "false" 28 | cors: false 29 | 30 | # Traefik image to be used; set to "false" to disable traefik management, default "traefik:latest" 31 | traefikImage: 'traefik:latest' 32 | 33 | # Traefik container name, default "exoframe-traefik" 34 | traefikName: 'exoframe-traefik' 35 | 36 | # Additional Traefik start args, default [] 37 | traefikArgs: [] 38 | 39 | # Network used by traefik to connect services to, default "exoframe" 40 | exoframeNetwork: 'exoframe' 41 | 42 | # server image update channel; can be "stable" or "nightly", default "stable" 43 | updateChannel: 'stable' 44 | 45 | # path to folder with authorized_keys, default "~/.ssh" 46 | publicKeysPath: '/path/to/your/public/keys' 47 | 48 | # whether Exoframe server would be running in swarm mode, default "false" 49 | swarm: false 50 | 51 | # plugins config 52 | plugins: 53 | # list of plugins that has to be installed and loaded by exoframe-server on startup 54 | install: ['exoframe-plugin-swarm'] 55 | # specific plugin config (see plugins docs to know what property they use) 56 | swarm: 57 | enabled: true 58 | ``` 59 | 60 | _Warning:_ Most changes to config are applied immediately. With exception of Letsencrypt config and Plugins config. 61 | If you are enabling letsencrypt after Traefik instance has been started, you'll need to remove Traefik and then restart Exoframe server for changes to take effect. 62 | If you are adding plugins after server has been started, you'll need to restart the server so that it can install and load newly added plugins. 63 | -------------------------------------------------------------------------------- /.github/workflows/prerelease.yml: -------------------------------------------------------------------------------- 1 | name: Prerelease 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | 8 | env: 9 | YARN_CACHE_FOLDER: ~/.yarn 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/cache@v1 17 | with: 18 | path: ${{ env.YARN_CACHE_FOLDER }} 19 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 20 | restore-keys: | 21 | ${{ runner.os }}-yarn- 22 | - uses: actions/setup-node@v1 23 | with: 24 | node-version: '12.x' 25 | registry-url: https://registry.npmjs.org 26 | - name: install 27 | run: yarn --frozen-lockfile 28 | 29 | # lint, test, report coverage 30 | - name: lint 31 | run: yarn lint 32 | - name: test 33 | run: yarn test 34 | 35 | # upload coverage 36 | - name: Coveralls 37 | uses: coverallsapp/github-action@master 38 | with: 39 | github-token: ${{ secrets.GITHUB_TOKEN }} 40 | 41 | publish: 42 | needs: test 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v2 46 | - uses: actions/cache@v1 47 | with: 48 | path: ${{ env.YARN_CACHE_FOLDER }} 49 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 50 | restore-keys: | 51 | ${{ runner.os }}-yarn- 52 | - uses: actions/setup-node@v1 53 | with: 54 | node-version: '12.x' 55 | registry-url: https://registry.npmjs.org 56 | 57 | # build 58 | - name: install 59 | run: yarn --frozen-lockfile 60 | - name: build 61 | run: yarn build 62 | - name: package 63 | run: yarn package 64 | 65 | # bump version & commit it 66 | - run: npm version prerelease --preid=dev --no-git-tag-version 67 | - name: setup git 68 | run: | 69 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com" 70 | git config user.name "$GITHUB_ACTOR" 71 | - name: commit new version to develop 72 | run: | 73 | git add -A 74 | git commit -m "Bump version for @next release" 75 | git push "https://$GITHUB_ACTOR:$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY" 76 | env: 77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | # deploy to npm 79 | - run: npm publish --access public --tag next 80 | env: 81 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 82 | -------------------------------------------------------------------------------- /src/commands/endpoint-rm.js: -------------------------------------------------------------------------------- 1 | // npm packages 2 | const chalk = require('chalk'); 3 | const inquirer = require('inquirer'); 4 | 5 | // our packages 6 | const {userConfig, updateConfig} = require('../config'); 7 | 8 | exports.command = 'rm-endpoint [url]'; 9 | exports.describe = 'remove existing exoframe endpoint'; 10 | exports.builder = { 11 | url: { 12 | alias: 'u', 13 | default: '', 14 | description: 'URL of an existing endpoint', 15 | }, 16 | }; 17 | exports.handler = async ({url}) => { 18 | let endpointUrl = url; 19 | 20 | // if one endpoint only - show error 21 | if (!userConfig.endpoints || !userConfig.endpoints.length) { 22 | console.log(chalk.red('Error!'), chalk.bold('Cannot remove the only endpoint URL:'), userConfig.endpoint); 23 | return; 24 | } 25 | 26 | // if not endpoint url given - show selector 27 | if (!endpointUrl || !endpointUrl.length) { 28 | // if multiple - show selector 29 | const prompts = []; 30 | prompts.push({ 31 | type: 'list', 32 | name: 'delEndpoint', 33 | message: 'Choose endpoint to remove:', 34 | default: userConfig.endpoint, 35 | choices: [userConfig.endpoint].concat(userConfig.endpoints.map(entry => entry.endpoint)), 36 | }); 37 | const {delEndpoint} = await inquirer.prompt(prompts); 38 | // assign new selected as entered endpoint 39 | endpointUrl = delEndpoint; 40 | } 41 | 42 | // if current endpoint set - move it to endpoints 43 | if (userConfig.endpoint === endpointUrl) { 44 | console.log(chalk.bold('Removing endpoint:'), endpointUrl); 45 | const newData = userConfig.endpoints.shift(); 46 | const endpoint = newData.endpoint; 47 | const user = newData ? newData.user : null; 48 | const token = newData ? newData.token : null; 49 | const endpoints = userConfig.endpoints.filter(e => e.endpoint !== newData.endpoint); 50 | updateConfig({endpoint, user, token, endpoints}); 51 | console.log(chalk.green('Endpoint removed!')); 52 | return; 53 | } 54 | 55 | const index = userConfig.endpoints.findIndex(it => it.endpoint === endpointUrl); 56 | if (index === -1) { 57 | console.log(chalk.red('Error!'), "Couldn't find endpoint with URL:", endpointUrl); 58 | return; 59 | } 60 | 61 | // then write new endpoint to current one and remove user/token 62 | console.log(chalk.bold('Removing endpoint:'), endpointUrl); 63 | const endpoints = userConfig.endpoints.filter(e => e.endpoint !== endpointUrl); 64 | updateConfig({endpoints}); 65 | console.log(chalk.green('Endpoint removed!')); 66 | }; 67 | -------------------------------------------------------------------------------- /src/commands/logs.js: -------------------------------------------------------------------------------- 1 | // npm packages 2 | const got = require('got'); 3 | const chalk = require('chalk'); 4 | 5 | // our packages 6 | const {userConfig, isLoggedIn, logout} = require('../config'); 7 | 8 | exports.command = ['logs ', 'log ']; 9 | exports.describe = 'get logs for given deployment'; 10 | exports.builder = { 11 | follow: { 12 | alias: 'f', 13 | description: 'Follow log output', 14 | count: true, 15 | }, 16 | }; 17 | exports.handler = ({id, follow}) => 18 | new Promise(resolve => { 19 | if (!isLoggedIn()) { 20 | return; 21 | } 22 | 23 | console.log(chalk.bold('Getting logs for deployment:'), id, '\n'); 24 | 25 | // services request url 26 | const remoteUrl = `${userConfig.endpoint}/logs/${id}`; 27 | // construct query 28 | const query = {}; 29 | if (follow) { 30 | query.follow = 'true'; 31 | } 32 | // construct shared request params 33 | const options = { 34 | headers: { 35 | Authorization: `Bearer ${userConfig.token}`, 36 | }, 37 | query, 38 | }; 39 | // try sending request 40 | const logStream = got.stream(remoteUrl, options); 41 | logStream.on('error', e => { 42 | // if authorization is expired/broken/etc 43 | if (e.statusCode === 401) { 44 | logout(userConfig); 45 | console.log(chalk.red('Error: authorization expired!'), 'Please, relogin and try again.'); 46 | return; 47 | } 48 | 49 | // if container was not found 50 | if (e.statusCode === 404) { 51 | console.log(chalk.red('Error: container was not found!'), 'Please, check deployment ID and try again.'); 52 | return; 53 | } 54 | 55 | console.log(chalk.red('Error while getting logs:'), e.toString()); 56 | }); 57 | logStream.on('data', buf => { 58 | const d = buf.toString(); 59 | const lines = d.split('\n'); 60 | lines 61 | .map(line => line.replace(/^\u0001.+?(\d)/g, '$1').replace(/\n+$/, '')) 62 | .filter(line => line && line.length > 0) 63 | .map(line => { 64 | if (line.startsWith('Logs for')) { 65 | return {date: null, msg: `${chalk.bold(line)}\n`}; 66 | } 67 | 68 | const parts = line.split(/\dZ\s/); 69 | const date = new Date(parts[0]); 70 | const msg = parts[1]; 71 | return {date, msg}; 72 | }) 73 | .filter(({date, msg}) => date !== undefined && msg !== undefined) 74 | .map(({date, msg}) => ({ 75 | date: date && isFinite(date) ? `${date.toLocaleDateString()} ${date.toLocaleTimeString()}` : ' ', 76 | msg, 77 | })) 78 | .map(({date, msg}) => `${chalk.gray(`${date}`)} ${msg}`) 79 | .forEach(line => console.log(line)); 80 | 81 | resolve(); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /logo/svg/exo_black.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 26 | 31 | 32 | 35 | 37 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /logo/svg/exo_blue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 28 | 33 | 34 | 37 | 39 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /logo/svg/exo_white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 24 | 29 | 30 | 33 | 35 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /test/__snapshots__/secrets.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should create new secret 1`] = ` 4 | Array [ 5 | Array [ 6 | "Generating new deployment secret for:", 7 | "http://localhost:8080", 8 | ], 9 | Array [ 10 | "New secret generated:", 11 | ], 12 | Array [ 13 | "", 14 | ], 15 | Array [ 16 | "Name: test", 17 | ], 18 | Array [ 19 | "Value: 12345", 20 | ], 21 | Array [ 22 | "", 23 | ], 24 | Array [ 25 | "DONE!", 26 | ], 27 | ] 28 | `; 29 | 30 | exports[`Should create new secret non-interactively 1`] = ` 31 | Array [ 32 | Array [ 33 | "Generating new deployment secret for:", 34 | "http://localhost:8080", 35 | ], 36 | Array [ 37 | "New secret generated:", 38 | ], 39 | Array [ 40 | "", 41 | ], 42 | Array [ 43 | "Name: test", 44 | ], 45 | Array [ 46 | "Value: 12345", 47 | ], 48 | Array [ 49 | "", 50 | ], 51 | Array [ 52 | "DONE!", 53 | ], 54 | ] 55 | `; 56 | 57 | exports[`Should deauth on 401 on creation 1`] = ` 58 | Array [ 59 | Array [ 60 | "Generating new deployment secret for:", 61 | "http://localhost:8080", 62 | ], 63 | Array [ 64 | "Error: authorization expired!", 65 | "Please, relogin and try again.", 66 | ], 67 | ] 68 | `; 69 | 70 | exports[`Should deauth on 401 on list 1`] = ` 71 | Array [ 72 | Array [ 73 | "Listing deployment secrets for:", 74 | "http://localhost:8080", 75 | ], 76 | Array [ 77 | "Error: authorization expired!", 78 | "Please, relogin and try again.", 79 | ], 80 | ] 81 | `; 82 | 83 | exports[`Should get secret value 1`] = ` 84 | Array [ 85 | Array [ 86 | "Getting deployment secret for:", 87 | "http://localhost:8080", 88 | ], 89 | Array [ 90 | "New secret generated:", 91 | ], 92 | Array [ 93 | "", 94 | ], 95 | Array [ 96 | "Name: undefined", 97 | ], 98 | Array [ 99 | "Value: undefined", 100 | ], 101 | Array [ 102 | "Date: 2/1/2018, 1:01:01 AM", 103 | ], 104 | ] 105 | `; 106 | 107 | exports[`Should list secrets 1`] = ` 108 | Array [ 109 | Array [ 110 | "Listing deployment secrets for:", 111 | "http://localhost:8080", 112 | ], 113 | Array [ 114 | "Got saved secrets:", 115 | ], 116 | Array [ 117 | "", 118 | ], 119 | Array [ 120 | " > @test []", 121 | ], 122 | ] 123 | `; 124 | 125 | exports[`Should list zero secrets 1`] = ` 126 | Array [ 127 | Array [ 128 | "Listing deployment secrets for:", 129 | "http://localhost:8080", 130 | ], 131 | Array [ 132 | "Got saved secrets:", 133 | ], 134 | Array [ 135 | "", 136 | ], 137 | Array [ 138 | " > No deployment secrets available!", 139 | ], 140 | ] 141 | `; 142 | 143 | exports[`Should remove secret 1`] = ` 144 | Array [ 145 | Array [ 146 | "Removing deployment secret for:", 147 | "http://localhost:8080", 148 | ], 149 | Array [ 150 | "Deployment secret successfully removed!", 151 | ], 152 | ] 153 | `; 154 | -------------------------------------------------------------------------------- /test/fixtures/id_rsa: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIJKAIBAAKCAgEAlHEWRAGzSXyB6C08R0rKim2Ncb5e8MtgCiPTuBdiaqM1uXSG 3 | srsmdd4bZEr7fOQHakzxpqvRgN9vnir9E5ZIN153WOwsByi8E8grAb/rPcfDmqIq 4 | X+8hgfnXKuHBLlrn5u5Qgmlyanf7IH2UK4FU6Cu/lGOZ9wYRmDB1VEXBdXQ+iA5D 5 | jeCN0LfIbT5WaA4IkXgyS4oS5pYQLK3W9CorNsINkGx/vPj7mcHU2viJdI60PxNF 6 | N4DCYaJZuBfXTbl033ccbSJ1HL/xkn7+sRRjS4DePt6F4yLvy+s5KxyAv+Ysl0U6 7 | ArEizaTocbEQQCBdir/JYPA2sViT7BTpENjIvHNeP+DLRx87Mk9HMrbxYeeF19kh 8 | afjYetI8daM3Iyv0GSA6+kbQUVSbrDn3DxvvfLudFuLEP7PZaQVxgO+B2SBYRt3I 9 | LFBP4BkD4e4wxLgKM2IXfKttlSf2Ec91zD69btEz6Ilkp4EYuSPLHxuAqyDEU3Ee 10 | db0ZS0/mxiOrWaEg5/2lccWrUnzExcPDgH2qw15MjSbp3e4AkOcGIsIC5DcEPO0e 11 | loghxqt/ncnBu3VinY9SF/YurBWm46MS3JQSlDJLNgaD4uRvwmb80ezT+Fwsuh1q 12 | /DbczA2ZU/eiGKNkO23BoyE5hH8iClNuMHA7fA+AdguUSznF2x+q3Z+rJIsCAwEA 13 | AQKCAgAWGFsjFkm0h4bio1EUn4pUXoguRRLmrq8F811BSqHIk7CcgT7Hfdn0s3HK 14 | VOroJR0BcqCJmYueriN0HJv8/WKUc0g6gTTwf9y26RWVthZy5Lg13SuHihWnviOL 15 | 63yQxNE9630qXD2+oUtNA8Q5UY/s2w4yLT0uqsWVrKjaOWPZcssX/1+Iz8LsLV70 16 | +9FqvaQC2yxJWC0GC5IuxFV5lIlIJkxUkgHVhA4XTeW8DkCPi+pCdUDZSR4l4D+Q 17 | imPu2ssNtDIUAIwEEoIHt0V74DgDdhQesYqmS338EXA5/Y0gg0dh4NkIq4stpv0+ 18 | ZwevM+IYlAGKn1bUwQWLn3Utn8cJI+6L42hixS8IS+FmQzunilK8rVfBVyhmK6Mv 19 | sD+wfmbZSvxEl7maQ12p9K5Wc46pkkWEo4ZlfwrnbW4H7/iJN7tMYCahWg6ZG3BI 20 | 19EMPNxt6E+Yrx1YAZqOq/rS+1ntwoIImc1XJIwC1P39DjXQ+69waq0Tb4VM5rEu 21 | MgZVTXaFXpmI2xOKAPenfchArrUx8PDA7C7gItxsdAh0DnBSRbcU0Xo1+nXkfC8+ 22 | fnEaSI/oPXb7eN5NarWk2sorNVSJzMuSSzVZZXeby4RZf3KDbotflIvSXEcatV84 23 | RuGcYE1SX+5x3v3FKLH4zxx/u+R72svC+XvB/3KHobjQLLzbCQKCAQEAwy+XLawJ 24 | Jp5h0nUCt2yi+/YQSIYx/IIzjVrV5WOxHrUu8Y1qXA5Ss3KVl/QqTXB6KFJ+t3NG 25 | v0SQO6SQ4h3FaXdpgqMPm4aeHBBnW9+1UX4cqw9sorOa17i5bW6/v95D2C1kf0TB 26 | Y4jmFap2/mjYWl9suNp57TGdUQDhhD5NQtYyIyxMzKvWqRX0gaK90WOffKwJFpac 27 | GUomboYAbMR/o3hR5VnRraZiUZcL49PZ/+hTjBT17Obd5LB4j0VlKTRBJHFlzAbe 28 | ANZfgdErAG2hmccAN5MGlCXFscV3Dadeobr3eFGnbLtaREwtFRqALOe+0S5Zy111 29 | J+02Moe8Mpm7tQKCAQEAwrEXV1fepEyTvMh5TeiNvdAZ3FUG8Jz+mfOeO/dGHl+Y 30 | Yyznb/z2frtOQkTxna5mxc6FJFWyFoDI7cSBC2kIL8rYEcT/S8oX3gAYIa9ME6DT 31 | pRFudrx3utmWq2qCtypMa8qS8ynfZnXwXlSrQfEH+4ZpK6SUvYq3T+hqU0FK79cC 32 | dBNjd6LYDr0OKCC/jRQHMbp//24YZnODrZ5jz9373bMp3hgUEDRn/zhN7+mBXbJS 33 | sHZy/+rslI2QGENnAKTFLQtAtieOYXXR+nMjMGvTrJ7uuhy+p20tcr1UV88hWoBj 34 | LmOuMrsY3vtMuKnZI6Vp95smiBnFyy+3XlZwMWQHPwKCAQEAhYJcCmWOrNfRWb+n 35 | 6AdrRKV/ZdxE/M+iq6HihVG3qaWNQrTUd7tkXlKWeKYO+YFiTYqAGsPDNLmVXvam 36 | E8UBb666gR1AY8WlCNU/ndxbji6RNYELcg8gb0Zvr6b9iiRii9Ag2d0R2x+dIlzD 37 | oCnfM5HqMFdg5xn03Z+YonhrKLiS+vQmZCl2X5og8Tt8U2mtgf27CbRjefj6aMdI 38 | Kx4NoKeAfTz+66TPw7dp7qDOkPk9jKgZv737MZ50GKwDEjgoBuF6HCDmYfRAx+9n 39 | Vpem3iT8xQbQjS15GKxVTwWY+U+GTsqrlvhgOi9Q9wp1ekHaiAMIcM6wgGsFk3K9 40 | DG+TsQKCAQArptUa6kKrB9hgDXrVMv0Ev/HsaswBitGy5uJlg42fJcPfCKRjgY4q 41 | 4Agt55NczUuRsjo76VLipMFoPhTI4CXLwWksosSy038CcXb3pnwiMn8BlaT0zlDx 42 | /fNAP8NLGhrEWEkWaB8EtBDOtaEoSciGZp6SAHaxALcVHYEpZYxNtiGAsRAuNL7I 43 | ny7bZGxOT11FkxE7zg1+ewvdE5RBeP4NuSv79d4ZvAZNPOyMjAhLX74WfphONPgS 44 | XqMeLY6coFTtQoah0MClrWsFAmezZZmyLHhOFj/Q/jOgdD7C6VgLGJokPolCKpzq 45 | 8eFDziE3UtEtiQXcohqs8eflKPBCwrC1AoIBABkBHdv5siKBwV9oyjsjRrZzsA6B 46 | xcWCx56CjWAk/dxUX5YSZZIgADhf1kYLybuoXbvW4LNzLKpr0R7qbKJTClYyF7EQ 47 | isFQlJSEkuKPs1UvzXNnfVV7MHXFcOe9JeorLKU2zVLhoqHZyboLcqUtji65HNcB 48 | mhYfRkYB8B3ZRPcxdUSFHYoqCsgZUn76jMM598mfxr07Qcpr+K1bsDSLrpL0zmZ3 49 | vgajLMZ8mNeuLOdcJ6N2KbaObc41o9d6Jrt582EGbB6m9cDsOxryWLrpJGXj022C 50 | bu9gCxSf0H5YJTKXtkHTg6hBoqa9a+JjtPj164BtMnV0OPRrYUbYgCP4m4s= 51 | -----END RSA PRIVATE KEY----- 52 | -------------------------------------------------------------------------------- /test/fixtures/id_rsa_b: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIJKQIBAAKCAgEA2gAz2aJvUv5nYHmDyh0zyAaQ9BQ6ZH6NELcSNaOi8qYV6AhP 3 | SdCsBfFrwER10N+OS3PfNQQ8kY6fcgO6VhXY9RzQ6+IdTESzoKvL+bOnXbUYdzI9 4 | yoEcrxz37PeKWaYtPCP08DzQkDpUpxoF6tw85/DJCH/Zegw2Hvm4V4l83ZH69lY2 5 | bTWsKj1Il6nY+/4zpB93Jr5Q26G3pRgmrtsx1tLrCd09M1M4mOVU0ar3JoN6F9ES 6 | ldjPs7fo5XJ9cC3ybzd+qw9TpF/i+CpeTEd9xYrbqUx3sfZLp/47LZ0WVEG0ikxt 7 | 8OBeH2+zpg9hQjOsL2y8UgK/QpVr2kljVbfq4aZ5KBnJhsEim98oqHmOWjSlStru 8 | FKEx6dZ04Uw37EpwSVmu4/qZAYfUB+Ytt5Z5ajxiK8Z7iOcpC4GMZdUMQf8J4UIz 9 | 3v5rQim/iKpSgPti8EqRAw44Mb8L7C77WxNOYAvZwj1vGbfartF3VFCV+KiTReSq 10 | QZydZzXDIoxeQOz3SAV1ZNl2MLNeQdTkVChitUlbGeh71ES2JFnrG+2RJ2XV1XCk 11 | rGm3Lqm9vPOVqBNZru1s4zwl5SUziFDyTwI6UNbxHQwuTrGbmVYmCOabE1/cXfRQ 12 | 6b/raHhrnNX4Jm/7d5Htly63HAc9LovWr9epyWENJSQaNv0u6vzq6SKZwScCAwEA 13 | AQKCAgB6QRzS0y5dO8CbsOnAognVBiGqT+HDU9kmY5R5slAChjPI0Ugt3DFsj8xe 14 | ocX0ojp5g/zMDZkuA+7VENQhzNTD+SoM7tyBVhHqbA3S9fZJLfMPfYR7mVaNudAr 15 | +1KCHhhGHuARDfnkuGGa6JQi9unNJRKIirJaqJh9XIwNw1PcKi60kPqq2iu38HBS 16 | ZXUDNLafNuoqNxa/ZPzJQDcJXnGfyN74xDK7ItOdoxajEp7R4W/KBXzHvjQU1HFu 17 | o3tHOu03g8ashcbhasitZY1Gd91HuCyhz/lcOwSIrKFhXjQUgliOKniJWjpOpOT5 18 | nqxrZPEOE4u07kd2PreiTf/AJYk5g3xFPhmgLpYTkC/FVVDaBqu6GqC3H1DJuTfU 19 | Nd/E9YOcWY0ruwC3JeGwZL4nidrS6As8IQrRTdqZAe3Z5372gFXB19kf3IF6/0KK 20 | VpXEDEq3BtuziwK+vie/rnGVT8VUsPahux0QULNPePtcJpiw0DDrNBFLkGBcXSS6 21 | 85uUpKxD4Kjb04At/RTp1/kqFXaxJ0IB1w5wiW9OqX/vM+i4gmnCMZKLm9uQ0BF3 22 | Gc9ep217flgLjIFX2dLy6ZDhL44Z8aAb8jpv3ppkGCx31JNjAUAKNI/qYPSLrWSJ 23 | PHxxWCbi3Pbu6jfE3IYpiKnvfMWOa3H096bVHsefElzHHw9SEQKCAQEA84/IbSCu 24 | OfLL9JZIlMO9EfizqIo+ce0dLKYGit2/DieTlVeBZQ/EngLMbPb71fZp1Qd9DNCa 25 | 61Lm1wOiaWjALuhDHoKWw6+SO7oD8tufvTfpt36LKeVze4MTBM2rh8UikWhtSJiS 26 | ZJ6boLv3XoVKOgb7UClabME/zdiq36xggUOBvRreKqQhsYLZ7CF2+/nDegicf1ua 27 | 6YNrox7aUlnQ9HwaP7D/XOAhOKaFMbJ/NebK94MCMyH4LHnQqLQmQP1lpgyjVINn 28 | DU2/xTBr6X4QnZ9+vSeZZZQyLK87oBeSSpf6/9njEkMVWteAmd4BFSaNCwINIPVj 29 | 3JlkDxJHeTHxHwKCAQEA5SI/jeh8o4jCb7aiB54E0asILGanBYaPSLTNBYQWum5E 30 | vO0wORLzPjDIMVyYb/Cx0+jGnV4ZQVTGhl0iFcUbuAPm6rcyo6MN004xAWFzJ215 31 | Om6f/mnl4kCKm4EDxLe7KQWNFePtVdejr5gd/EcOusP2kf4UxyR07zm2XjKlAsot 32 | r03oiZx6Kxtgdsdu0OSTkW7xZDFB4DX5RVkUBMxqZAtxUhaSSw3PWP0Im0ZWRFyk 33 | utCzJnHqApcCtLu2a+OB07c5YCMyalCv2K91IBXN8xCm/IaROs1B8UR8kEF7lrw5 34 | 1gMZTb4jNzX5EYWAX0apv7aLcF61fDO4Po45OTCG+QKCAQByC45ewuGx0qV7tm7v 35 | 8aNzac++CFVrBQsXH/qKRYCzgQaS9DFrf/Ghx0+vagrLyiEOFf6Q5JDVxbC/Vz1S 36 | PDFZBXqAK8eqg2hmvRgiPIJUmHzAZemFyaHEYMCkDCXYYg9opMwfJQnPQZE/tyQC 37 | R0lVWHWOuH5V1DsrCNwh9dYOUOzL/muu7cG1G78s2RKcs/u7BZM580vbMaVR8R1r 38 | WBEGmaG01w0LkQsdOqO9fIYiWBoLwXVjOfG56aQxioErBzv0f5Bu/0Rer1wvWhf9 39 | cgEXvVob+hHSxYyk7bxunvrqILXv3Td9EppKOgRGh/Rb0fxS/jflieGjptN6VyXO 40 | cvsjAoIBAQCYvoPvbZPo0DoNjonk2goMWx9Puf6NSG4r4ZrqmmssTGW2K8eBxIgy 41 | ZQKPJVLZDHL++KWuMhRT4NeWqm8WZBdeS/ubbBRt0TxeKDmy2euR1QCDW0t8pcuB 42 | T9UWZKLGeFRjvDeY6CBGoUGUxKr4icXf6tJr8ByZxEvPTYGsucYWXgCmZCEn2w/1 43 | vIJJrBjLWBSnVnXEfLGVbWWGPSODL2Gc36PjPPi437PhdoxONk07tSZ7ArTqY/XD 44 | VDeRk7C/qyHYqMOuusfY54DRyeUiRmbMKcWngW0+7aWP2D/RSlXA3ikP6XDsyltf 45 | arNmYsB4wvpVurKpbfk9mpyv1s9+uELBAoIBAQDtc6gzjX16nQguafwUluJacB3z 46 | 4k96lbZQqqs3b08DAGVjxX8HQWl+rqENk2SqlJuBA2WKXaKj5s1jd95+zl84ra3Y 47 | rutC7uTWYXrE51CHW8kjXWrx044GufnvcNHas9QYMwywBtldjidsamsUoqYcK8Ta 48 | qNIWx6m8LWAMEsGug72xPab9QIBzlChjHD4mHCs3iLYzQFMepdBbxO5VB4fZneo0 49 | IchLygR+W/DQMMpOLFJwir9290GyoMPoTTpLSh740C4txjuBzbUs7+An7ASwaN4i 50 | 2MnYgVs3jvz2ehxCYErnOqjs2N3XGYUQBdqXzQvuupNzzeKKDHsKSEHcwj2H 51 | -----END RSA PRIVATE KEY----- 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Exoframe 2 | 3 | > Simple Docker deployment tool 4 | 5 | [![Test Status](https://github.com/exoframejs/exoframe/workflows/Test/badge.svg)](https://github.com/exoframejs/exoframe/actions?query=workflow%3ATest) 6 | [![Coverage Status](https://coveralls.io/repos/github/exoframejs/exoframe/badge.svg?branch=master)](https://coveralls.io/github/exoframejs/exoframe?branch=master) 7 | [![npm](https://img.shields.io/npm/v/exoframe.svg)](https://www.npmjs.com/package/exoframe) 8 | [![license](https://img.shields.io/github/license/mashape/apistatus.svg?maxAge=2592000)](https://opensource.org/licenses/MIT) 9 | 10 | Exoframe is a self-hosted tool that allows simple one-command deployments using Docker. 11 | 12 | ## Features 13 | 14 | - One-command project deployment 15 | - SSH key based auth 16 | - Rolling updates 17 | - Deploy tokens (e.g. to deploy from CI) 18 | - Deploy secrets (e.g. to hide sensitive env vars) 19 | - Automated HTTPS setup via letsencrypt \* 20 | - Automated gzip compression \* 21 | - Rate-limit support \* 22 | - Basic HTTP Auth support \* 23 | - Simple access to the logs of deployments 24 | - Docker-compose support 25 | - Simple function deployments 26 | - Multiple deployment endpoints and multi-user support 27 | - Simple update procedure for client, server and Traefik 28 | - Optional automatic subdomain assignment (i.e. every deployment gets its own subdomain) 29 | - Swarm mode deployments 30 | - Complex recipes support (i.e. deploy complex systems in one command) 31 | 32 | \* Feature provided by [Traefik](https://traefik.io/) 33 | 34 | ## Demo 35 | 36 | [![asciicast](https://asciinema.org/a/129255.png)](https://asciinema.org/a/129255) 37 | 38 | ## Installation and Usage 39 | 40 | To run Exoframe you will need two parts - Exoframe CLI and [Exoframe server](https://github.com/exoframejs/exoframe-server). 41 | For server install instructions see [server installation docs section](docs/ServerInstallation.md). 42 | 43 | To install Exoframe CLI you can either download one of the pre-packaged binaries from [releases page](https://github.com/exoframejs/exoframe/releases) or install it using npm (needs at least Node 8.x): 44 | 45 | ``` 46 | npm install exoframe -g 47 | ``` 48 | 49 | Make sure you have [Exoframe server](https://github.com/exoframejs/exoframe-server) deployed, set it as your endpoint using: 50 | 51 | ``` 52 | exoframe endpoint http://you.server.url 53 | ``` 54 | 55 | Then login using: 56 | 57 | ``` 58 | exoframe login 59 | ``` 60 | 61 | Then you will be able to deploy your projects by simply running: 62 | 63 | ``` 64 | exoframe 65 | ``` 66 | 67 | You can find a list of all commands and options in the [docs](./docs/README.md). 68 | 69 | ## Docs 70 | 71 | - [Basics](docs/Basics.md) 72 | - [Server Installation](docs/ServerInstallation.md) 73 | - [Server Configuration](docs/ServerConfiguration.md) 74 | - [Advanced topics](docs/Advanced.md) 75 | - [Function deployments](docs/Functions.md) 76 | - [FAQ](docs/FAQ.md) 77 | - [Contribution Guidelines](docs/Contributing.md) 78 | - [Templates guide](docs/TemplatesGuide.md) 79 | - [Recipes guide](docs/RecipesGuide.md) 80 | - [Using nightly versions](docs/Nightly.md) 81 | - [Using development and debug versions](docs/Development.md) 82 | - [Tutorials, articles, video and related links](docs/Links.md) 83 | - [Change Log](CHANGELOG.md) 84 | 85 | ## Special thanks 86 | 87 | Thanks to: 88 | 89 | - [Ivan Semenov](https://www.behance.net/ivan_semenov) for making [an awesome logo](./logo/README.md) 90 | 91 | ## License 92 | 93 | Licensed under MIT. 94 | -------------------------------------------------------------------------------- /src/commands/setup.js: -------------------------------------------------------------------------------- 1 | // npm packages 2 | const got = require('got'); 3 | const chalk = require('chalk'); 4 | const inquirer = require('inquirer'); 5 | const ora = require('ora'); 6 | 7 | // our packages 8 | const {userConfig, isLoggedIn, logout} = require('../config'); 9 | 10 | exports.command = ['setup [recipe]']; 11 | exports.describe = 'setup new deployment using recipe'; 12 | exports.builder = { 13 | recipe: { 14 | description: 'Name of the recipe to setup', 15 | }, 16 | verbose: { 17 | alias: 'v', 18 | description: 'Verbose mode; will output more information', 19 | }, 20 | }; 21 | exports.handler = async args => { 22 | if (!isLoggedIn()) { 23 | return; 24 | } 25 | 26 | // services request url 27 | const remoteUrl = `${userConfig.endpoint}/setup`; 28 | // get command 29 | const {verbose, recipe} = args; 30 | // construct shared request params 31 | const baseOptions = { 32 | headers: { 33 | Authorization: `Bearer ${userConfig.token}`, 34 | }, 35 | responseType: 'json', 36 | }; 37 | 38 | console.log(chalk.bold('Setting new deployment using recipe at:'), userConfig.endpoint); 39 | 40 | // get recipe name from params 41 | let recipeName = recipe; 42 | 43 | // ask for Recipe name if not given 44 | if (!recipeName) { 45 | const prompts = []; 46 | prompts.push({ 47 | type: 'input', 48 | name: 'givenRecipeName', 49 | message: 'Recipe name:', 50 | }); 51 | const {givenRecipeName} = await inquirer.prompt(prompts); 52 | recipeName = givenRecipeName; 53 | } 54 | 55 | // ask for questions for this recipe 56 | const options = Object.assign({}, baseOptions, { 57 | method: 'GET', 58 | searchParams: { 59 | recipeName, 60 | }, 61 | responseType: 'json', 62 | }); 63 | 64 | // show loader 65 | const spinner = ora('Installing new recipe...').start(); 66 | 67 | // try sending request 68 | try { 69 | const {body} = await got(remoteUrl, options); 70 | const showLog = verbose || !body.success; 71 | if (showLog) { 72 | console.log(''); 73 | console.log('Log:'); 74 | (body.log || ['No log available']) 75 | .filter(l => l !== undefined) 76 | .filter(l => l && l.message && l.message.length > 0) 77 | .forEach(line => console.log(line.message.trim())); 78 | } 79 | if (!body.success) { 80 | spinner.fail('Error installing new recipe!'); 81 | return; 82 | } 83 | 84 | spinner.succeed('New recipe installed! Preparing setup..'); 85 | 86 | // get questions from body 87 | const {questions} = body; 88 | // ask user to answer 89 | const answers = await inquirer.prompt(questions); 90 | 91 | // show loader 92 | spinner.start('Executing recipe with user configuration...'); 93 | 94 | // send answers and execute recipe 95 | const answerOptions = Object.assign({}, baseOptions, { 96 | method: 'POST', 97 | json: { 98 | recipeName, 99 | answers, 100 | }, 101 | responseType: 'json', 102 | }); 103 | 104 | const {body: finalBody} = await got(remoteUrl, answerOptions); 105 | const showFullSetupLog = verbose || !finalBody.success; 106 | console.log(''); 107 | showFullSetupLog ? console.log('Log:') : console.log(''); 108 | (finalBody.log || [{message: 'No log available', level: 'debug'}]) 109 | .filter(l => l !== undefined) 110 | .filter(l => showFullSetupLog || l.level === 'info') 111 | .filter(l => l && l.message && l.message.length > 0) 112 | .forEach(line => console.log(line.message.trim())); 113 | console.log(''); 114 | 115 | if (!finalBody.success) { 116 | spinner.fail('Error executing recipe!'); 117 | return; 118 | } 119 | 120 | spinner.succeed('Recipe successfully executed!'); 121 | } catch (e) { 122 | spinner.fail('Recipe execution failed!'); 123 | // if authorization is expired/broken/etc 124 | if (e.response.statusCode === 401) { 125 | logout(userConfig); 126 | console.log(chalk.red('Error: authorization expired!'), 'Please, relogin and try again.'); 127 | return; 128 | } 129 | 130 | console.log(chalk.red('Error executing deployment recipe:'), e.toString()); 131 | } 132 | }; 133 | -------------------------------------------------------------------------------- /test/setup.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | // mock config for testing 3 | jest.mock('../src/config', () => require('./__mocks__/config')); 4 | 5 | // npm packages 6 | const nock = require('nock'); 7 | const sinon = require('sinon'); 8 | const inquirer = require('inquirer'); 9 | 10 | // our packages 11 | const {handler: setup} = require('../src/commands/setup'); 12 | const cfg = require('../src/config'); 13 | 14 | // questions mock 15 | const questions = [ 16 | { 17 | type: 'input', 18 | name: 'test1', 19 | message: 'Test q1:', 20 | }, 21 | { 22 | type: 'input', 23 | name: 'test2', 24 | message: 'Test q2:', 25 | }, 26 | ]; 27 | 28 | // test generation 29 | test('Should execute new setup', done => { 30 | // handle correct request 31 | const setupServerGet = nock('http://localhost:8080') 32 | .get('/setup') 33 | .query({recipeName: 'test'}) 34 | .reply(200, {success: 'true', questions, log: ['1', '2', '3']}); 35 | const setupServerPost = nock('http://localhost:8080') 36 | .post('/setup') 37 | .reply(200, { 38 | success: 'true', 39 | log: [ 40 | {message: '1', level: 'info'}, 41 | {message: '2', level: 'info'}, 42 | {message: '3', level: 'debug'}, 43 | ], 44 | }); 45 | // spy on console 46 | const consoleSpy = sinon.spy(console, 'log'); 47 | // stup inquirer answers 48 | sinon 49 | .stub(inquirer, 'prompt') 50 | .onFirstCall() 51 | .callsFake(() => Promise.resolve({givenRecipeName: 'test'})) 52 | .onSecondCall() 53 | .callsFake(() => Promise.resolve({test1: 'answer1', test2: 'answer2'})); 54 | // execute login 55 | setup({}).then(() => { 56 | // make sure log in was successful 57 | // check that server was called 58 | expect(setupServerGet.isDone()).toBeTruthy(); 59 | expect(setupServerPost.isDone()).toBeTruthy(); 60 | // first check console output 61 | expect(consoleSpy.args).toMatchSnapshot(); 62 | // restore console 63 | console.log.restore(); 64 | // restore inquirer 65 | inquirer.prompt.restore(); 66 | // tear down nock 67 | setupServerGet.done(); 68 | setupServerPost.done(); 69 | done(); 70 | }); 71 | }); 72 | 73 | // test deauth 74 | test('Should deauth on 401 on questions list', done => { 75 | // save config for restoration 76 | cfg.__save('template'); 77 | // handle correct request 78 | const setupServer = nock('http://localhost:8080').get('/setup').query(true).reply(401); 79 | // spy on console 80 | const consoleSpy = sinon.spy(console, 'log'); 81 | // stup inquirer answers 82 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve({givenRecipeName: 'test'})); 83 | // execute login 84 | setup({}).then(() => { 85 | // make sure log in was successful 86 | // check that server was called 87 | expect(setupServer.isDone()).toBeTruthy(); 88 | // first check console output 89 | expect(consoleSpy.args).toMatchSnapshot(); 90 | // restore console 91 | console.log.restore(); 92 | // restore inquirer 93 | inquirer.prompt.restore(); 94 | // tear down nock 95 | setupServer.done(); 96 | done(); 97 | }); 98 | }); 99 | 100 | test('Should deauth on 401 on list', done => { 101 | // restore original config 102 | cfg.__restore('template'); 103 | // handle correct request 104 | const recipeServerGet = nock('http://localhost:8080') 105 | .get('/setup') 106 | .query(true) 107 | .reply(200, {success: 'true', questions, log: ['1', '2', '3']}); 108 | const recipeServerPost = nock('http://localhost:8080').post('/setup').reply(401); 109 | // spy on console 110 | const consoleSpy = sinon.spy(console, 'log'); 111 | // stup inquirer answers 112 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve({givenRecipeName: 'test'})); 113 | // execute login 114 | setup({cmd: 'ls'}).then(() => { 115 | // make sure log in was successful 116 | // check that server was called 117 | expect(recipeServerGet.isDone()).toBeTruthy(); 118 | expect(recipeServerPost.isDone()).toBeTruthy(); 119 | // first check console output 120 | expect(consoleSpy.args).toMatchSnapshot(); 121 | // restore console 122 | console.log.restore(); 123 | // restore inquirer 124 | inquirer.prompt.restore(); 125 | // tear down nock 126 | recipeServerGet.done(); 127 | recipeServerPost.done(); 128 | done(); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | env: 9 | YARN_CACHE_FOLDER: ~/.yarn 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/cache@v1 17 | with: 18 | path: ${{ env.YARN_CACHE_FOLDER }} 19 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 20 | restore-keys: | 21 | ${{ runner.os }}-yarn- 22 | - uses: actions/setup-node@v1 23 | with: 24 | node-version: '12.x' 25 | registry-url: https://registry.npmjs.org 26 | - name: install 27 | run: yarn --frozen-lockfile 28 | 29 | # lint, test, report coverage 30 | - name: lint 31 | run: yarn lint 32 | - name: test 33 | run: yarn test 34 | 35 | # upload coverage 36 | - name: Coveralls 37 | uses: coverallsapp/github-action@master 38 | with: 39 | github-token: ${{ secrets.GITHUB_TOKEN }} 40 | 41 | publish: 42 | needs: test 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v2 46 | - uses: actions/cache@v1 47 | with: 48 | path: ${{ env.YARN_CACHE_FOLDER }} 49 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 50 | restore-keys: | 51 | ${{ runner.os }}-yarn- 52 | - uses: actions/setup-node@v1 53 | with: 54 | node-version: '12.x' 55 | registry-url: https://registry.npmjs.org 56 | 57 | # build 58 | - name: install 59 | run: yarn --frozen-lockfile 60 | - name: build 61 | run: yarn build 62 | - name: package 63 | run: yarn package 64 | 65 | # deploy to npm 66 | - run: npm publish --access public 67 | env: 68 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 69 | 70 | # deploy to new github release 71 | - name: Create Release 72 | id: create_release 73 | uses: actions/create-release@v1 74 | env: 75 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 76 | with: 77 | tag_name: ${{ github.ref }} 78 | release_name: Release ${{ github.ref }} 79 | draft: false 80 | prerelease: false 81 | - name: Publish linux binaries as release assets 82 | id: upload-linux-release-asset 83 | uses: actions/upload-release-asset@v1 84 | env: 85 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 86 | with: 87 | upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps 88 | asset_path: ./exoframe-linux 89 | asset_name: exoframe-linux 90 | asset_content_type: application/binary 91 | - name: Publish macos binaries as release assets 92 | id: upload-mac-release-asset 93 | uses: actions/upload-release-asset@v1 94 | env: 95 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 96 | with: 97 | upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps 98 | asset_path: ./exoframe-macos 99 | asset_name: exoframe-macos 100 | asset_content_type: application/binary 101 | - name: Publish windows binaries as release assets 102 | id: upload-win-release-asset 103 | uses: actions/upload-release-asset@v1 104 | env: 105 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 106 | with: 107 | upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps 108 | asset_path: ./exoframe-win.exe 109 | asset_name: exoframe-win.exe 110 | asset_content_type: application/binary 111 | -------------------------------------------------------------------------------- /test/list.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | // mock config for testing 3 | jest.mock('../src/config', () => require('./__mocks__/config')); 4 | 5 | // npm packages 6 | const nock = require('nock'); 7 | const sinon = require('sinon'); 8 | 9 | // our packages 10 | const {handler: list} = require('../src/commands/list'); 11 | 12 | const containers = [ 13 | { 14 | Id: '123', 15 | Name: '/test', 16 | Config: { 17 | Labels: { 18 | 'traefik.http.routers.test.rule': 'Host(`test.host`)', 19 | 'exoframe.deployment': 'test', 20 | 'exoframe.project': 'test', 21 | }, 22 | }, 23 | State: { 24 | Status: 'Up 10 minutes', 25 | }, 26 | NetworkSettings: { 27 | Networks: { 28 | exoframe: { 29 | Aliases: null, 30 | }, 31 | }, 32 | }, 33 | }, 34 | { 35 | Id: '321', 36 | Name: '/test2', 37 | Config: { 38 | Labels: {'exoframe.project': 'test'}, 39 | }, 40 | State: { 41 | Status: 'Up 12 minutes', 42 | }, 43 | NetworkSettings: { 44 | Networks: { 45 | exoframe: { 46 | Aliases: null, 47 | }, 48 | }, 49 | }, 50 | }, 51 | { 52 | Id: '111', 53 | Name: '/test3', 54 | Config: { 55 | Labels: {'exoframe.project': 'other'}, 56 | }, 57 | State: { 58 | Status: 'Up 13 minutes', 59 | }, 60 | NetworkSettings: { 61 | Networks: { 62 | exoframe: { 63 | Aliases: null, 64 | }, 65 | }, 66 | }, 67 | }, 68 | { 69 | Id: '444', 70 | Name: '/test4', 71 | Config: { 72 | Labels: {'exoframe.project': 'somethingelse'}, 73 | }, 74 | State: { 75 | Status: 'Up 10 minutes', 76 | }, 77 | NetworkSettings: { 78 | Networks: { 79 | default: { 80 | Aliases: null, 81 | }, 82 | traefik: { 83 | Aliases: ['alias4'], 84 | }, 85 | }, 86 | }, 87 | }, 88 | ]; 89 | 90 | const services = [ 91 | { 92 | ID: '12345', 93 | Spec: { 94 | Name: 'test-service-one', 95 | Labels: { 96 | 'exoframe.project': 'test-service', 97 | }, 98 | Networks: [ 99 | { 100 | Target: 'netid', 101 | }, 102 | ], 103 | }, 104 | }, 105 | { 106 | ID: '0987', 107 | Spec: { 108 | Name: 'test-service-two', 109 | Labels: { 110 | 'exoframe.project': 'test-service', 111 | 'exoframe.deployment': 'test-service-two', 112 | 'traefik.http.routers.test-service-two.rule': 'Host(`test.host`)', 113 | }, 114 | Networks: [ 115 | { 116 | Target: 'netid', 117 | Aliases: ['test.host'], 118 | }, 119 | ], 120 | }, 121 | }, 122 | { 123 | ID: '321', 124 | Spec: { 125 | Name: 'test-service-three', 126 | Labels: { 127 | 'exoframe.project': 'test-project', 128 | 'exoframe.deployment': 'test-service-three', 129 | 'traefik.http.routers.test-service-three.rule': 'Host(`other.domain`)', 130 | }, 131 | Networks: [ 132 | { 133 | Target: 'netid', 134 | }, 135 | ], 136 | }, 137 | }, 138 | ]; 139 | 140 | // test list 141 | test('Should get list of deployments', done => { 142 | // handle correct request 143 | const listServer = nock('http://localhost:8080').get(`/list`).reply(200, {containers}); 144 | // spy on console 145 | const consoleSpy = sinon.spy(console, 'log'); 146 | // execute login 147 | list().then(() => { 148 | // make sure log in was successful 149 | // check that server was called 150 | expect(listServer.isDone()).toBeTruthy(); 151 | // first check console output 152 | expect(consoleSpy.args).toMatchSnapshot(); 153 | // restore console 154 | console.log.restore(); 155 | listServer.done(); 156 | done(); 157 | }); 158 | }); 159 | 160 | // test swarm list 161 | test('Should get list of swarm deployments', done => { 162 | // handle correct request 163 | const listServer = nock('http://localhost:8080').get(`/list`).reply(200, {services}); 164 | // spy on console 165 | const consoleSpy = sinon.spy(console, 'log'); 166 | // execute login 167 | list().then(() => { 168 | // make sure log in was successful 169 | // check that server was called 170 | expect(listServer.isDone()).toBeTruthy(); 171 | // first check console output 172 | expect(consoleSpy.args).toMatchSnapshot(); 173 | // restore console 174 | console.log.restore(); 175 | listServer.done(); 176 | done(); 177 | }); 178 | }); 179 | -------------------------------------------------------------------------------- /src/commands/login.js: -------------------------------------------------------------------------------- 1 | // npm packages 2 | const fs = require('fs'); 3 | const os = require('os'); 4 | const path = require('path'); 5 | const chalk = require('chalk'); 6 | const got = require('got'); 7 | const inquirer = require('inquirer'); 8 | const jwt = require('jsonwebtoken'); 9 | 10 | // our packages 11 | const {userConfig, updateConfig} = require('../config'); 12 | const {handler: endpointHandler} = require('./endpoint'); 13 | 14 | const validate = input => input && input.length > 0; 15 | const filter = input => input.trim(); 16 | 17 | exports.command = 'login [url]'; 18 | exports.describe = 'login into exoframe server'; 19 | exports.builder = { 20 | key: { 21 | alias: 'k', 22 | description: 'User private key used for authentication', 23 | }, 24 | passphrase: { 25 | alias: 'p', 26 | description: 'Passphrase for user private key (if set)', 27 | }, 28 | url: { 29 | alias: 'u', 30 | default: '', 31 | description: 'URL of a new endpoint', 32 | }, 33 | }; 34 | exports.handler = async ({key, passphrase, url}) => { 35 | if (url && url.length) { 36 | await endpointHandler({url}); 37 | } 38 | 39 | console.log(chalk.bold('Logging in to:'), userConfig.endpoint); 40 | 41 | // get user private keys 42 | const noKey = !key || !key.length; 43 | let privateKeys = []; 44 | const sshFolder = path.join(os.homedir(), '.ssh'); 45 | if (noKey) { 46 | try { 47 | const allFiles = fs.readdirSync(sshFolder); 48 | const filterOut = ['authorized_keys', 'config', 'known_hosts']; 49 | privateKeys = allFiles.filter(f => !f.endsWith('.pub') && !filterOut.includes(f)); 50 | } catch (e) { 51 | console.log(chalk.red('Error logging in!'), 'Default folder (~/.ssh) with private keys does not exists!'); 52 | return; 53 | } 54 | } 55 | 56 | // generate and show choices 57 | const prompts = []; 58 | prompts.push({ 59 | type: 'input', 60 | name: 'username', 61 | message: 'Username:', 62 | validate, 63 | filter, 64 | }); 65 | // only ask for key if no user key given 66 | if (noKey) { 67 | prompts.push({ 68 | type: 'list', 69 | name: 'privateKeyName', 70 | message: 'Private key:', 71 | choices: privateKeys, 72 | }); 73 | prompts.push({ 74 | type: 'password', 75 | name: 'password', 76 | message: 'Private key passpharse (leave blank if not set):', 77 | }); 78 | } 79 | 80 | // get username, key filename, password and generate key path 81 | const {username, privateKeyName, password: userPass} = await inquirer.prompt(prompts); 82 | const password = passphrase || userPass; 83 | const privateKey = noKey ? path.join(sshFolder, privateKeyName) : key; 84 | 85 | // generate login url 86 | const remoteUrl = `${userConfig.endpoint}/login`; 87 | 88 | // get login request phrase and ID from server 89 | let phrase; 90 | let loginReqId; 91 | try { 92 | const {body} = await got(remoteUrl, {responseType: 'json'}); 93 | phrase = body.phrase; 94 | loginReqId = body.uid; 95 | if (!phrase || !loginReqId) { 96 | console.log( 97 | chalk.red('Error logging in!'), 98 | 'Error getting login request phrase. Server did not return correct values!' 99 | ); 100 | return; 101 | } 102 | } catch (e) { 103 | console.log( 104 | chalk.red('Error logging in!'), 105 | 'Error getting login request phrase. Make sure your endpoint is correct!', 106 | e.toString() 107 | ); 108 | return; 109 | } 110 | 111 | // generate login token based on phrase from server 112 | let reqToken; 113 | try { 114 | const cert = 115 | password && password.length 116 | ? {key: fs.readFileSync(privateKey), passphrase: password} 117 | : fs.readFileSync(privateKey); 118 | reqToken = jwt.sign(phrase, cert, {algorithm: 'RS256'}); 119 | } catch (e) { 120 | console.log( 121 | chalk.red('Error logging in!'), 122 | 'Error generating login token! Make sure your private key password is correct', 123 | e.toString() 124 | ); 125 | return; 126 | } 127 | 128 | if (!reqToken) { 129 | console.log( 130 | chalk.red('Error logging in!'), 131 | 'Error generating login token! Something went wrong, please try again.' 132 | ); 133 | return; 134 | } 135 | 136 | // send login request 137 | try { 138 | const user = {username}; 139 | const {body} = await got(remoteUrl, { 140 | method: 'POST', 141 | json: { 142 | user, 143 | token: reqToken, 144 | requestId: loginReqId, 145 | }, 146 | responseType: 'json', 147 | }); 148 | // check for errors 149 | if (!body || !body.token) { 150 | throw new Error('Error logging in!'); 151 | } 152 | updateConfig(Object.assign(body, {user})); 153 | console.log(chalk.green('Successfully logged in!')); 154 | } catch (e) { 155 | console.log(chalk.red('Error logging in!'), 'Check your username and password and try again.', e.toString()); 156 | } 157 | }; 158 | -------------------------------------------------------------------------------- /src/commands/token.js: -------------------------------------------------------------------------------- 1 | // npm packages 2 | const got = require('got'); 3 | const chalk = require('chalk'); 4 | const inquirer = require('inquirer'); 5 | 6 | // our packages 7 | const {userConfig, isLoggedIn, logout} = require('../config'); 8 | 9 | exports.command = ['token [cmd]']; 10 | exports.describe = 'generate, list or remove deployment token'; 11 | exports.builder = { 12 | cmd: { 13 | default: '', 14 | description: 'command to execute [ls | rm]', 15 | }, 16 | }; 17 | exports.handler = async args => { 18 | if (!isLoggedIn()) { 19 | return; 20 | } 21 | 22 | // services request url 23 | const remoteUrl = `${userConfig.endpoint}/deployToken`; 24 | // get command 25 | const {cmd} = args; 26 | // if remove or ls - fetch tokens from remote, then do work 27 | if (cmd === 'ls' || cmd === 'rm') { 28 | console.log( 29 | chalk.bold(`${cmd === 'ls' ? 'Listing' : 'Removing'} deployment token${cmd === 'ls' ? 's' : ''} for:`), 30 | userConfig.endpoint 31 | ); 32 | 33 | // get tokens from server 34 | // construct shared request params 35 | const options = { 36 | method: 'GET', 37 | headers: { 38 | Authorization: `Bearer ${userConfig.token}`, 39 | }, 40 | responseType: 'json', 41 | }; 42 | // try sending request 43 | let tokens = []; 44 | try { 45 | const {body} = await got(remoteUrl, options); 46 | tokens = body.tokens; 47 | } catch (e) { 48 | // if authorization is expired/broken/etc 49 | if (e.response.statusCode === 401) { 50 | logout(userConfig); 51 | console.log(chalk.red('Error: authorization expired!'), 'Please, relogin and try again.'); 52 | return; 53 | } 54 | 55 | console.log(chalk.red('Error getting deployment tokens:'), e.toString()); 56 | return; 57 | } 58 | 59 | if (cmd === 'ls') { 60 | console.log(chalk.bold('Got generated tokens:')); 61 | console.log(''); 62 | tokens.map(t => 63 | console.log(` > ${chalk.green(t.tokenName)} ${chalk.gray(`[${new Date(t.meta.created).toLocaleString()}]`)}`) 64 | ); 65 | if (!tokens.length) { 66 | console.log(' > No deployment tokens available!'); 67 | } 68 | return; 69 | } 70 | 71 | const prompts = []; 72 | prompts.push({ 73 | type: 'list', 74 | name: 'rmToken', 75 | message: 'Choose token to remove:', 76 | choices: tokens.map(t => t.tokenName), 77 | }); 78 | const {rmToken} = await inquirer.prompt(prompts); 79 | 80 | // construct shared request params 81 | const rmOptions = { 82 | method: 'DELETE', 83 | headers: { 84 | Authorization: `Bearer ${userConfig.token}`, 85 | }, 86 | responseType: 'json', 87 | json: { 88 | tokenName: rmToken, 89 | }, 90 | }; 91 | try { 92 | const {body, statusCode} = await got(remoteUrl, rmOptions); 93 | if (statusCode !== 204) { 94 | console.log(chalk.red('Error removing deployment token!'), body.reason || 'Please try again!'); 95 | return; 96 | } 97 | console.log(chalk.green('Deployment token successfully removed!')); 98 | } catch (e) { 99 | // if authorization is expired/broken/etc 100 | if (e.response.statusCode === 401) { 101 | logout(userConfig); 102 | console.log(chalk.red('Error: authorization expired!'), 'Please, relogin and try again.'); 103 | return; 104 | } 105 | 106 | console.log(chalk.red('Error removing token:'), e.toString()); 107 | return; 108 | } 109 | 110 | return; 111 | } 112 | 113 | console.log(chalk.bold('Generating new deployment token for:'), userConfig.endpoint); 114 | 115 | // ask for token name 116 | const prompts = []; 117 | prompts.push({ 118 | type: 'input', 119 | name: 'tokenName', 120 | message: 'Token name:', 121 | validate: input => input && input.length > 0, 122 | filter: input => input.trim(), 123 | }); 124 | const {tokenName} = await inquirer.prompt(prompts); 125 | 126 | // construct shared request params 127 | const options = { 128 | method: 'POST', 129 | headers: { 130 | Authorization: `Bearer ${userConfig.token}`, 131 | }, 132 | responseType: 'json', 133 | json: { 134 | tokenName, 135 | }, 136 | }; 137 | // try sending request 138 | try { 139 | const {body} = await got(remoteUrl, options); 140 | const {token} = body; 141 | console.log(chalk.bold('New token generated:')); 142 | console.log(''); 143 | console.log(token); 144 | console.log(''); 145 | console.log(chalk.yellow('WARNING!'), 'Make sure to write it down, you will not be able to get it again!'); 146 | } catch (e) { 147 | // if authorization is expired/broken/etc 148 | if (e.response.statusCode === 401) { 149 | logout(userConfig); 150 | console.log(chalk.red('Error: authorization expired!'), 'Please, relogin and try again.'); 151 | return; 152 | } 153 | 154 | console.log(chalk.red('Error generating deployment token:'), e.toString()); 155 | } 156 | }; 157 | -------------------------------------------------------------------------------- /test/remove.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | // mock config for testing 3 | jest.mock('../src/config', () => require('./__mocks__/config')); 4 | 5 | // npm packages 6 | const nock = require('nock'); 7 | const sinon = require('sinon'); 8 | 9 | // our packages 10 | const {handler: remove} = require('../src/commands/remove'); 11 | const cfg = require('../src/config'); 12 | 13 | const id = 'test-id'; 14 | const url = 'test.example.com'; 15 | 16 | // test removal 17 | test('Should remove', done => { 18 | // handle correct request 19 | const rmServer = nock('http://localhost:8080').post(`/remove/${id}`).reply(204); 20 | // spy on console 21 | const consoleSpy = sinon.spy(console, 'log'); 22 | // execute login 23 | remove({id}).then(() => { 24 | // make sure log in was successful 25 | // check that server was called 26 | expect(rmServer.isDone()).toBeTruthy(); 27 | // first check console output 28 | expect(consoleSpy.args).toMatchSnapshot(); 29 | // restore console 30 | console.log.restore(); 31 | rmServer.done(); 32 | done(); 33 | }); 34 | }); 35 | 36 | test('Should remove by url', done => { 37 | const rmServer = nock('http://localhost:8080').post(`/remove/${url}`).reply(204); 38 | 39 | const consoleSpy = sinon.spy(console, 'log'); 40 | 41 | remove({id: url}).then(() => { 42 | // make sure log in was successful 43 | // check that server was called 44 | expect(rmServer.isDone()).toBeTruthy(); 45 | // first check console output 46 | expect(consoleSpy.args).toMatchSnapshot(); 47 | // restore console 48 | console.log.restore(); 49 | rmServer.done(); 50 | done(); 51 | }); 52 | }); 53 | 54 | test('Should remove by token instead of default auth', done => { 55 | const rmServer = nock('http://localhost:8080').post(`/remove/${id}`).reply(204); 56 | 57 | const consoleSpy = sinon.spy(console, 'log'); 58 | 59 | remove({id: id, token: 'test-token'}).then(() => { 60 | // make sure log in was successful 61 | // check that server was called 62 | expect(rmServer.isDone()).toBeTruthy(); 63 | // first check console output 64 | expect(consoleSpy.args).toMatchSnapshot(); 65 | // restore console 66 | console.log.restore(); 67 | rmServer.done(); 68 | done(); 69 | }); 70 | }); 71 | 72 | // test removal error 73 | test('Should show remove error', done => { 74 | // handle correct request 75 | const rmServer = nock('http://localhost:8080').post(`/remove/${id}`).reply(500); 76 | // spy on console 77 | const consoleSpy = sinon.spy(console, 'log'); 78 | // execute login 79 | remove({id}).then(() => { 80 | // make sure log in was successful 81 | // check that server was called 82 | expect(rmServer.isDone()).toBeTruthy(); 83 | // first check console output 84 | expect(consoleSpy.args).toMatchSnapshot(); 85 | // restore console 86 | console.log.restore(); 87 | rmServer.done(); 88 | done(); 89 | }); 90 | }); 91 | 92 | // test removal error 93 | test('Should show not found error', done => { 94 | // handle correct request 95 | const rmServer = nock('http://localhost:8080').post(`/remove/${id}`).reply(404); 96 | // spy on console 97 | const consoleSpy = sinon.spy(console, 'log'); 98 | // execute login 99 | remove({id}).then(() => { 100 | // make sure log in was successful 101 | // check that server was called 102 | expect(rmServer.isDone()).toBeTruthy(); 103 | // first check console output 104 | expect(consoleSpy.args).toMatchSnapshot(); 105 | // restore console 106 | console.log.restore(); 107 | rmServer.done(); 108 | done(); 109 | }); 110 | }); 111 | 112 | // test removal error on incorrect success code 113 | test('Should show not found error', done => { 114 | // handle correct request 115 | const rmServer = nock('http://localhost:8080').post(`/remove/${id}`).reply(200); 116 | // spy on console 117 | const consoleSpy = sinon.spy(console, 'log'); 118 | // execute login 119 | remove({id}).then(() => { 120 | // make sure log in was successful 121 | // check that server was called 122 | expect(rmServer.isDone()).toBeTruthy(); 123 | // first check console output 124 | expect(consoleSpy.args).toMatchSnapshot(); 125 | // restore console 126 | console.log.restore(); 127 | rmServer.done(); 128 | done(); 129 | }); 130 | }); 131 | 132 | // test 133 | test('Should deauth on 401', done => { 134 | // handle correct request 135 | const rmServer = nock('http://localhost:8080').post(`/remove/${id}`).reply(401); 136 | // spy on console 137 | const consoleSpy = sinon.spy(console, 'log'); 138 | // execute login 139 | remove({id}).then(() => { 140 | // make sure log in was successful 141 | // check that server was called 142 | expect(rmServer.isDone()).toBeTruthy(); 143 | // first check console output 144 | expect(consoleSpy.args).toMatchSnapshot(); 145 | // check config 146 | expect(cfg.userConfig.user).toBeUndefined(); 147 | expect(cfg.userConfig.token).toBeUndefined(); 148 | // restore console 149 | console.log.restore(); 150 | // tear down nock 151 | rmServer.done(); 152 | done(); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /src/commands/update.js: -------------------------------------------------------------------------------- 1 | // npm packages 2 | const _ = require('lodash'); 3 | const got = require('got'); 4 | const chalk = require('chalk'); 5 | const ora = require('ora'); 6 | const inquirer = require('inquirer'); 7 | 8 | // our packages 9 | const {userConfig, isLoggedIn, logout} = require('../config'); 10 | 11 | // valid targets list 12 | const validTargets = ['traefik', 'server', 'all']; 13 | // construct shared request params 14 | const options = { 15 | headers: { 16 | Authorization: `Bearer ${userConfig.token}`, 17 | }, 18 | responseType: 'json', 19 | }; 20 | 21 | const runUpdate = async target => { 22 | console.log(chalk.bold(`Updating ${target} on:`), userConfig.endpoint); 23 | 24 | // services request url 25 | const remoteUrl = `${userConfig.endpoint}/update/${target}`; 26 | // try sending request 27 | try { 28 | const {body, statusCode} = await got.post(remoteUrl, {...options, json: {}}); 29 | if (statusCode !== 200 || body.error) { 30 | throw new Error(body.error || 'Oops. Something went wrong! Try again please.'); 31 | } 32 | 33 | if (body.updated) { 34 | console.log(chalk.green(`Successfully updated ${target}!`)); 35 | return; 36 | } 37 | 38 | console.log(chalk.green(`${_.capitalize(target)} is already up to date!`)); 39 | } catch (e) { 40 | // if authorization is expired/broken/etc 41 | if (e.response.statusCode === 401) { 42 | logout(userConfig); 43 | console.log(chalk.red('Error: authorization expired!'), 'Please, relogin and try again.'); 44 | return; 45 | } 46 | 47 | const reason = e.response.body && e.response.body.error ? e.response.body.error : e.toString(); 48 | console.log(chalk.red(`Error updating ${target}:`), reason); 49 | console.log('Update log:\n'); 50 | (e.response.body.log || 'No log available') 51 | .split('\n') 52 | .map(l => { 53 | try { 54 | return JSON.parse(l); 55 | } catch (e) { 56 | return l; 57 | } 58 | }) 59 | .filter(l => l !== undefined) 60 | .map(l => l.trim()) 61 | .filter(l => l && l.length > 0) 62 | .forEach(line => console.log(line)); 63 | } 64 | }; 65 | 66 | exports.command = ['update [target]']; 67 | exports.describe = 'check for updates or update given target'; 68 | exports.builder = { 69 | target: { 70 | alias: 't', 71 | description: `Target for update (${validTargets.join(', ')})`, 72 | }, 73 | }; 74 | exports.handler = async ({target}) => { 75 | if (!isLoggedIn()) { 76 | return; 77 | } 78 | 79 | // if no target given - check for update 80 | if (!target || !target.length) { 81 | // show loader 82 | const spinner = ora('Checking for update...').start(); 83 | 84 | // services request url 85 | const remoteUrl = `${userConfig.endpoint}/version`; 86 | // send request 87 | const {body, statusCode} = await got.get(remoteUrl, options); 88 | if (statusCode !== 200 || body.error) { 89 | spinner.fail('Error checking for update'); 90 | console.log(body.error || 'Oops. Something went wrong! Try again please.'); 91 | return; 92 | } 93 | 94 | if (body.serverUpdate || body.traefikUpdate) { 95 | spinner.warn('Updates available!'); 96 | } else { 97 | spinner.succeed('You are up to date!'); 98 | } 99 | 100 | console.log(); 101 | console.log(chalk.bold('Exoframe Server:')); 102 | console.log(` current: ${body.server}`); 103 | console.log(` latest: ${body.latestServer}`); 104 | console.log(); 105 | console.log(chalk.bold('Traefik:')); 106 | console.log(` current: ${body.traefik}`); 107 | console.log(` latest: ${body.latestTraefik}`); 108 | console.log(); 109 | 110 | // if updates are available - ask user if he want them immediately 111 | if (!body.serverUpdate && !body.traefikUpdate) { 112 | return; 113 | } 114 | 115 | const prompts = []; 116 | if (body.serverUpdate) { 117 | prompts.push({ 118 | type: 'confirm', 119 | name: 'upServer', 120 | message: 'Update server now?', 121 | default: true, 122 | }); 123 | } 124 | 125 | if (body.traefikUpdate) { 126 | prompts.push({ 127 | type: 'confirm', 128 | name: 'upTraefik', 129 | message: 'Update Traefik now?', 130 | default: true, 131 | }); 132 | } 133 | const {upServer, upTraefik} = await inquirer.prompt(prompts); 134 | // if user doesn't want update - just exit 135 | if (!upServer && !upTraefik) { 136 | return; 137 | } 138 | // define target based on user input 139 | if (upServer && upTraefik) { 140 | target = 'all'; 141 | } else if (upServer) { 142 | target = 'server'; 143 | } else if (upTraefik) { 144 | target = 'traefik'; 145 | } 146 | } 147 | 148 | if (!validTargets.includes(target)) { 149 | console.log( 150 | chalk.red('Error!'), 151 | 'Invalid target! Should be one of:', 152 | validTargets.map(it => chalk.green(it)).join(', ') 153 | ); 154 | return; 155 | } 156 | 157 | // if target is all - run updates sequentially 158 | if (target === 'all') { 159 | await runUpdate('traefik'); 160 | await runUpdate('server'); 161 | return; 162 | } 163 | 164 | // otherwise - just run given target 165 | await runUpdate(target); 166 | }; 167 | -------------------------------------------------------------------------------- /test/config.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | // mock config for testing 3 | jest.mock('../src/config', () => require('./__mocks__/config')); 4 | 5 | // npm packages 6 | const fs = require('fs'); 7 | const path = require('path'); 8 | const yaml = require('js-yaml'); 9 | const sinon = require('sinon'); 10 | const inquirer = require('inquirer'); 11 | const md5 = require('apache-md5'); 12 | 13 | // our packages 14 | const {handler: config} = require('../src/commands/config'); 15 | 16 | const configData = { 17 | name: 'test', 18 | domain: 'test.dev', 19 | port: '8080', 20 | project: 'test-project', 21 | env: 'ENV=1, OTHER=2', 22 | labels: 'label=1, other=2', 23 | hostname: 'host', 24 | restart: 'no', 25 | template: 'static', 26 | compess: true, 27 | letsencrypt: true, 28 | enableRatelimit: true, 29 | ratelimitAverage: 20, 30 | ratelimitBurst: 30, 31 | volumes: 'test:/volume', 32 | basicAuth: true, 33 | function: true, 34 | functionType: 'worker', 35 | functionRoute: '/test', 36 | }; 37 | const users = [ 38 | { 39 | username: 'user1', 40 | password: 'pass', 41 | askAgain: true, 42 | }, 43 | { 44 | username: 'user2', 45 | password: 'pass', 46 | askAgain: false, 47 | }, 48 | ]; 49 | const cwd = process.cwd(); 50 | const folderName = path.basename(cwd); 51 | const configPath = path.join(cwd, 'exoframe.json'); 52 | 53 | const verifyBasicAuth = (input, actual) => { 54 | actual.split(',').forEach((element, index) => { 55 | const hash = element.split(':')[1]; 56 | expect(hash).toEqual(md5(input[index].password, hash)); 57 | }); 58 | }; 59 | 60 | beforeAll(() => { 61 | try { 62 | fs.statSync(configPath); 63 | fs.unlinkSync(configPath); 64 | } catch (e) { 65 | // no config, just exit 66 | } 67 | 68 | sinon 69 | .stub(inquirer, 'prompt') 70 | .onFirstCall() 71 | .callsFake(() => Promise.resolve(configData)) 72 | .onSecondCall() 73 | .callsFake(() => Promise.resolve(users[0])) 74 | .onThirdCall() 75 | .callsFake(() => Promise.resolve(users[1])); 76 | }); 77 | 78 | test('Should generate the config with parameters', done => { 79 | // spy on console 80 | const consoleSpy = sinon.spy(console, 'log'); 81 | 82 | config({ 83 | domain: 'test123.dev', 84 | port: '1234', 85 | restart: 'unless-stopped', 86 | project: 'give-project-name', 87 | name: 'test name 123', 88 | hostname: 'test123.dev', 89 | }).then(() => { 90 | expect(consoleSpy.args).toMatchSnapshot(); 91 | // then check config changes 92 | const cfg = yaml.safeLoad(fs.readFileSync(configPath, 'utf8')); 93 | expect(cfg.name).toEqual('test name 123'); 94 | expect(cfg.restart).toEqual('unless-stopped'); 95 | expect(cfg.domain).toEqual('test123.dev'); 96 | expect(cfg.port).toEqual('1234'); 97 | expect(cfg.project).toEqual('give-project-name'); 98 | expect(cfg.hostname).toEqual('test123.dev'); 99 | // restore console 100 | console.log.restore(); 101 | // remove corrupted config 102 | fs.unlinkSync(configPath); 103 | done(); 104 | }); 105 | }); 106 | 107 | // test config generation 108 | test('Should generate config file', done => { 109 | // spy on console 110 | const consoleSpy = sinon.spy(console, 'log'); 111 | // execute login 112 | config().then(() => { 113 | // first check console output 114 | expect(consoleSpy.args).toMatchSnapshot(); 115 | // then check config changes 116 | const cfg = yaml.safeLoad(fs.readFileSync(configPath, 'utf8')); 117 | expect(cfg.name).toEqual(configData.name); 118 | expect(cfg.restart).toEqual(configData.restart); 119 | expect(cfg.domain).toEqual(configData.domain); 120 | expect(cfg.port).toEqual(configData.port); 121 | expect(cfg.project).toEqual(configData.project); 122 | expect(cfg.hostname).toEqual(configData.hostname); 123 | expect(cfg.env.ENV).toEqual('1'); 124 | expect(cfg.env.OTHER).toEqual('2'); 125 | expect(cfg.labels.label).toEqual('1'); 126 | expect(cfg.labels.other).toEqual('2'); 127 | expect(cfg.template).toEqual(configData.template); 128 | expect(cfg.compress).toEqual(configData.compress); 129 | expect(cfg.letsencrypt).toEqual(configData.letsencrypt); 130 | expect(cfg.rateLimit).toEqual({ 131 | average: configData.ratelimitAverage, 132 | burst: configData.ratelimitBurst, 133 | }); 134 | expect(cfg.function).toEqual({ 135 | type: configData.functionType, 136 | route: configData.functionRoute, 137 | }); 138 | verifyBasicAuth(users, cfg.basicAuth); 139 | // restore inquirer 140 | inquirer.prompt.restore(); 141 | // restore console 142 | console.log.restore(); 143 | // remove corrupted config 144 | fs.unlinkSync(configPath); 145 | done(); 146 | }); 147 | }); 148 | 149 | // test config generation 150 | test('Should generate config file for functions', done => { 151 | // spy on console 152 | const consoleSpy = sinon.spy(console, 'log'); 153 | // execute login 154 | config({func: true}).then(() => { 155 | // first check console output 156 | expect(consoleSpy.args).toMatchSnapshot(); 157 | // then check config changes 158 | const cfg = yaml.safeLoad(fs.readFileSync(configPath, 'utf8')); 159 | expect(cfg.name).toEqual(folderName); 160 | expect(cfg.function).toEqual(true); 161 | // restore console 162 | console.log.restore(); 163 | // remove corrupted config 164 | fs.unlinkSync(configPath); 165 | done(); 166 | }); 167 | }); 168 | -------------------------------------------------------------------------------- /docs/Functions.md: -------------------------------------------------------------------------------- 1 | # Function deployments 2 | 3 | Exoframe also allows deploying simple Node.js functions using Exoframe Server itself. 4 | 5 | ## Setup 6 | 7 | To deploy a function, `exoframe.json` needs to indicate that current project is a function. 8 | You can easily do that by running the following command: 9 | 10 | ``` 11 | exoframe init -f 12 | ``` 13 | 14 | If you want more fine-grained config - simply run `exoframe config` and answer interactive questions. 15 | 16 | By default, Exoframe Server expects `index.js` file only. 17 | You might add `package.json` with dependencies - in this case `yarn install` will be executed before loading the function. 18 | 19 | ## Types 20 | 21 | Each deployed function has a `type` property in the config. 22 | Exoframe currently supports 4 different function types: 23 | 24 | ### HTTP functions 25 | 26 | Basic HTTP handler function. 27 | This is used as a default type for any deployed function without explicitly specified type. 28 | 29 | ### Worker functions 30 | 31 | Long-running function that is executed in a separate [worker thread](https://nodejs.org/api/worker_threads.html). 32 | 33 | ### Trigger functions 34 | 35 | Custom user-defined trigger that can dispatch event for other custom functions to react to. 36 | Can be useful to granularly execute functions that react to e.g. database changes. 37 | 38 | ### Custom functions 39 | 40 | React to custom user-defined triggers. 41 | Should have type that matches the name of the trigger. 42 | 43 | ## Logging 44 | 45 | If your function uses provided by Exoframe logger - you can fetch function logs by using `exoframe logs` command in CLI. 46 | 47 | ## Running functions locally 48 | 49 | Sometimes you might need to execute your functions locally before deploying them. 50 | Exoframe provides `exoframe-faas` binary to do so. 51 | 52 | To run your function, simply execute: 53 | 54 | ```sh 55 | $ npx exoframe-faas 56 | ``` 57 | 58 | If you are developing an HTTP function, you'll need to install Fastify locally for `exoframe-faas` to work, e.g.: 59 | 60 | ```sh 61 | $ npm i -D fastify 62 | $ npx exoframe-faas 63 | ``` 64 | 65 | ## Caveats 66 | 67 | - All defined functions have to be `async` or return promise. 68 | - Deployed functions have a separate `function.route` setting and do not take `domain` config into account - they'll always be served from your Exoframe Server. 69 | - By default Exoframe Server imports function by using a folder name. This means function entry point file _must be_ `index.js`. This can be changed by adding `package.json` and changing `"main": "file.js"` property. 70 | 71 | ## Examples 72 | 73 | ### Simple HTTP function 74 | 75 | ```js 76 | // exoframe.json 77 | { 78 | "name": "test-http-fn", 79 | "function": { 80 | // no type is given, so it defaults to http 81 | // will be served from http://exoframe.domain.com/test 82 | "route": "/test" 83 | } 84 | } 85 | 86 | // index.js 87 | module.exports = async (event, context) => { 88 | // use context.log to provide logs to exoframe 89 | // those logs can be then accessed from exoframe CLI 90 | context.log('test log'); 91 | context.log('other log'); 92 | 93 | // you can just return a value 94 | return `hello world`; 95 | 96 | // alternatively you can use reply prop 97 | // to directly access Fastify reply object 98 | context.reply.code(200).send('hello world!'); 99 | // make sure to return false-y value if you do this 100 | // so exoframe doesn't try to send the response second time 101 | return false; 102 | }; 103 | ``` 104 | 105 | ### Simple Worker function 106 | 107 | ```js 108 | // exoframe.json 109 | { 110 | "name": "test-worker-fn", 111 | "function": { 112 | // define type as worker, so that exoframe starts 113 | // the function using worker thread 114 | "type": "worker" 115 | } 116 | } 117 | 118 | // index.js 119 | module.exports = async (_, context) => { 120 | // use context.log to log stuff, just as in HTTP function 121 | context.log('Worker started.'); 122 | // worker can execute any long-running task you want 123 | let counter = 0; 124 | setInterval(() => { 125 | context.log(`Worker: ${counter++}`); 126 | }, 1000); 127 | }; 128 | ``` 129 | 130 | ### Simple Trigger function 131 | 132 | ```js 133 | // exoframe.json 134 | { 135 | "name": "test-trigger", 136 | "function": { 137 | // define type as trigger, so that exoframe provides it a way 138 | // to dispatch events for other functions 139 | "type": "trigger" 140 | } 141 | } 142 | 143 | // index.js 144 | module.exports = async (dispatchEvent, context) => { 145 | // log 146 | context.log('Trigger started.'); 147 | 148 | // in this case we trigger all subscribed functions every 1s 149 | const interval = setInterval(() => { 150 | context.log(`Triggering!`); 151 | // dispatching new events to all function with data 152 | dispatchEvent({data: 'hello world!'}); 153 | }, 1000); 154 | 155 | // trigger function should return a cleanup function 156 | return () => { 157 | clearInterval(interval); 158 | }; 159 | }; 160 | ``` 161 | 162 | ### Simple Custom trigger handler function 163 | 164 | ```js 165 | // exoframe.json 166 | { 167 | "name": "test-triggered-fn", 168 | "function": { 169 | // define type as "test-trigger", so that exoframe knows that 170 | // custom dispatched events from test-trigger must be handled by this function 171 | "type": "test-trigger" 172 | } 173 | } 174 | 175 | // index.js 176 | module.exports = async (event, context) => { 177 | // Will get custom data from trigger above, so logging will say: 178 | // Custom function triggered: {"data": "hello world!"} 179 | context.log(`Custom function triggered: ${JSON.stringify(event.data)}`); 180 | }; 181 | ``` 182 | -------------------------------------------------------------------------------- /docs/Advanced.md: -------------------------------------------------------------------------------- 1 | # Advanced topics 2 | 3 | ## Routing requests to specific path 4 | 5 | Since Traefik supports routing requests to specific path, you can also do that with Exoframe. 6 | By default, Exoframe generates the following frontend string: 7 | 8 | ```js 9 | // where config is project config json 10 | Labels[`traefik.http.routers.${name}.rule`] = config.domain.includes('Host(') 11 | ? // if string already contains Host() - use it as is 12 | config.domain 13 | : // otherwise - wrap it into Host() 14 | `Host(\`${config.domain}\`)`; 15 | ``` 16 | 17 | You can route requests to path instead by using Traefik [router rules](https://docs.traefik.io/routing/routers/#rule) and using them inside of `domain` field in config. 18 | For example, you can route requests from `http://bots.domain.com/myhook` to your service. 19 | To achieve this, you will need to simply set `domain` field in the config file to `` Host(`bots.domain.com`) && Path(`/myhook`) ``. 20 | This will route all requests from `bots.domain.com/myhook` to `your.service.host/myhook`. 21 | 22 | If you need to strip or replace path, you have to provide additional label for Traefik. 23 | E.g. the following config will route `domain.com/myprefix` to `your.service.host`: 24 | 25 | ```json 26 | { 27 | "domain": "Host(`domain.com`) && Path(`/myprefix`)", 28 | "labels": { 29 | "traefik.http.middlewares.test-stripprefix.stripprefix.prefixes": "/myprefix" 30 | } 31 | } 32 | ``` 33 | 34 | For more info and options see the aforementioned Traefik [router rules](https://docs.traefik.io/routing/routers/#rule) as well as [middlewares](https://docs.traefik.io/middlewares/overview/) docs. 35 | 36 | ## Docker-compose based deployment 37 | 38 | Deploying using docker compose works almost the same as using a normal docker compose file, but there are a few labels you should use to ensure Traefik can correctly access your application. 39 | 40 | version: '2' 41 | services: 42 | web: 43 | build: . 44 | labels: 45 | traefik.http.routers.web.rule: 'Host(`test.dev`)' 46 | redis: 47 | image: "redis:alpine" 48 | 49 | Any of the [configuration options](https://docs.traefik.io/reference/dynamic-configuration/docker/) for the default Traefik docker setup can be used. 50 | 51 | If you have a docker-compose.yml file, **any domain set in exoframe.json will be ignored**. 52 | 53 | For the most part, Exoframe doesn't pass anything from `exoframe.json` to the compose. 54 | However, one thing that is being passed is environmental variables. 55 | You can use any variables defined in `exoframe.json` in your compose file. 56 | First, define them in your `exoframe.json`: 57 | 58 | ```json 59 | { 60 | "name": "test-compose-deploy", 61 | "env": { 62 | "CUSTOM_LABEL": "custom-value", 63 | "CUSTOM_SECRET": "@test-secret" 64 | } 65 | } 66 | ``` 67 | 68 | Then use them inside your `docker-compose.yml`: 69 | 70 | version: '2' 71 | services: 72 | web: 73 | build: . 74 | labels: 75 | traefik.http.routers.web.rule: 'Host(`test.dev`)' 76 | custom.envvar: "${CUSTOM_LABEL}" 77 | custom.secret: "${CUSTOM_SECRET}" 78 | redis: 79 | image: "redis:alpine" 80 | 81 | ## Rate limiting 82 | 83 | Exoframe allows you to enable basic IP-based rate-limiting integrated into Traefik. 84 | To do that, simply specify the following fields in the project config file: 85 | 86 | ```js 87 | { 88 | // adding this object will enable IP-based rate-limiting 89 | "rate-limit": { 90 | // average request rate over given time period 91 | // defaults to 1 if not specified 92 | "average": 5, 93 | // maximal burst request rate over given time period 94 | // defaults to 5 if not specified 95 | "burst": 10 96 | } 97 | } 98 | ``` 99 | 100 | This will define how many requests (`average`) over given time (`period`) can be performed from one IP address. 101 | For the example above - an average of 5 requests every second is allowed with busts of up to 10 requests. 102 | 103 | For more information, see [Traefik rate-limiting docs](https://docs.traefik.io/middlewares/ratelimit/). 104 | 105 | ## Secrets 106 | 107 | Exoframe allows you to create server-side secret values that can be used during service deployments. 108 | To use secrets you first need to create one. This can be done by running: 109 | 110 | ``` 111 | $ exoframe secret new 112 | ``` 113 | 114 | Once you specify the name and value, Exoframe server will create new secret _for your current user_. 115 | After creation the secret can be used in `exoframe.json` config file by using secret name and prefixing it with `@`, like so (in this example the secret was name `my-secret`): 116 | 117 | ```json 118 | "env": { 119 | "SECRET_KEY": "@my-secret" 120 | }, 121 | ``` 122 | 123 | Current caveats: 124 | 125 | - Currently secrets only work for environment variables 126 | - Currently secrets work only for normal deployments (any template or recipe that uses `startFromParams` won't have secrets expanded) 127 | 128 | ## Accessing Exoframe data from within the deployed application 129 | 130 | Exoframe provides a set of environment variables that are set on each deployment to allow getting project info and settings. 131 | Currently those are: 132 | 133 | ```bash 134 | # owner of current deployment 135 | EXOFRAME_USER=admin 136 | # project of current deployment 137 | EXOFRAME_PROJECT=projectName 138 | # full deployment ID 139 | EXOFRAME_DEPLOYMENT=exo-admin-deployName-ID 140 | # host used to expose current deployment (if any) 141 | EXOFRAME_HOST=exo-admin-deployName-ID.baseDomain 142 | ``` 143 | 144 | ## Plugins 145 | 146 | Exoframe-Server supports extension of core features using plugins. 147 | Plugins are installed and loaded automatically once corresponding config is added to [server configuration](ServerConfiguration.md). 148 | Refer to specific plugins docs to see how to configure them. 149 | -------------------------------------------------------------------------------- /test/template.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | // mock config for testing 3 | jest.mock('../src/config', () => require('./__mocks__/config')); 4 | 5 | // npm packages 6 | const nock = require('nock'); 7 | const sinon = require('sinon'); 8 | const inquirer = require('inquirer'); 9 | 10 | // our packages 11 | const {handler: template} = require('../src/commands/template'); 12 | const cfg = require('../src/config'); 13 | 14 | // test generation 15 | test('Should install new template', done => { 16 | // handle correct request 17 | const templateServer = nock('http://localhost:8080') 18 | .post('/templates') 19 | .reply(200, {success: 'true', log: ['1', '2', '3']}); 20 | // spy on console 21 | const consoleSpy = sinon.spy(console, 'log'); 22 | // stup inquirer answers 23 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve({templateName: 'test'})); 24 | // execute login 25 | template({}).then(() => { 26 | // make sure log in was successful 27 | // check that server was called 28 | expect(templateServer.isDone()).toBeTruthy(); 29 | // first check console output 30 | expect(consoleSpy.args).toMatchSnapshot(); 31 | // restore console 32 | console.log.restore(); 33 | // restore inquirer 34 | inquirer.prompt.restore(); 35 | // tear down nock 36 | templateServer.done(); 37 | done(); 38 | }); 39 | }); 40 | 41 | // test list 42 | test('Should list templates', done => { 43 | // handle correct request 44 | const templateServer = nock('http://localhost:8080') 45 | .get('/templates') 46 | .reply(200, {template: '^0.0.1', otherTemplate: '^1.0.0'}); 47 | // spy on console 48 | const consoleSpy = sinon.spy(console, 'log'); 49 | // execute login 50 | template({cmd: 'ls'}).then(() => { 51 | // make sure log in was successful 52 | // check that server was called 53 | expect(templateServer.isDone()).toBeTruthy(); 54 | // first check console output 55 | expect(consoleSpy.args).toMatchSnapshot(); 56 | // restore console 57 | console.log.restore(); 58 | // tear down nock 59 | templateServer.done(); 60 | done(); 61 | }); 62 | }); 63 | 64 | test('Should list zero templates', done => { 65 | // handle correct request 66 | const templateServer = nock('http://localhost:8080').get('/templates').reply(200, {}); 67 | // spy on console 68 | const consoleSpy = sinon.spy(console, 'log'); 69 | // execute login 70 | template({cmd: 'ls'}).then(() => { 71 | // make sure log in was successful 72 | // check that server was called 73 | expect(templateServer.isDone()).toBeTruthy(); 74 | // first check console output 75 | expect(consoleSpy.args).toMatchSnapshot(); 76 | // restore console 77 | console.log.restore(); 78 | // tear down nock 79 | templateServer.done(); 80 | done(); 81 | }); 82 | }); 83 | 84 | // test removal 85 | test('Should remove template', done => { 86 | // handle correct request 87 | const templateGetServer = nock('http://localhost:8080').get('/templates').reply(200, {testTemplate: '0.0.1'}); 88 | // handle correct request 89 | const templateServer = nock('http://localhost:8080') 90 | .delete('/templates') 91 | .reply(200, {removed: true, log: ['1', '2', '3']}); 92 | // spy on console 93 | const consoleSpy = sinon.spy(console, 'log'); 94 | // stup inquirer answers 95 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve({rmTemplate: 'testTemplate'})); 96 | // execute login 97 | template({cmd: 'rm'}).then(() => { 98 | // make sure log in was successful 99 | // check that server was called 100 | expect(templateGetServer.isDone()).toBeTruthy(); 101 | expect(templateServer.isDone()).toBeTruthy(); 102 | // first check console output 103 | expect(consoleSpy.args).toMatchSnapshot(); 104 | // restore console 105 | console.log.restore(); 106 | // restore inquirer 107 | inquirer.prompt.restore(); 108 | // tear down nock 109 | templateGetServer.done(); 110 | templateServer.done(); 111 | done(); 112 | }); 113 | }); 114 | 115 | // test deauth 116 | test('Should deauth on 401 on creation', done => { 117 | // save config for restoration 118 | cfg.__save('template'); 119 | // handle correct request 120 | const templateServer = nock('http://localhost:8080').post('/templates').reply(401); 121 | // spy on console 122 | const consoleSpy = sinon.spy(console, 'log'); 123 | // stup inquirer answers 124 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve({templateName: 'test'})); 125 | // execute login 126 | template({}).then(() => { 127 | // make sure log in was successful 128 | // check that server was called 129 | expect(templateServer.isDone()).toBeTruthy(); 130 | // first check console output 131 | expect(consoleSpy.args).toMatchSnapshot(); 132 | // restore console 133 | console.log.restore(); 134 | // restore inquirer 135 | inquirer.prompt.restore(); 136 | // tear down nock 137 | templateServer.done(); 138 | done(); 139 | }); 140 | }); 141 | 142 | test('Should deauth on 401 on list', done => { 143 | // restore original config 144 | cfg.__restore('template'); 145 | // handle correct request 146 | const templateServer = nock('http://localhost:8080').get('/templates').reply(401); 147 | // spy on console 148 | const consoleSpy = sinon.spy(console, 'log'); 149 | // stup inquirer answers 150 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve({templateName: 'test'})); 151 | // execute login 152 | template({cmd: 'ls'}).then(() => { 153 | // make sure log in was successful 154 | // check that server was called 155 | expect(templateServer.isDone()).toBeTruthy(); 156 | // first check console output 157 | expect(consoleSpy.args).toMatchSnapshot(); 158 | // restore console 159 | console.log.restore(); 160 | // restore inquirer 161 | inquirer.prompt.restore(); 162 | // tear down nock 163 | templateServer.done(); 164 | done(); 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /test/token.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | // mock config for testing 3 | jest.mock('../src/config', () => require('./__mocks__/config')); 4 | 5 | // npm packages 6 | const nock = require('nock'); 7 | const sinon = require('sinon'); 8 | const inquirer = require('inquirer'); 9 | 10 | // our packages 11 | const {handler: token} = require('../src/commands/token'); 12 | const cfg = require('../src/config'); 13 | 14 | // test generation 15 | test('Should generate token', done => { 16 | // handle correct request 17 | const tokenServer = nock('http://localhost:8080').post('/deployToken').reply(200, {token: 'test'}); 18 | // spy on console 19 | const consoleSpy = sinon.spy(console, 'log'); 20 | // stup inquirer answers 21 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve({tokenName: 'test'})); 22 | // execute login 23 | token({}).then(() => { 24 | // make sure log in was successful 25 | // check that server was called 26 | expect(tokenServer.isDone()).toBeTruthy(); 27 | // first check console output 28 | expect(consoleSpy.args).toMatchSnapshot(); 29 | // restore console 30 | console.log.restore(); 31 | // restore inquirer 32 | inquirer.prompt.restore(); 33 | // tear down nock 34 | tokenServer.done(); 35 | done(); 36 | }); 37 | }); 38 | 39 | // test list 40 | test('Should list tokens', done => { 41 | const createDate = new Date(2017, 1, 1, 1, 1, 1, 1); 42 | // handle correct request 43 | const tokenServer = nock('http://localhost:8080') 44 | .get('/deployToken') 45 | .reply(200, {tokens: [{tokenName: 'test', meta: {created: createDate}}]}); 46 | // spy on console 47 | const consoleSpy = sinon.spy(console, 'log'); 48 | // execute login 49 | token({cmd: 'ls'}).then(() => { 50 | // make sure log in was successful 51 | // check that server was called 52 | expect(tokenServer.isDone()).toBeTruthy(); 53 | // first check console output 54 | expect(consoleSpy.args.map(lines => lines.map(l => l.replace(createDate.toLocaleString(), '')))).toMatchSnapshot(); 55 | // restore console 56 | console.log.restore(); 57 | // tear down nock 58 | tokenServer.done(); 59 | done(); 60 | }); 61 | }); 62 | 63 | test('Should list zero tokens', done => { 64 | // handle correct request 65 | const tokenServer = nock('http://localhost:8080').get('/deployToken').reply(200, {tokens: []}); 66 | // spy on console 67 | const consoleSpy = sinon.spy(console, 'log'); 68 | // execute login 69 | token({cmd: 'ls'}).then(() => { 70 | // make sure log in was successful 71 | // check that server was called 72 | expect(tokenServer.isDone()).toBeTruthy(); 73 | // first check console output 74 | expect(consoleSpy.args).toMatchSnapshot(); 75 | // restore console 76 | console.log.restore(); 77 | // tear down nock 78 | tokenServer.done(); 79 | done(); 80 | }); 81 | }); 82 | 83 | // test removal 84 | test('Should remove token', done => { 85 | const createDate = new Date(); 86 | // handle correct request 87 | const tokenGetServer = nock('http://localhost:8080') 88 | .get('/deployToken') 89 | .reply(200, {tokens: [{tokenName: 'test', meta: {created: createDate}}]}); 90 | // handle correct request 91 | const tokenServer = nock('http://localhost:8080').delete('/deployToken').reply(204, ''); 92 | // spy on console 93 | const consoleSpy = sinon.spy(console, 'log'); 94 | // stup inquirer answers 95 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve({rmToken: 'test'})); 96 | // execute login 97 | token({cmd: 'rm'}).then(() => { 98 | // make sure log in was successful 99 | // check that server was called 100 | expect(tokenGetServer.isDone()).toBeTruthy(); 101 | expect(tokenServer.isDone()).toBeTruthy(); 102 | // first check console output 103 | expect(consoleSpy.args).toMatchSnapshot(); 104 | // restore console 105 | console.log.restore(); 106 | // restore inquirer 107 | inquirer.prompt.restore(); 108 | // tear down nock 109 | tokenGetServer.done(); 110 | tokenServer.done(); 111 | done(); 112 | }); 113 | }); 114 | 115 | // test deauth 116 | test('Should deauth on 401 on creation', done => { 117 | // save current config state 118 | cfg.__save('token'); 119 | // handle correct request 120 | const tokenServer = nock('http://localhost:8080').post('/deployToken').reply(401); 121 | // spy on console 122 | const consoleSpy = sinon.spy(console, 'log'); 123 | // stup inquirer answers 124 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve({tokenName: 'test'})); 125 | // execute login 126 | token({}).then(() => { 127 | // make sure log in was successful 128 | // check that server was called 129 | expect(tokenServer.isDone()).toBeTruthy(); 130 | // first check console output 131 | expect(consoleSpy.args).toMatchSnapshot(); 132 | // restore console 133 | console.log.restore(); 134 | // restore inquirer 135 | inquirer.prompt.restore(); 136 | // tear down nock 137 | tokenServer.done(); 138 | done(); 139 | }); 140 | }); 141 | 142 | test('Should deauth on 401 on list', done => { 143 | // restore config with auth 144 | cfg.__restore('token'); 145 | // handle correct request 146 | const tokenServer = nock('http://localhost:8080').get('/deployToken').reply(401); 147 | // spy on console 148 | const consoleSpy = sinon.spy(console, 'log'); 149 | // stup inquirer answers 150 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve({tokenName: 'test'})); 151 | // execute login 152 | token({cmd: 'ls'}).then(() => { 153 | // make sure log in was successful 154 | // check that server was called 155 | expect(tokenServer.isDone()).toBeTruthy(); 156 | // first check console output 157 | expect(consoleSpy.args).toMatchSnapshot(); 158 | // restore console 159 | console.log.restore(); 160 | // restore inquirer 161 | inquirer.prompt.restore(); 162 | // tear down nock 163 | tokenServer.done(); 164 | done(); 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /test/update.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | // mock config for testing 3 | jest.mock('../src/config', () => require('./__mocks__/config')); 4 | 5 | // npm packages 6 | const nock = require('nock'); 7 | const sinon = require('sinon'); 8 | const inquirer = require('inquirer'); 9 | 10 | // our packages 11 | const {handler: update} = require('../src/commands/update'); 12 | const {userConfig} = require('../src/config'); 13 | 14 | // test update 15 | test('Should update traefik', done => { 16 | // handle correct request 17 | const updateServer = nock('http://localhost:8080').post('/update/traefik').reply(200, {updated: true}); 18 | // spy on console 19 | const consoleSpy = sinon.spy(console, 'log'); 20 | // execute login 21 | update({target: 'traefik'}).then(() => { 22 | // make sure log in was successful 23 | // check that server was called 24 | expect(updateServer.isDone()).toBeTruthy(); 25 | // first check console output 26 | expect(consoleSpy.args).toMatchSnapshot(); 27 | // restore console 28 | console.log.restore(); 29 | updateServer.done(); 30 | done(); 31 | }); 32 | }); 33 | 34 | // test update 35 | test('Should update server', done => { 36 | // handle correct request 37 | const updateServer = nock('http://localhost:8080').post('/update/server').reply(200, {updated: true}); 38 | // spy on console 39 | const consoleSpy = sinon.spy(console, 'log'); 40 | // execute login 41 | update({target: 'server'}).then(() => { 42 | // make sure log in was successful 43 | // check that server was called 44 | expect(updateServer.isDone()).toBeTruthy(); 45 | // first check console output 46 | expect(consoleSpy.args).toMatchSnapshot(); 47 | // restore console 48 | console.log.restore(); 49 | updateServer.done(); 50 | done(); 51 | }); 52 | }); 53 | 54 | // test update error 55 | test('Should display update error', done => { 56 | // handle correct request 57 | const response = {updated: false, error: 'Test error', log: 'log'}; 58 | const updateServer = nock('http://localhost:8080').post('/update/traefik').reply(500, response); 59 | // spy on console 60 | const consoleSpy = sinon.spy(console, 'log'); 61 | // execute login 62 | update({target: 'traefik'}).then(() => { 63 | // make sure log in was successful 64 | // check that server was called 65 | expect(updateServer.isDone()).toBeTruthy(); 66 | // first check console output 67 | expect(consoleSpy.args).toMatchSnapshot(); 68 | // restore console 69 | console.log.restore(); 70 | updateServer.done(); 71 | done(); 72 | }); 73 | }); 74 | 75 | // test version check 76 | test('Should display versions', done => { 77 | // handle correct request 78 | const response = { 79 | server: '0.18.0', 80 | latestServer: '0.19.1', 81 | serverUpdate: true, 82 | traefik: 'v1.3.0', 83 | latestTraefik: 'v1.3.2', 84 | traefikUpdate: true, 85 | }; 86 | const updateServer = nock('http://localhost:8080').get('/version').reply(200, response); 87 | // spy on console 88 | const consoleSpy = sinon.spy(console, 'log'); 89 | // stup inquirer answers 90 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve({upServer: false, upTraefik: false})); 91 | // execute login 92 | update({}).then(() => { 93 | // make sure log in was successful 94 | // check that server was called 95 | expect(updateServer.isDone()).toBeTruthy(); 96 | // first check console output 97 | expect(consoleSpy.args).toMatchSnapshot(); 98 | // restore console 99 | console.log.restore(); 100 | // restore inquirer 101 | inquirer.prompt.restore(); 102 | // cleanup server 103 | updateServer.done(); 104 | done(); 105 | }); 106 | }); 107 | 108 | // test version check 109 | test('Should update all on user prompt', done => { 110 | // handle correct request 111 | const response = { 112 | server: '0.18.0', 113 | latestServer: '0.19.1', 114 | serverUpdate: true, 115 | traefik: 'v1.3.0', 116 | latestTraefik: 'v1.3.2', 117 | traefikUpdate: true, 118 | }; 119 | const updateInfoServer = nock('http://localhost:8080').get('/version').reply(200, response); 120 | const updateServerRun = nock('http://localhost:8080').post('/update/server').reply(200, {updated: true}); 121 | const updateTraefikRun = nock('http://localhost:8080').post('/update/traefik').reply(200, {updated: true}); 122 | // spy on console 123 | const consoleSpy = sinon.spy(console, 'log'); 124 | // stup inquirer answers 125 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve({upServer: true, upTraefik: true})); 126 | // execute login 127 | update({}).then(() => { 128 | // make sure log in was successful 129 | // check that servers were called 130 | expect(updateInfoServer.isDone()).toBeTruthy(); 131 | expect(updateServerRun.isDone()).toBeTruthy(); 132 | expect(updateTraefikRun.isDone()).toBeTruthy(); 133 | // first check console output 134 | expect(consoleSpy.args).toMatchSnapshot(); 135 | // restore console 136 | console.log.restore(); 137 | // restore inquirer 138 | inquirer.prompt.restore(); 139 | // cleanup server 140 | updateInfoServer.done(); 141 | updateServerRun.done(); 142 | updateTraefikRun.done(); 143 | done(); 144 | }); 145 | }); 146 | 147 | // test deauth 148 | test('Should deauth on 401', done => { 149 | // handle correct request 150 | const updateServer = nock('http://localhost:8080').post(`/update/traefik`).reply(401); 151 | // spy on console 152 | const consoleSpy = sinon.spy(console, 'log'); 153 | // execute login 154 | update({target: 'traefik'}).then(() => { 155 | // make sure log in was successful 156 | // check that server was called 157 | expect(updateServer.isDone()).toBeTruthy(); 158 | // first check console output 159 | expect(consoleSpy.args).toMatchSnapshot(); 160 | // check config 161 | expect(userConfig.user).toBeUndefined(); 162 | expect(userConfig.token).toBeUndefined(); 163 | // restore console 164 | console.log.restore(); 165 | // tear down nock 166 | updateServer.done(); 167 | done(); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /src/commands/template.js: -------------------------------------------------------------------------------- 1 | // npm packages 2 | const got = require('got'); 3 | const chalk = require('chalk'); 4 | const inquirer = require('inquirer'); 5 | const ora = require('ora'); 6 | const Table = require('cli-table3'); 7 | 8 | // our packages 9 | const {userConfig, isLoggedIn, logout} = require('../config'); 10 | const {tableBorder, tableStyle} = require('../config/table'); 11 | 12 | exports.command = ['template [cmd]']; 13 | exports.describe = 'add, list or remove deployment template'; 14 | exports.builder = { 15 | cmd: { 16 | default: '', 17 | description: 'command to execute [ls | rm]', 18 | }, 19 | verbose: { 20 | alias: 'v', 21 | description: 'Verbose mode; will output more information', 22 | }, 23 | }; 24 | exports.handler = async args => { 25 | if (!isLoggedIn()) { 26 | return; 27 | } 28 | 29 | // services request url 30 | const remoteUrl = `${userConfig.endpoint}/templates`; 31 | // get command 32 | const {cmd, verbose} = args; 33 | // construct shared request params 34 | const baseOptions = { 35 | headers: { 36 | Authorization: `Bearer ${userConfig.token}`, 37 | }, 38 | responseType: 'json', 39 | }; 40 | 41 | // if remove or ls - fetch tokens from remote, then do work 42 | if (cmd === 'ls' || cmd === 'rm') { 43 | console.log( 44 | chalk.bold(`${cmd === 'ls' ? 'Listing' : 'Removing'} deployment template${cmd === 'ls' ? 's' : ''} for:`), 45 | userConfig.endpoint 46 | ); 47 | 48 | // try sending request 49 | let templates = []; 50 | try { 51 | const {body} = await got(remoteUrl, {...baseOptions}); 52 | templates = body; 53 | } catch (e) { 54 | // if authorization is expired/broken/etc 55 | if (e.response.statusCode === 401) { 56 | logout(userConfig); 57 | console.log(chalk.red('Error: authorization expired!'), 'Please, relogin and try again.'); 58 | return; 59 | } 60 | 61 | console.log(chalk.red('Error while getting templates:'), e.toString()); 62 | return; 63 | } 64 | // check for errors 65 | if (!templates) { 66 | throw new Error('Server returned empty response!'); 67 | } 68 | 69 | // if no templates - just exit 70 | if (templates.length === 0) { 71 | console.log(chalk.green(`No templates found on ${userConfig.endpoint}!`)); 72 | return; 73 | } 74 | 75 | if (cmd === 'ls') { 76 | // print count 77 | console.log(chalk.green(`${Object.keys(templates).length} templates found on ${userConfig.endpoint}:\n`)); 78 | 79 | // create table 80 | const resultTable = new Table({ 81 | head: ['Template', 'Version'], 82 | chars: tableBorder, 83 | style: tableStyle, 84 | }); 85 | Object.keys(templates).forEach(name => resultTable.push([name, templates[name]])); 86 | 87 | console.log(resultTable.toString()); 88 | return; 89 | } 90 | 91 | const prompts = []; 92 | prompts.push({ 93 | type: 'list', 94 | name: 'rmTemplate', 95 | message: 'Choose template to remove:', 96 | choices: Object.keys(templates), 97 | }); 98 | const {rmTemplate} = await inquirer.prompt(prompts); 99 | 100 | // construct shared request params 101 | const rmOptions = { 102 | ...baseOptions, 103 | method: 'DELETE', 104 | json: { 105 | templateName: rmTemplate, 106 | }, 107 | responseType: 'json', 108 | }; 109 | try { 110 | const {body} = await got(remoteUrl, rmOptions); 111 | if (!body.removed) { 112 | console.log(chalk.red('Error removing template!')); 113 | console.log(''); 114 | console.log('Log:'); 115 | (body.log || ['No log available']) 116 | .filter(l => l !== undefined) 117 | .filter(l => l && l.message && l.message.length > 0) 118 | .forEach(line => console.log(line.message.trim())); 119 | return; 120 | } 121 | console.log(chalk.green('Template successfully removed!')); 122 | } catch (e) { 123 | // if authorization is expired/broken/etc 124 | if (e.response.statusCode === 401) { 125 | logout(userConfig); 126 | console.log(chalk.red('Error: authorization expired!'), 'Please, relogin and try again.'); 127 | return; 128 | } 129 | 130 | console.log(chalk.red('Error removing template:'), e.toString()); 131 | return; 132 | } 133 | 134 | return; 135 | } 136 | 137 | console.log(chalk.bold('Adding new deployment template for:'), userConfig.endpoint); 138 | 139 | // ask for template name 140 | const prompts = []; 141 | prompts.push({ 142 | type: 'input', 143 | name: 'templateName', 144 | message: 'Template name:', 145 | validate: input => input && input.length > 0, 146 | filter: input => input.trim(), 147 | }); 148 | const {templateName} = await inquirer.prompt(prompts); 149 | 150 | // construct shared request params 151 | const options = { 152 | ...baseOptions, 153 | method: 'POST', 154 | json: { 155 | templateName, 156 | }, 157 | responseType: 'json', 158 | }; 159 | 160 | // show loader 161 | const spinner = ora('Installing new template...').start(); 162 | 163 | // try sending request 164 | try { 165 | const {body} = await got(remoteUrl, options); 166 | const showLog = verbose || !body.success; 167 | if (showLog) { 168 | console.log(''); 169 | console.log('Log:'); 170 | (body.log || ['No log available']) 171 | .filter(l => l !== undefined) 172 | .filter(l => l && l.message && l.message.length > 0) 173 | .forEach(line => console.log(line.message.trim())); 174 | } 175 | if (body.success) { 176 | spinner.succeed('New template installed!'); 177 | } else { 178 | spinner.fail('Error installing template!'); 179 | } 180 | } catch (e) { 181 | spinner.fail('Template install failed!'); 182 | // if authorization is expired/broken/etc 183 | if (e.response.statusCode === 401) { 184 | logout(userConfig); 185 | console.log(chalk.red('Error: authorization expired!'), 'Please, relogin and try again.'); 186 | return; 187 | } 188 | 189 | console.log(chalk.red('Error installing deployment template:'), e.toString()); 190 | } 191 | }; 192 | -------------------------------------------------------------------------------- /docs/ServerInstallation.md: -------------------------------------------------------------------------------- 1 | # Exoframe Server 2 | 3 | ## Installation and Usage 4 | 5 | 1. Make sure you have Docker [installed and running](https://docs.docker.com/engine/installation/) on your host. 6 | 2. Pull and run Exoframe server using docker: 7 | 8 | ```sh 9 | docker run -d \ 10 | -v /var/run/docker.sock:/var/run/docker.sock \ 11 | -v /path/to/exoframe-folder:/root/.exoframe \ 12 | -v /home/user/.ssh/authorized_keys:/root/.ssh/authorized_keys:ro \ 13 | -e EXO_PRIVATE_KEY=your_private_key \ 14 | --label traefik.enable=true \ 15 | --label "traefik.http.routers.exoframe-server.rule=Host(\`exoframe.your-host.com\`)" \ 16 | --restart always \ 17 | --name exoframe-server \ 18 | exoframe/server 19 | 20 | # Explanation for arguments: 21 | # this allows Exoframe to access your docker 22 | -v /var/run/docker.sock:/var/run/docker.sock 23 | 24 | # /path/to/exoframe-folder should be path on your server 25 | # to desired folder that'll hold Exoframe configs 26 | -v /path/to/exoframe-folder:/root/.exoframe 27 | 28 | # /home/user/.ssh/authorized_keys should point to your authorized_keys file 29 | # for SSH that holds allowed public keys 30 | -v /home/user/.ssh/authorized_keys:/root/.ssh/authorized_keys:ro 31 | 32 | # this is your private key used for JWT encryption 33 | # you can set this to any string you want (a long random string generated by your password manager is recommended) 34 | -e EXO_PRIVATE_KEY=your_jwt_encryption_key 35 | 36 | # this is used to tell traefik that it should be enabled for exoframe-server 37 | --label traefik.enable=true 38 | 39 | # this is used to tell traefik on which domain should Exoframe server be listening 40 | # NOTE: it is important, that it is prefixed with "exoframe", or anything really, 41 | # so that exoframe has its own domain and does not interfere with your 42 | # application's url config. 43 | --label "traefik.http.routers.exoframe-server.rule=Host(\`exoframe.your-host.com\`)" 44 | 45 | # this is used to tell traefik to enable letsencrypt on the exoframe server 46 | # you can safely remove this label if you are no using letsencrypt 47 | --label traefik.http.routers.exoframe-server.tls.certresolver=exoframeChallenge 48 | ``` 49 | 50 | 3. Edit config file to fit your needs (see [Server Configuration](ServerConfiguration.md) section) 51 | 52 | Then install [Exoframe CLI](https://github.com/exoframejs/exoframe), point it to your new Exoframe server and use it. 53 | 54 | ## Installation and usage with Letsencrypt 55 | 56 | 1. Make sure you have Docker [installed and running](https://docs.docker.com/engine/installation/) on your host. 57 | 2. Create exoframe config file and enable `letsencrypt` in it (see [Server Configuration](ServerConfiguration.md) section) 58 | 3. Pull and run Exoframe server using docker: 59 | 60 | ```sh 61 | docker run -d \ 62 | -v /var/run/docker.sock:/var/run/docker.sock \ 63 | -v /path/to/exoframe-folder:/root/.exoframe \ 64 | -v /home/user/.ssh/authorized_keys:/root/.ssh/authorized_keys:ro \ 65 | -e EXO_PRIVATE_KEY=your_private_key \ 66 | --label traefik.enable=true \ 67 | --label "traefik.http.routers.exoframe-server.rule=Host(\`exoframe.your-host.com\`)" \ 68 | --label "traefik.http.routers.exoframe-server-web.rule=Host(\`exoframe.your-host.com\`)" \ 69 | --label traefik.http.routers.exoframe-server.tls.certresolver=exoframeChallenge \ 70 | --label traefik.http.middlewares.exoframe-server-redirect.redirectscheme.scheme=https \ 71 | --label traefik.http.routers.exoframe-server-web.entrypoints=web \ 72 | --label traefik.http.routers.exoframe-server-web.middlewares=exoframe-server-redirect@docker \ 73 | --label traefik.http.routers.exoframe-server.entrypoints=websecure \ 74 | --label entryPoints.web.address=:80 \ 75 | --label entryPoints.websecure.address=:443 \ 76 | --restart always \ 77 | --name exoframe-server \ 78 | exoframe/server 79 | 80 | # Explanation for new arguments: 81 | # this is used to tell traefik on which domain should Exoframe server be listening 82 | # first line is for http, second one - for https 83 | --label "traefik.http.routers.exoframe-server.rule=Host(\`exoframe.your-host.com\`)" 84 | --label "traefik.http.routers.exoframe-server-web.rule=Host(\`exoframe.your-host.com\`)" \ 85 | 86 | # this is used to tell traefik to enable letsencrypt on the exoframe server 87 | # you can safely remove this label if you are no using letsencrypt 88 | --label traefik.http.routers.exoframe-server.tls.certresolver=exoframeChallenge 89 | 90 | # this labels below set up automatic http -> https redirect 91 | # by defining two entrypoints - web on port 80 and websecure on port 443 92 | # and creating redirect middleware for web endpoint 93 | # for more details see traefik docs 94 | --label traefik.http.middlewares.exoframe-server-redirect.redirectscheme.scheme=https \ 95 | --label traefik.http.routers.exoframe-server-web.entrypoints=web \ 96 | --label traefik.http.routers.exoframe-server-web.middlewares=exoframe-server-redirect@docker \ 97 | --label traefik.http.routers.exoframe-server.entrypoints=websecure \ 98 | --label entryPoints.web.address=:80 \ 99 | --label entryPoints.websecure.address=:443 \ 100 | ``` 101 | 102 | **Note**: 103 | It is important to enable `letsencrypt` in Exoframe config _before_ starting Exoframe server. 104 | If that's not done - Exoframe will not create `exoframeChallenge` resolver for TLS and Traefik will error out. 105 | 106 | ## Installation and usage in Swarm mode 107 | 108 | Exoframe also supports running in [Swarm mode](https://docs.docker.com/engine/swarm/). 109 | To run Exoframe server in swarm, you need to do the following: 110 | 111 | 1. Make sure you have Docker on your host. 112 | 2. Make sure your Docker has [Swarm mode enabled](https://docs.docker.com/engine/swarm/swarm-tutorial/create-swarm/). 113 | 3. Pull and run Exoframe server using Docker on your manager node: 114 | 115 | ``` 116 | docker service create \ 117 | --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ 118 | --mount type=bind,source=/path/to/exoframe-folder,target=/root/.exoframe \ 119 | --mount type=bind,source=/home/user/.ssh/authorized_keys,target=/root/.ssh/authorized_keys,readonly \ 120 | -e EXO_PRIVATE_KEY=your_private_key \ 121 | --label traefik.enable=true \ 122 | --label "traefik.http.routers.exoframe-server.rule=Host(\`exoframe.your-host.com\`)" \ 123 | --label traefik.port=8080 \ 124 | --constraint=node.role==manager \ 125 | --name exoframe-server \ 126 | exoframe/server 127 | ``` 128 | 129 | Note that both Exoframe server and Traefik will be run on your manager node. 130 | -------------------------------------------------------------------------------- /docs/TemplatesGuide.md: -------------------------------------------------------------------------------- 1 | # Templates guide 2 | 3 | Exoframe allows extending the types of deployments it supports using third party plugins. 4 | This guide aims to explain basics you need to know to create your own templates. 5 | If you are looking for template usage - please see [Basics](Basics.md) part of the docs. 6 | 7 | ## Basics 8 | 9 | Exoframe uses [yarn](https://yarnpkg.com/) to install and remove third-party templates. 10 | The templates then are added to Exoframe server using Node.js `require()` method. 11 | So, make sure that your template's `package.json` has correct `main` attribute. 12 | 13 | Your template main script needs to export the following variables and methods: 14 | 15 | ```js 16 | // template name 17 | // can be used by user to specify the template in config 18 | exports.name = 'mytemplate'; 19 | 20 | // function to check if the template fits the project 21 | // will be executed unless template is specified by user explicitly 22 | exports.checkTemplate = async props => {}; 23 | 24 | // function to execute current template 25 | // handle building and starting the containers 26 | exports.executeTemplate = async props => {}; 27 | ``` 28 | 29 | ## Template props 30 | 31 | Both `checkTemplate` and `executeTemplate` get the same properties object upon execution. 32 | This object contains all data and methods required to build and execute new docker containers. 33 | Here's a snippet from the Exoframe server code that shows the props object being assembled: 34 | 35 | ```js 36 | // generate template props 37 | const templateProps = { 38 | // user project config 39 | config, 40 | // current user username 41 | username, 42 | // response stream, used to send log back to user 43 | resultStream, 44 | // temp dir that contains the project 45 | tempDockerDir, 46 | // docker-related things 47 | docker: { 48 | // docker daemon, instance of dockerode 49 | daemon: docker, 50 | // exoframe build function 51 | // has following signature: async ({username, resultStream}) => {} 52 | // executes `docker build` in project temp dir 53 | // returns following object: {log, image} 54 | build, 55 | // exoframe start function 56 | // has the following signature: async ({image, username, resultStream}) => {} 57 | // executes `docker start` with given image while setting all required labels, env vars, etc 58 | // returns inspect info from started container 59 | start, 60 | }, 61 | // exoframe utilities & logger 62 | // see code here: https://github.com/exoframejs/exoframe-server/blob/master/src/util/index.js 63 | util: Object.assign({}, util, { 64 | logger, 65 | }), 66 | }; 67 | ``` 68 | 69 | ## Checking if the projects fit your template 70 | 71 | First thing Exoframe server will do is execute your `checkTemplate` function to see if your template fits the current project. 72 | Typically you'd want to read the list of files in temporary project folder and see if it contains files related to your template type. 73 | Here's an example of the core static HTML template, it check whether folder contains `index.html` file: 74 | 75 | ```js 76 | // function to check if the template fits this recipe 77 | exports.checkTemplate = async ({tempDockerDir}) => { 78 | // if project already has dockerfile - just exit 79 | try { 80 | const filesList = fs.readdirSync(tempDockerDir); 81 | if (filesList.includes('index.html')) { 82 | return true; 83 | } 84 | return false; 85 | } catch (e) { 86 | return false; 87 | } 88 | }; 89 | ``` 90 | 91 | ## Executing the template 92 | 93 | Once you've determined that the project is indeed supported by your template, you will need to execute it. 94 | It is up to you to build _and_ start a docker image. 95 | Here's an example for the same static HTML core template that deploys current project using Nginx Dockerfile: 96 | 97 | ```js 98 | const nginxDockerfile = `FROM nginx:latest 99 | COPY . /usr/share/nginx/html 100 | RUN chmod -R 755 /usr/share/nginx/html 101 | `; 102 | 103 | // function to execute current template 104 | exports.executeTemplate = async ({username, tempDockerDir, resultStream, util, docker}) => { 105 | try { 106 | // generate new dockerfile 107 | const dockerfile = nginxDockerfile; 108 | // write the file to project temp dir 109 | const dfPath = path.join(tempDockerDir, 'Dockerfile'); 110 | fs.writeFileSync(dfPath, dockerfile, 'utf-8'); 111 | // send log to user 112 | util.writeStatus(resultStream, {message: 'Deploying Static HTML project..', level: 'info'}); 113 | 114 | // build docker image 115 | const buildRes = await docker.build({username, resultStream}); 116 | // send results to user 117 | util.logger.debug('Build result:', buildRes); 118 | 119 | // check for errors in build log 120 | if ( 121 | buildRes.log 122 | .map(it => it.toLowerCase()) 123 | .some(it => it.includes('error') || (it.includes('failed') && !it.includes('optional'))) 124 | ) { 125 | // if there are - add to server log 126 | util.logger.debug('Build log contains error!'); 127 | // and report to user 128 | util.writeStatus(resultStream, {message: 'Build log contains errors!', level: 'error'}); 129 | // and end the result stream immediately 130 | resultStream.end(''); 131 | return; 132 | } 133 | 134 | // start image 135 | const containerInfo = await docker.start(Object.assign({}, buildRes, {username, resultStream})); 136 | // log results in server logs 137 | util.logger.debug(containerInfo.Name); 138 | 139 | // clean temp folder 140 | await util.cleanTemp(); 141 | 142 | // get container info 143 | const containerData = docker.daemon.getContainer(containerInfo.Id); 144 | const container = await containerData.inspect(); 145 | // return new deployments to user 146 | util.writeStatus(resultStream, {message: 'Deployment success!', deployments: [container], level: 'info'}); 147 | // end result stream 148 | resultStream.end(''); 149 | } catch (e) { 150 | // if there was an error - log it in server log 151 | util.logger.debug('build failed!', e); 152 | // return it to user 153 | util.writeStatus(resultStream, {message: e.error, error: e.error, log: e.log, level: 'error'}); 154 | // end result stream 155 | resultStream.end(''); 156 | } 157 | }; 158 | ``` 159 | 160 | ## Examples 161 | 162 | - [Core templates](https://github.com/exoframejs/exoframe-server/tree/master/src/docker/templates) (incl. node, nginx, dockerfile and docker-compose) 163 | - [Maven template](https://github.com/exoframejs/exoframe-template-maven) 164 | - [Java template](https://github.com/exoframejs/exoframe-template-java) 165 | - [Tomcat template](https://github.com/exoframejs/exoframe-template-tomcat) 166 | -------------------------------------------------------------------------------- /test/__snapshots__/deploy.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should deauth on 401 1`] = ` 4 | Array [ 5 | Array [ 6 | "Deploying test/fixtures/test_html_project to endpoint:", 7 | "http://localhost:8080", 8 | ], 9 | Array [ 10 | "Error: authorization expired!", 11 | "Please, relogin and try again.", 12 | ], 13 | ] 14 | `; 15 | 16 | exports[`Should deploy 1`] = ` 17 | Array [ 18 | Array [ 19 | "Deploying test/fixtures/test_html_project to endpoint:", 20 | "http://localhost:8080", 21 | ], 22 | Array [ 23 | "Your project is now deployed as: 24 | ", 25 | ], 26 | Array [ 27 | " ID URL Hostname Type 28 | test localhost test Container ", 29 | ], 30 | ] 31 | `; 32 | 33 | exports[`Should deploy with custom config 1`] = ` 34 | Array [ 35 | Array [ 36 | "Deploying test/fixtures/test_custom_config_project to endpoint:", 37 | "http://localhost:8080", 38 | ], 39 | Array [ 40 | "Your project is now deployed as: 41 | ", 42 | ], 43 | Array [ 44 | " ID URL Hostname Type 45 | test localhost test Container ", 46 | ], 47 | ] 48 | `; 49 | 50 | exports[`Should deploy with endpoint flag 1`] = ` 51 | Array [ 52 | Array [ 53 | "Deploying test/fixtures/test_html_project to endpoint:", 54 | "http://localhost:3000", 55 | ], 56 | Array [ 57 | "Your project is now deployed as: 58 | ", 59 | ], 60 | Array [ 61 | " ID URL Hostname Type 62 | test localhost test Container ", 63 | ], 64 | ] 65 | `; 66 | 67 | exports[`Should deploy without auth but with token 1`] = ` 68 | Array [ 69 | Array [ 70 | "Deploying current project to endpoint:", 71 | "http://localhost:8080", 72 | ], 73 | Array [ 74 | " 75 | Deploying using given token..", 76 | ], 77 | Array [ 78 | "Your project is now deployed as: 79 | ", 80 | ], 81 | Array [ 82 | " ID URL Hostname Type 83 | test localhost test Container ", 84 | ], 85 | ] 86 | `; 87 | 88 | exports[`Should deploy without path 1`] = ` 89 | Array [ 90 | Array [ 91 | "Deploying current project to endpoint:", 92 | "http://localhost:8080", 93 | ], 94 | Array [ 95 | "Your project is now deployed as: 96 | ", 97 | ], 98 | Array [ 99 | " ID URL Hostname Type 100 | test localhost test Container ", 101 | ], 102 | ] 103 | `; 104 | 105 | exports[`Should display error log 1`] = ` 106 | Array [ 107 | Array [ 108 | "Deploying current project to endpoint:", 109 | "http://localhost:8080", 110 | ], 111 | Array [ 112 | "Error deploying project:", 113 | "Build failed! See build log for details.", 114 | ], 115 | Array [ 116 | "Build log: 117 | ", 118 | ], 119 | Array [ 120 | "Error log", 121 | ], 122 | Array [ 123 | "here", 124 | ], 125 | ] 126 | `; 127 | 128 | exports[`Should display error on malformed JSON 1`] = ` 129 | Array [ 130 | Array [ 131 | "Deploying current project to endpoint:", 132 | "http://localhost:8080", 133 | ], 134 | Array [ 135 | "Error deploying project:", 136 | "Bad Gateway", 137 | ], 138 | Array [ 139 | "Build log: 140 | ", 141 | ], 142 | Array [ 143 | "No log available", 144 | ], 145 | ] 146 | `; 147 | 148 | exports[`Should display error on zero deployments 1`] = ` 149 | Array [ 150 | Array [ 151 | "Deploying current project to endpoint:", 152 | "http://localhost:8080", 153 | ], 154 | Array [ 155 | "Error deploying project:", 156 | "Error: Something went wrong!", 157 | ], 158 | Array [ 159 | "Build log: 160 | ", 161 | ], 162 | Array [ 163 | "No log available", 164 | ], 165 | ] 166 | `; 167 | 168 | exports[`Should display verbose output 1`] = ` 169 | Array [ 170 | Array [ 171 | "Deploying test/fixtures/test_ignore_project to endpoint:", 172 | "http://localhost:8080", 173 | ], 174 | Array [ 175 | " 176 | Ignoring following paths:", 177 | Array [ 178 | "yarn.lock", 179 | "ignore.me", 180 | ".exoframeignore", 181 | ], 182 | ], 183 | Array [ 184 | "[error]", 185 | "Error parsing line:", 186 | "Bad Gateway", 187 | ], 188 | Array [ 189 | "Error deploying project:", 190 | "Bad Gateway", 191 | ], 192 | Array [ 193 | "Build log: 194 | ", 195 | ], 196 | Array [ 197 | "No log available", 198 | ], 199 | Array [ 200 | "", 201 | ], 202 | ] 203 | `; 204 | 205 | exports[`Should execute update 1`] = ` 206 | Array [ 207 | Array [ 208 | "Updating current project to endpoint:", 209 | "http://localhost:8080", 210 | ], 211 | Array [ 212 | "Your project is now deployed as: 213 | ", 214 | ], 215 | Array [ 216 | " ID URL Hostname Type 217 | test localhost test Container ", 218 | ], 219 | ] 220 | `; 221 | 222 | exports[`Should ignore specified files 1`] = ` 223 | Array [ 224 | Array [ 225 | "Deploying test/fixtures/test_ignore_project to endpoint:", 226 | "http://localhost:8080", 227 | ], 228 | Array [ 229 | "Your project is now deployed as: 230 | ", 231 | ], 232 | Array [ 233 | " ID URL Hostname Type 234 | test localhost test Container ", 235 | ], 236 | ] 237 | `; 238 | 239 | exports[`Should not deploy with broken config 1`] = ` 240 | Array [ 241 | Array [ 242 | "Deploying current project to endpoint:", 243 | "http://localhost:8080", 244 | ], 245 | Array [ 246 | "Please, check your config and try again:", 247 | "SyntaxError: Unexpected token I in JSON at position 0", 248 | ], 249 | ] 250 | `; 251 | 252 | exports[`Should not deploy with config without project name 1`] = ` 253 | Array [ 254 | Array [ 255 | "Deploying current project to endpoint:", 256 | "http://localhost:8080", 257 | ], 258 | Array [ 259 | "Please, check your config and try again:", 260 | "Error: Project should have a valid name in config!", 261 | ], 262 | ] 263 | `; 264 | 265 | exports[`Should not deploy with non-existent path 1`] = ` 266 | Array [ 267 | Array [ 268 | "Deploying i-do-not-exist to endpoint:", 269 | "http://localhost:8080", 270 | ], 271 | Array [ 272 | "Please, check your arguments and try again.", 273 | ], 274 | ] 275 | `; 276 | 277 | exports[`Should open webpage after deploy 1`] = ` 278 | Array [ 279 | Array [ 280 | "Deploying current project to endpoint:", 281 | "http://localhost:8080", 282 | ], 283 | Array [ 284 | "Your project is now deployed as: 285 | ", 286 | ], 287 | Array [ 288 | " ID URL Hostname Type 289 | test localhost test Container ", 290 | ], 291 | ] 292 | `; 293 | -------------------------------------------------------------------------------- /test/login.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | // mock config for testing 3 | jest.mock('../src/config', () => require('./__mocks__/config')); 4 | 5 | // npm packages 6 | const fs = require('fs'); 7 | const path = require('path'); 8 | const nock = require('nock'); 9 | const sinon = require('sinon'); 10 | const inquirer = require('inquirer'); 11 | const jwt = require('jsonwebtoken'); 12 | 13 | // our packages 14 | const {handler: login} = require('../src/commands/login'); 15 | const cfg = require('../src/config'); 16 | 17 | const token = 'test-token'; 18 | const loginRequest = {phrase: 'test', uid: '123'}; 19 | const privateKeyName = path.join(__dirname, 'fixtures', 'id_rsa'); 20 | const privateKeyNameBroken = path.join(__dirname, 'fixtures', 'id_rsa_b'); 21 | const privateKeyWithPassphrase = path.join(__dirname, 'fixtures', 'id_rsa_keyphrase'); 22 | const cert = fs.readFileSync(privateKeyName); 23 | const certKey = fs.readFileSync(privateKeyWithPassphrase); 24 | const certBroken = fs.readFileSync(privateKeyNameBroken); 25 | const reqToken = jwt.sign(loginRequest.phrase, cert, {algorithm: 'RS256'}); 26 | const reqTokenKey = jwt.sign(loginRequest.phrase, {key: certKey, passphrase: 'test123'}, {algorithm: 'RS256'}); 27 | const reqTokenBroken = jwt.sign(loginRequest.phrase, certBroken, {algorithm: 'RS256'}); 28 | const correctLogin = {user: {username: 'admin'}, token: reqToken, requestId: loginRequest.uid}; 29 | const correctLoginWithPassphrase = { 30 | user: {username: 'admin'}, 31 | token: reqTokenKey, 32 | requestId: loginRequest.uid, 33 | }; 34 | const failedLogin = {user: {username: 'broken'}, token: reqTokenBroken, requestId: loginRequest.uid}; 35 | const wrongUser = {username: 'wrong', privateKeyName: 'i am broken', password: ''}; 36 | const testEndpointUrl = 'http://my-awesome-endpoint'; 37 | 38 | // handle correct request 39 | nock('http://localhost:8080').get('/login').times(4).reply(200, loginRequest); 40 | const correctLoginSrv = nock('http://localhost:8080').post('/login', correctLogin).reply(200, {token}); 41 | const correctLoginPassSrv = nock('http://localhost:8080') 42 | .post('/login', correctLoginWithPassphrase) 43 | .reply(200, {token}); 44 | const failedLoginSrv = nock('http://localhost:8080').post('/login', failedLogin).reply(401); 45 | 46 | // handle login request to second test endpoint 47 | nock(testEndpointUrl).get('/login').reply(200, loginRequest); 48 | const correctEndpointLoginSrv = nock(testEndpointUrl).post('/login', correctLogin).reply(200, {token}); 49 | 50 | // test login 51 | test('Should login', done => { 52 | // stup inquirer answers 53 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve(correctLogin.user)); 54 | // spy on console 55 | const consoleSpy = sinon.spy(console, 'log'); 56 | // execute login 57 | login({key: privateKeyName}).then(() => { 58 | // make sure log in was successful 59 | // check that server was called 60 | expect(correctLoginSrv.isDone()).toBeTruthy(); 61 | // first check console output 62 | expect(consoleSpy.args).toMatchSnapshot(); 63 | // then check config changes 64 | expect(cfg.userConfig.token).toEqual(token); 65 | expect(cfg.userConfig.user.username).toEqual(correctLogin.user.username); 66 | // restore inquirer 67 | inquirer.prompt.restore(); 68 | // restore console 69 | console.log.restore(); 70 | done(); 71 | }); 72 | }); 73 | 74 | // test login 75 | test('Should login using key with passphrase', done => { 76 | // stup inquirer answers 77 | sinon 78 | .stub(inquirer, 'prompt') 79 | .callsFake(() => Promise.resolve(Object.assign({}, correctLoginWithPassphrase.user, {password: 'test123'}))); 80 | // spy on console 81 | const consoleSpy = sinon.spy(console, 'log'); 82 | // execute login 83 | login({key: privateKeyWithPassphrase}).then(() => { 84 | // make sure log in was successful 85 | // check that server was called 86 | expect(correctLoginPassSrv.isDone()).toBeTruthy(); 87 | // first check console output 88 | expect(consoleSpy.args).toMatchSnapshot(); 89 | // then check config changes 90 | expect(cfg.userConfig.token).toEqual(token); 91 | expect(cfg.userConfig.user.username).toEqual(correctLoginWithPassphrase.user.username); 92 | // restore inquirer 93 | inquirer.prompt.restore(); 94 | // restore console 95 | console.log.restore(); 96 | done(); 97 | }); 98 | }); 99 | 100 | // test wrong credentials 101 | test('Should fail to login with broken private key', done => { 102 | // stup inquirer answers 103 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve(wrongUser)); 104 | // spy on console 105 | const consoleSpy = sinon.spy(console, 'log'); 106 | // execute login 107 | login({key: 'asd'}).then(() => { 108 | // first check console output 109 | expect(consoleSpy.args).toMatchSnapshot(); 110 | // then check the config (should not change) 111 | expect(cfg.userConfig.token).toEqual(token); 112 | expect(cfg.userConfig.user.username).toEqual(correctLogin.user.username); 113 | // restore inquirer 114 | inquirer.prompt.restore(); 115 | // restore console 116 | console.log.restore(); 117 | done(); 118 | }); 119 | }); 120 | 121 | // test failure 122 | test('Should not login with wrong certificate', done => { 123 | // stup inquirer answers 124 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve(failedLogin.user)); 125 | // spy on console 126 | const consoleSpy = sinon.spy(console, 'log'); 127 | // execute login 128 | login({key: privateKeyNameBroken}).then(() => { 129 | // make sure log in was successful 130 | // check that server was called 131 | expect(failedLoginSrv.isDone()).toBeTruthy(); 132 | // first check console output 133 | expect(consoleSpy.args).toMatchSnapshot(); 134 | // restore inquirer 135 | inquirer.prompt.restore(); 136 | // restore console 137 | console.log.restore(); 138 | done(); 139 | }); 140 | }); 141 | 142 | // test login 143 | test('Should login and update endpoint when endpoint was provided', done => { 144 | // stup inquirer answers 145 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve(correctLogin.user)); 146 | // spy on console 147 | const consoleSpy = sinon.spy(console, 'log'); 148 | // execute login 149 | login({url: testEndpointUrl, key: privateKeyName}).then(() => { 150 | // make sure log in was successful 151 | // check that server was called 152 | expect(correctEndpointLoginSrv.isDone()).toBeTruthy(); 153 | // first check console output 154 | expect(consoleSpy.args).toMatchSnapshot(); 155 | // then check config changes 156 | expect(cfg.userConfig.token).toEqual(token); 157 | expect(cfg.userConfig.user.username).toEqual(correctLogin.user.username); 158 | expect(cfg.userConfig.endpoint).toEqual(testEndpointUrl); 159 | // restore inquirer 160 | inquirer.prompt.restore(); 161 | // restore console 162 | console.log.restore(); 163 | done(); 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /src/commands/secrets.js: -------------------------------------------------------------------------------- 1 | // npm packages 2 | const got = require('got'); 3 | const chalk = require('chalk'); 4 | const inquirer = require('inquirer'); 5 | 6 | // our packages 7 | const {userConfig, isLoggedIn, logout} = require('../config'); 8 | 9 | exports.command = ['secret [cmd] [name] [value]']; 10 | exports.describe = 'create, list or remove deployment secrets'; 11 | exports.builder = { 12 | cmd: { 13 | default: 'new', 14 | description: 'command to execute [new | ls | get | rm]', 15 | }, 16 | name: { 17 | description: 'name of the secret', 18 | }, 19 | value: { 20 | description: 'new value of the secret', 21 | }, 22 | }; 23 | exports.handler = async args => { 24 | if (!isLoggedIn()) { 25 | return; 26 | } 27 | 28 | // services request url 29 | const remoteUrl = `${userConfig.endpoint}/secrets`; 30 | // get command 31 | const {cmd} = args; 32 | // if remove or ls - fetch secrets from remote, then do work 33 | if (cmd === 'ls' || cmd === 'rm' || cmd === 'get') { 34 | const actions = { 35 | ls: 'Listing', 36 | rm: 'Removing', 37 | get: 'Getting', 38 | }; 39 | console.log(chalk.bold(`${actions[cmd]} deployment secret${cmd === 'ls' ? 's' : ''} for:`), userConfig.endpoint); 40 | 41 | // get secrets from server 42 | // construct shared request params 43 | const options = { 44 | method: 'GET', 45 | headers: { 46 | Authorization: `Bearer ${userConfig.token}`, 47 | }, 48 | responseType: 'json', 49 | }; 50 | // try sending request 51 | let secrets = []; 52 | try { 53 | const {body} = await got(remoteUrl, options); 54 | secrets = body.secrets; 55 | } catch (e) { 56 | // if authorization is expired/broken/etc 57 | if (e.response.statusCode === 401) { 58 | logout(userConfig); 59 | console.log(chalk.red('Error: authorization expired!'), 'Please, relogin and try again.'); 60 | return; 61 | } 62 | 63 | console.log(chalk.red('Error getting deployment secrets:'), e.toString()); 64 | return; 65 | } 66 | 67 | if (cmd === 'ls') { 68 | console.log(chalk.bold('Got saved secrets:')); 69 | console.log(''); 70 | secrets.map(t => 71 | console.log(` > ${chalk.green(`@${t.name}`)} ${chalk.gray(`[${new Date(t.meta.created).toLocaleString()}]`)}`) 72 | ); 73 | if (!secrets.length) { 74 | console.log(' > No deployment secrets available!'); 75 | } 76 | return; 77 | } 78 | 79 | // get selected secret from args 80 | let selectedSecret = args.name; 81 | 82 | // if it's not provided - present user with selection 83 | if (!selectedSecret || !selectedSecret.length) { 84 | const prompts = []; 85 | prompts.push({ 86 | type: 'list', 87 | name: 'selectedSecret', 88 | message: `Choose secret to ${cmd === 'get' ? 'get' : 'remove'}:`, 89 | choices: secrets.map(t => t.name), 90 | }); 91 | ({selectedSecret} = await inquirer.prompt(prompts)); 92 | } 93 | 94 | // if getting secret - ask user once more if he's sure 95 | if (cmd === 'get') { 96 | const {doGet} = await inquirer.prompt([ 97 | { 98 | type: 'confirm', 99 | name: 'doGet', 100 | message: 'Get secret value? (will be shown in plain text)', 101 | default: false, 102 | }, 103 | ]); 104 | 105 | if (!doGet) { 106 | console.log(chalk.red('Stopping!'), 'User decided not to read secret value..'); 107 | return; 108 | } 109 | 110 | // get secrets from server 111 | // construct shared request params 112 | const options = { 113 | method: 'GET', 114 | headers: { 115 | Authorization: `Bearer ${userConfig.token}`, 116 | }, 117 | responseType: 'json', 118 | }; 119 | let secret; 120 | try { 121 | const {body} = await got(`${remoteUrl}/${selectedSecret}`, options); 122 | secret = body.secret; 123 | } catch (e) { 124 | // if authorization is expired/broken/etc 125 | if (e.response.statusCode === 401) { 126 | logout(userConfig); 127 | console.log(chalk.red('Error: authorization expired!'), 'Please, relogin and try again.'); 128 | return; 129 | } 130 | 131 | console.log(chalk.red('Error getting deployment secret:'), e.toString()); 132 | return; 133 | } 134 | 135 | console.log(chalk.bold('New secret generated:')); 136 | console.log(''); 137 | console.log(`Name: ${secret.name}`); 138 | console.log(`Value: ${secret.value}`); 139 | console.log(`Date: ${new Date(secret.meta.created).toLocaleString()}`); 140 | 141 | return; 142 | } 143 | 144 | // construct shared request params 145 | const rmOptions = { 146 | method: 'DELETE', 147 | headers: { 148 | Authorization: `Bearer ${userConfig.token}`, 149 | }, 150 | responseType: 'json', 151 | json: { 152 | secretName: selectedSecret, 153 | }, 154 | }; 155 | try { 156 | const {body, statusCode} = await got(remoteUrl, rmOptions); 157 | if (statusCode !== 204) { 158 | console.log(chalk.red('Error removing deployment secret!'), body.reason || 'Please try again!'); 159 | return; 160 | } 161 | console.log(chalk.green('Deployment secret successfully removed!')); 162 | } catch (e) { 163 | // if authorization is expired/broken/etc 164 | if (e.response.statusCode === 401) { 165 | logout(userConfig); 166 | console.log(chalk.red('Error: authorization expired!'), 'Please, relogin and try again.'); 167 | return; 168 | } 169 | 170 | console.log(chalk.red('Error removing secret:'), e.toString()); 171 | return; 172 | } 173 | 174 | return; 175 | } 176 | 177 | console.log(chalk.bold('Generating new deployment secret for:'), userConfig.endpoint); 178 | 179 | let secretName = args.name; 180 | let secretValue = args.value; 181 | 182 | // if user haven't provided name and value - ask interactively 183 | if (!secretName || !secretValue) { 184 | // ask for secret name and value 185 | const prompts = []; 186 | prompts.push({ 187 | type: 'input', 188 | name: 'secretName', 189 | message: 'Secret name:', 190 | validate: input => input && input.length > 0, 191 | filter: input => input.trim(), 192 | }); 193 | prompts.push({ 194 | type: 'input', 195 | name: 'secretValue', 196 | message: 'Secret value:', 197 | validate: input => input && input.length > 0, 198 | filter: input => input.trim(), 199 | }); 200 | ({secretName, secretValue} = await inquirer.prompt(prompts)); 201 | } 202 | 203 | // construct shared request params 204 | const options = { 205 | method: 'POST', 206 | headers: { 207 | Authorization: `Bearer ${userConfig.token}`, 208 | }, 209 | responseType: 'json', 210 | json: { 211 | secretName, 212 | secretValue, 213 | }, 214 | }; 215 | // try sending request 216 | try { 217 | const {body} = await got(remoteUrl, options); 218 | console.log(chalk.bold('New secret generated:')); 219 | console.log(''); 220 | console.log(`Name: ${body.name}`); 221 | console.log(`Value: ${body.value}`); 222 | console.log(''); 223 | console.log(chalk.green('DONE!')); 224 | } catch (e) { 225 | // if authorization is expired/broken/etc 226 | if (e.response.statusCode === 401) { 227 | logout(userConfig); 228 | console.log(chalk.red('Error: authorization expired!'), 'Please, relogin and try again.'); 229 | return; 230 | } 231 | 232 | console.log(chalk.red('Error generating deployment secret:'), e.toString()); 233 | } 234 | }; 235 | -------------------------------------------------------------------------------- /test/secrets.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | // mock config for testing 3 | jest.mock('../src/config', () => require('./__mocks__/config')); 4 | 5 | // npm packages 6 | const nock = require('nock'); 7 | const sinon = require('sinon'); 8 | const inquirer = require('inquirer'); 9 | 10 | // our packages 11 | const {handler: secrets} = require('../src/commands/secrets'); 12 | const cfg = require('../src/config'); 13 | 14 | const testSecret = { 15 | secretName: 'test', 16 | secretValue: '12345', 17 | }; 18 | 19 | // test generation 20 | test('Should create new secret', done => { 21 | // handle correct request 22 | const secretServer = nock('http://localhost:8080') 23 | .post('/secrets') 24 | .reply(200, {name: testSecret.secretName, value: testSecret.secretValue}); 25 | // spy on console 26 | const consoleSpy = sinon.spy(console, 'log'); 27 | // stup inquirer answers 28 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve(testSecret)); 29 | // execute login 30 | secrets({}).then(() => { 31 | // make sure log in was successful 32 | // check that server was called 33 | expect(secretServer.isDone()).toBeTruthy(); 34 | // first check console output 35 | expect(consoleSpy.args).toMatchSnapshot(); 36 | // restore console 37 | console.log.restore(); 38 | // restore inquirer 39 | inquirer.prompt.restore(); 40 | // tear down nock 41 | secretServer.done(); 42 | done(); 43 | }); 44 | }); 45 | 46 | // test non-interactive generation 47 | test('Should create new secret non-interactively', done => { 48 | // handle correct request 49 | const secretServer = nock('http://localhost:8080') 50 | .post('/secrets') 51 | .reply(200, {name: testSecret.secretName, value: testSecret.secretValue}); 52 | // spy on console 53 | const consoleSpy = sinon.spy(console, 'log'); 54 | // execute login 55 | secrets({cmd: 'new', name: testSecret.secretName, value: testSecret.secretValue}).then(() => { 56 | // make sure log in was successful 57 | // check that server was called 58 | expect(secretServer.isDone()).toBeTruthy(); 59 | // first check console output 60 | expect(consoleSpy.args).toMatchSnapshot(); 61 | // restore console 62 | console.log.restore(); 63 | // tear down nock 64 | secretServer.done(); 65 | done(); 66 | }); 67 | }); 68 | 69 | // test list 70 | test('Should list secrets', done => { 71 | const createDate = new Date(2017, 1, 1, 1, 1, 1, 1); 72 | // handle correct request 73 | const secretsServer = nock('http://localhost:8080') 74 | .get('/secrets') 75 | .reply(200, {secrets: [{name: testSecret.secretName, meta: {created: createDate}}]}); 76 | // spy on console 77 | const consoleSpy = sinon.spy(console, 'log'); 78 | // execute login 79 | secrets({cmd: 'ls'}).then(() => { 80 | // make sure log in was successful 81 | // check that server was called 82 | expect(secretsServer.isDone()).toBeTruthy(); 83 | // first check console output 84 | expect(consoleSpy.args.map(lines => lines.map(l => l.replace(createDate.toLocaleString(), '')))).toMatchSnapshot(); 85 | // restore console 86 | console.log.restore(); 87 | // tear down nock 88 | secretsServer.done(); 89 | done(); 90 | }); 91 | }); 92 | 93 | // test getting 94 | test('Should get secret value', done => { 95 | const createDate = new Date(2018, 1, 1, 1, 1, 1, 1); 96 | // handle correct request 97 | const secretGetServer = nock('http://localhost:8080') 98 | .get('/secrets') 99 | .reply(200, {secrets: [{name: testSecret.secretName, meta: {created: createDate}}]}); 100 | // handle correct request 101 | const secretServer = nock('http://localhost:8080') 102 | .get(`/secrets/${testSecret.secretName}`) 103 | .reply(200, {secret: {...testSecret, meta: {created: createDate}}}); 104 | // spy on console 105 | const consoleSpy = sinon.spy(console, 'log'); 106 | // stup inquirer answers 107 | sinon 108 | .stub(inquirer, 'prompt') 109 | .onFirstCall() 110 | .callsFake(() => Promise.resolve({selectedSecret: testSecret.secretName})) 111 | .onSecondCall() 112 | .callsFake(() => Promise.resolve({doGet: true})); 113 | // execute login 114 | secrets({cmd: 'get'}).then(() => { 115 | // make sure log in was successful 116 | // check that server was called 117 | expect(secretGetServer.isDone()).toBeTruthy(); 118 | expect(secretServer.isDone()).toBeTruthy(); 119 | // first check console output 120 | expect(consoleSpy.args).toMatchSnapshot(); 121 | // restore console 122 | console.log.restore(); 123 | // restore inquirer 124 | inquirer.prompt.restore(); 125 | // tear down nock 126 | secretGetServer.done(); 127 | secretServer.done(); 128 | done(); 129 | }); 130 | }); 131 | 132 | test('Should list zero secrets', done => { 133 | // handle correct request 134 | const secretsServer = nock('http://localhost:8080').get('/secrets').reply(200, {secrets: []}); 135 | // spy on console 136 | const consoleSpy = sinon.spy(console, 'log'); 137 | // execute login 138 | secrets({cmd: 'ls'}).then(() => { 139 | // make sure log in was successful 140 | // check that server was called 141 | expect(secretsServer.isDone()).toBeTruthy(); 142 | // first check console output 143 | expect(consoleSpy.args).toMatchSnapshot(); 144 | // restore console 145 | console.log.restore(); 146 | // tear down nock 147 | secretsServer.done(); 148 | done(); 149 | }); 150 | }); 151 | 152 | // test removal 153 | test('Should remove secret', done => { 154 | const createDate = new Date(); 155 | // handle correct request 156 | const secretGetServer = nock('http://localhost:8080') 157 | .get('/secrets') 158 | .reply(200, {secrets: [{name: testSecret.secretName, meta: {created: createDate}}]}); 159 | // handle correct request 160 | const secretServer = nock('http://localhost:8080').delete('/secrets').reply(204, ''); 161 | // spy on console 162 | const consoleSpy = sinon.spy(console, 'log'); 163 | // stup inquirer answers 164 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve({selectedSecret: testSecret.secretName})); 165 | // execute login 166 | secrets({cmd: 'rm'}).then(() => { 167 | // make sure log in was successful 168 | // check that server was called 169 | expect(secretGetServer.isDone()).toBeTruthy(); 170 | expect(secretServer.isDone()).toBeTruthy(); 171 | // first check console output 172 | expect(consoleSpy.args).toMatchSnapshot(); 173 | // restore console 174 | console.log.restore(); 175 | // restore inquirer 176 | inquirer.prompt.restore(); 177 | // tear down nock 178 | secretGetServer.done(); 179 | secretServer.done(); 180 | done(); 181 | }); 182 | }); 183 | 184 | // test deauth 185 | test('Should deauth on 401 on creation', done => { 186 | // save current config state 187 | cfg.__save('token'); 188 | // handle correct request 189 | const secretServer = nock('http://localhost:8080').post('/secrets').reply(401); 190 | // spy on console 191 | const consoleSpy = sinon.spy(console, 'log'); 192 | // stup inquirer answers 193 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve({secretName: 'test'})); 194 | // execute login 195 | secrets({}).then(() => { 196 | // make sure log in was successful 197 | // check that server was called 198 | expect(secretServer.isDone()).toBeTruthy(); 199 | // first check console output 200 | expect(consoleSpy.args).toMatchSnapshot(); 201 | // restore console 202 | console.log.restore(); 203 | // restore inquirer 204 | inquirer.prompt.restore(); 205 | // tear down nock 206 | secretServer.done(); 207 | done(); 208 | }); 209 | }); 210 | 211 | test('Should deauth on 401 on list', done => { 212 | // restore config with auth 213 | cfg.__restore('token'); 214 | // handle correct request 215 | const secretServer = nock('http://localhost:8080').get('/secrets').reply(401); 216 | // spy on console 217 | const consoleSpy = sinon.spy(console, 'log'); 218 | // stup inquirer answers 219 | sinon.stub(inquirer, 'prompt').callsFake(() => Promise.resolve({secretName: 'test'})); 220 | // execute login 221 | secrets({cmd: 'ls'}).then(() => { 222 | // make sure log in was successful 223 | // check that server was called 224 | expect(secretServer.isDone()).toBeTruthy(); 225 | // first check console output 226 | expect(consoleSpy.args).toMatchSnapshot(); 227 | // restore console 228 | console.log.restore(); 229 | // restore inquirer 230 | inquirer.prompt.restore(); 231 | // tear down nock 232 | secretServer.done(); 233 | done(); 234 | }); 235 | }); 236 | --------------------------------------------------------------------------------