├── 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 |Test test test.
8 | 9 | 10 | -------------------------------------------------------------------------------- /test/fixtures/test_html_project/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |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 |Test test test.
8 | 9 | 10 | -------------------------------------------------------------------------------- /test/fixtures/test_compose_project/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |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.4 2017-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
2 |
3 | > Simple Docker deployment tool
4 |
5 | [](https://github.com/exoframejs/exoframe/actions?query=workflow%3ATest)
6 | [](https://coveralls.io/github/exoframejs/exoframe?branch=master)
7 | [](https://www.npmjs.com/package/exoframe)
8 | [](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 | [](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 |
--------------------------------------------------------------------------------