├── .gitignore ├── README.md ├── lib └── MetaSync.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | dist 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | 18 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 19 | .grunt 20 | 21 | # node-waf configuration 22 | .lock-wscript 23 | 24 | # Compiled binary addons (http://nodejs.org/api/addons.html) 25 | build/Release 26 | 27 | # Dependency directory 28 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 29 | node_modules 30 | 31 | #IDE Stuff 32 | **/.idea 33 | 34 | #OS STUFF 35 | .DS_Store 36 | .tmp 37 | 38 | #SERVERLESS STUFF 39 | admin.env 40 | .env -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Serverless](http://serverless.com/) Meta Sync Plugin 2 | 3 | [![serverless](http://public.serverless.com/badges/v3.svg)](http://www.serverless.com) 4 | [![gitter](https://img.shields.io/gitter/room/serverless/serverless.svg)](https://gitter.im/serverless/serverless) 5 | [![version](https://img.shields.io/npm/v/serverless-meta-sync.svg)](https://www.npmjs.com/package/serverless-meta-sync) 6 | [![downloads](https://img.shields.io/npm/dm/serverless-meta-sync.svg)](https://www.npmjs.com/package/serverless-meta-sync) 7 | [![dependencies](https://img.shields.io/david/serverless/serverless-meta-sync.svg)](https://www.npmjs.com/package/serverless-meta-sync) 8 | [![license](https://img.shields.io/npm/l/serverless-meta-sync.svg)](https://www.npmjs.com/package/serverless-meta-sync) 9 | 10 | Secure syncing of Serverless project's meta data across teams (via S3 bucket). 11 | 12 | This plugin adds a `serverless meta sync` command. When you run it with a stage or a region `-s dev -r us-east-1`, this plugin will first find or create an S3 bucket using the credentials you have set for that stage, then sync the variables files you have locally with the ones on the S3 bucket. For example, running `serverless meta sync -s dev` will sync your project's `s-variables-dev.json` with the `s-variables-dev.json` located on the S3 bucket. 13 | 14 | When used via the CLI and conflicts are found, an interactive screen will let you easily select which option to use. When used without the CLI, the files located remotely automatically overwrite the files located locally, which is useful when used in the beginning of CI processes. 15 | 16 | ## Demo 17 | [![asciicast](https://asciinema.org/a/40566.png)](https://asciinema.org/a/40566) 18 | 19 | ## Setup 20 | 21 | * Install via npm in the root of your Serverless Project: 22 | ``` 23 | npm install serverless-meta-sync --save 24 | ``` 25 | 26 | * Add the plugin to the `plugins` array and to the `custom` object in your Serverless Project's `s-project.json`, like this: 27 | 28 | ```js 29 | "custom": { 30 | "meta": { 31 | "name": "YOUR_SYNC_S3_BUCKET_NAME", 32 | "region": "S3_BUCKET_REGION", 33 | 34 | // Optional, by default: "serverless/PROJECT_NAME/variables/" 35 | "keyPrefix": "S3_KEY_PREFIX" 36 | } 37 | }, 38 | "plugins": [ 39 | "serverless-meta-sync" 40 | ] 41 | ``` 42 | 43 | * All done! 44 | 45 | ## Usage 46 | Run: `serverless meta sync`. 47 | 48 | ### Options 49 | * `-s` `--stage` — Stage. Optional if only one stage is defined in project. This will only sync the variables file of the specified stage (e.g., `s-variables-dev.json`). 50 | * `-r` `--region` — Region. Optional. This will only sync the variables file for the specified region in the specified stage (e.g., `s-variables-dev-useast1.json`). 51 | -------------------------------------------------------------------------------- /lib/MetaSync.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Serverless Meta Sync Plugin 5 | * - Sync your variables via S3 bucket 6 | */ 7 | 8 | const prefix = (pfx, str) => str.split('\n').map(line => pfx + line).join('\n'); 9 | 10 | const path = require('path'), 11 | fs = require('fs'), 12 | _ = require('lodash'), 13 | chalk = require('chalk'), 14 | BbPromise = require('bluebird'); 15 | 16 | module.exports = function(S) { 17 | const SError = require(S.getServerlessPath('Error')); 18 | const SCli = require(S.getServerlessPath('utils/cli')); 19 | const diffString = require('json-diff').diffString; 20 | const diff = require('json-diff').diff; 21 | 22 | class MetaSync extends S.classes.Plugin { 23 | 24 | constructor() { 25 | super(); 26 | this.name = 'metaSync'; // Define your plugin's name 27 | } 28 | 29 | registerActions() { 30 | 31 | S.addAction(this.metaSync.bind(this), { 32 | handler: 'metaSync', 33 | description: 'This command allows you to share your Serverless project\'s meta files via S3 bucket', 34 | context: 'meta', 35 | contextAction: 'sync', 36 | options: [ 37 | { 38 | option: 'stage', 39 | shortcut: 's', 40 | description: 'Optional if only one stage is defined in project' 41 | }, { 42 | option: 'region', 43 | shortcut: 'r', 44 | description: 'Optional - Region' 45 | }, 46 | ], 47 | parameters: [] 48 | }); 49 | 50 | return BbPromise.resolve(); 51 | } 52 | 53 | 54 | metaSync(evt) { 55 | this.evt = evt; 56 | return this._prompt() 57 | .bind(this) 58 | .then(this._validateAndPrepare) 59 | .then(this._sync) 60 | .then(() => { 61 | SCli.log(`Done`); 62 | return this.evt; 63 | }) 64 | } 65 | 66 | getKey() { 67 | return this.config.keyPrefix + this.syncFileName; 68 | } 69 | 70 | 71 | /** 72 | * Prompt stage and region 73 | */ 74 | 75 | _prompt() { 76 | // Skip if non-interactive or stage is provided 77 | if (!S.config.interactive || this.evt.options.stage) return BbPromise.resolve(); 78 | 79 | if (!S.getProject().getAllStages().length) return BbPromise.reject(new SError('No existing stages in the project')); 80 | 81 | return this.cliPromptSelectStage('Select an existing stage: ', this.evt.options.stage, false) 82 | .then(stage => this.evt.options.stage = stage) 83 | } 84 | 85 | 86 | _validateAndPrepare() { 87 | const stage = this.evt.options.stage; 88 | const region = this.evt.options.region; 89 | const proj = S.getProject(); 90 | 91 | // validate options 92 | 93 | if (!stage) { 94 | return BbPromise.reject(new SError(`Stage is required!`)) 95 | } 96 | 97 | if (stage && !proj.validateStageExists(stage)) { 98 | return BbPromise.reject(new SError(`Stage ${stage} doesnt exist in this project!`)) 99 | } 100 | 101 | if (stage && region && !proj.validateRegionExists(stage, region)) { 102 | return BbPromise.reject(new SError(`Region ${region} doesnt exist in stage ${stage}!`)) 103 | } 104 | 105 | // loading config 106 | this.config = _.get(proj.toObjectPopulated({stage, region}), 'custom.meta'); 107 | if (!this.config) return BbPromise.reject(new SError(`Meta Sync config must be defined in "s-project.json"!`)); 108 | 109 | // validate config 110 | if (_.isEmpty(this.config.name)) return BbPromise.reject(new SError(`Missing config property "name"!`)); 111 | if (_.isEmpty(this.config.region)) return BbPromise.reject(new SError(`Missing config property "region"!`)); 112 | _.defaults(this.config, {keyPrefix: `serverless/${proj.getName()}/variables/`}); 113 | 114 | // set the file name to sync 115 | if (stage && region) this.syncFileName = `s-variables-${stage}-${S.classes.Region.regionNameToVarsFilename(region)}.json`; 116 | else if (stage) this.syncFileName = `s-variables-${stage}.json`; 117 | 118 | // get local version 119 | this.localVersion = S.utils.readFileSync(proj.getRootPath('_meta', 'variables', this.syncFileName)); 120 | 121 | // get remote version 122 | const params = { 123 | Bucket: this.config.name, 124 | Key: this.getKey() 125 | }; 126 | 127 | return S.getProvider('aws').request('S3', 'getObject', params, stage, this.config.region) 128 | .catch({code: 'NoSuchBucket'}, e => { 129 | this.bucketDoesntExist = true; 130 | return; 131 | }) 132 | .catch({code: 'NoSuchKey'}, e => {} ) 133 | .then(reply => { 134 | if (reply) this.remoteVersion = JSON.parse((new Buffer(reply.Body)).toString()); 135 | }) 136 | } 137 | 138 | _sync() { 139 | if (!this.remoteVersion && !this.localVersion) { 140 | SCli.log(`${this.syncFileName} dosn't exist locally nor on S3`); 141 | return; 142 | } 143 | 144 | if (this.remoteVersion && !this.localVersion) { 145 | SCli.log(`Creating local copy of ${this.syncFileName}...`); 146 | return S.utils.writeFile(proj.getRootPath('_meta', 'variables', this.syncFileName), this.remoteVersion); 147 | } 148 | 149 | if (!this.remoteVersion && this.localVersion) { 150 | SCli.log(`Creating remote copy of ${this.syncFileName}...`); 151 | return this._updateRemote(); 152 | } 153 | 154 | if (S.config.interactive) { 155 | return this._diff(); 156 | } else { 157 | // When used programmatically, it should simply overwrite 158 | // the local project variables with the variables on the S3 Bucket 159 | return this._updateLocal(); 160 | } 161 | } 162 | 163 | _updateLocal(data) { 164 | const proj = S.getProject(); 165 | 166 | S.utils.sDebug(`Overwrite "${this.syncFileName}" with the remote version`); 167 | return S.utils.writeFile(proj.getRootPath('_meta', 'variables', this.syncFileName), data || this.remoteVersion); 168 | } 169 | 170 | _updateRemote(data) { 171 | const stage = this.evt.options.stage; 172 | const region = this.config.region; 173 | 174 | return BbPromise.try(() => { 175 | // create a bucket if it doesn't exist 176 | if (!this.bucketDoesntExist) return; 177 | 178 | S.utils.sDebug(`Creating new bucket "${this.config.name}" in "${region}"`); 179 | 180 | const params = {Bucket: this.config.name}; 181 | 182 | return S.getProvider('aws').request('S3', 'createBucket', params, stage, region); 183 | }) 184 | .then(() => { 185 | const params = { 186 | Bucket: this.config.name, 187 | Key: this.getKey(), 188 | Body: JSON.stringify(data || this.localVersion) 189 | }; 190 | 191 | S.utils.sDebug(`Uploading "${this.syncFileName}" to S3`); 192 | 193 | S.getProvider('aws').request('S3', 'putObject', params, stage, region); 194 | }); 195 | } 196 | 197 | _diff() { 198 | const difference = diffString(this.localVersion, this.remoteVersion); 199 | 200 | SCli.log(`Going to sync "${this.syncFileName}"... \n`); 201 | 202 | if (difference.trim() === 'undefined') { 203 | SCli.log('Resource templates are equal. There is nothing to sync.'); 204 | return; 205 | } 206 | 207 | process.stdout.write(difference); 208 | process.stdout.write("\n"); 209 | 210 | const choices = [ 211 | { 212 | value: "oneByOne", 213 | label: "Review these changes one by one" 214 | }, 215 | { 216 | value: "remote", 217 | label: "Apply these changes to the local version" 218 | }, 219 | { 220 | value: "local", 221 | label: "Discard these changes and sync the remote version with the local one" 222 | }, 223 | { 224 | value: "cancel", 225 | label: "Cancel" 226 | } 227 | ]; 228 | 229 | return this.cliPromptSelect("How should these differences be handled?", choices, false) 230 | .then(values => values[0].value) 231 | .then(choice => { 232 | switch (choice) { 233 | case 'local': 234 | return this._updateRemote(); 235 | case 'remote': 236 | return this._updateLocal(); 237 | case 'oneByOne': 238 | return this._updateOneByOne(); 239 | } 240 | }); 241 | } 242 | 243 | _updateOneByOne() { 244 | const out = _.assign({}, this.localVersion); 245 | let difference = diff(this.localVersion, this.remoteVersion); 246 | 247 | return BbPromise.each(_.keys(difference), (key, i, len) => { 248 | process.stdout.write(chalk.gray(`\n----------------------------------------\nChange ${++i} of ${len}\n\n`)); 249 | 250 | const value = difference[key]; 251 | let propName, action; 252 | 253 | if (key.endsWith('__deleted')) { 254 | action = 'delete'; 255 | propName = key.replace('__deleted', '') 256 | console.log(chalk.red.bold('Delete:')); 257 | console.log(chalk.red(prefix('- ', `${propName}: ${JSON.stringify(value)}`))); 258 | } else if (key.endsWith('__added')) { 259 | action = 'add'; 260 | propName = key.replace('__added', '') 261 | console.log(chalk.green.bold('Add:')); 262 | console.log(chalk.green(prefix('+ ', `${propName}: ${JSON.stringify(value)}`))); 263 | } else if (value.__old && value.__new) { 264 | action = 'update'; 265 | console.log(chalk.yellow.bold('Update:')); 266 | console.log(chalk.dim('Old:')); 267 | console.log(chalk.red(prefix('- ', `${key}: ${JSON.stringify(value.__old, null, 2)}`))); 268 | console.log(chalk.dim('New:')); 269 | console.log(chalk.green(prefix('+ ', `${key}: ${JSON.stringify(value.__new, null, 2)}`))); 270 | } 271 | 272 | process.stdout.write('\n'); 273 | return this._promptChangeAction() 274 | .then(applyChange => { 275 | if (!applyChange) return; 276 | 277 | if (action === 'delete') delete out[propName]; 278 | else if (action === 'add') out[propName] = value; 279 | else if (action === 'update') out[key] = value.__new; 280 | }) 281 | }) 282 | .then(() => { 283 | process.stdout.write(chalk.gray('\n----------------------------------------\n\n')); 284 | 285 | SCli.log('Please, review the selected changes:\n'); 286 | console.log(diffString(this.localVersion, out)); 287 | 288 | const choices = [ 289 | {value: true, label: "Yes"}, 290 | {value: false, label: "Cancel"} 291 | ]; 292 | 293 | return this.cliPromptSelect("Apply these changes to the local version and update the remote one?", choices, false) 294 | .then(values => values[0].value) 295 | .then(applyChanges => { 296 | return applyChanges && BbPromise.all([ 297 | this._updateLocal(out), 298 | this._updateRemote(out) 299 | ]); 300 | }) 301 | .then(() => this.evt.data.out = out); 302 | }) 303 | } 304 | 305 | _promptChangeAction() { 306 | const choices = [ 307 | {label: "Yes", value: true}, 308 | {label: "No", value: false} 309 | ]; 310 | 311 | return this.cliPromptSelect('Apply this change?', choices, false) 312 | .then(values => values[0].value); 313 | } 314 | } 315 | 316 | 317 | return MetaSync; 318 | 319 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-meta-sync", 3 | "version": "0.1.0", 4 | "description": "This plugin allows you to share your Serverless project's meta files via S3 bucket.", 5 | "main": "lib/MetaSync.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/serverless/serverless-meta-sync.git" 12 | }, 13 | "keywords": [ 14 | "serverless", 15 | "plugin", 16 | "sync" 17 | ], 18 | "author": "Egor Kislitsyn ", 19 | "license": "ISC", 20 | "bugs": { 21 | "url": "https://github.com/serverless/serverless-meta-sync/issues" 22 | }, 23 | "homepage": "https://github.com/serverless/serverless-meta-sync#readme", 24 | "dependencies": { 25 | "bluebird": "^3.3.4", 26 | "chalk": "^1.1.1", 27 | "json-diff": "^0.3.1", 28 | "lodash": "^4.6.1" 29 | } 30 | } 31 | --------------------------------------------------------------------------------