├── .editorconfig ├── .gitignore ├── .jshintrc ├── .travis.yml ├── README.md ├── config └── environment.js ├── index.js ├── lib └── s3-adapter.js ├── node_tests ├── helpers │ └── mockS3.js ├── runner.js └── unit │ └── s3-adapter-test.js ├── package.json └── vendor └── .gitkeep /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [*.js] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [*.hbs] 21 | indent_style = space 22 | indent_size = 2 23 | 24 | [*.css] 25 | indent_style = space 26 | indent_size = 2 27 | 28 | [*.html] 29 | indent_style = space 30 | indent_size = 2 31 | 32 | [*.{diff,md}] 33 | trim_trailing_whitespace = false 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /tmp 4 | 5 | # dependencies 6 | /node_modules 7 | /bower_components 8 | 9 | # misc 10 | /.sass-cache 11 | /connect.lock 12 | /coverage/* 13 | /libpeerconnection.log 14 | npm-debug.log 15 | testem.log -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "predef": [ 3 | "console", 4 | "it", 5 | "describe", 6 | "beforeEach", 7 | "afterEach", 8 | "before", 9 | "after", 10 | "-Promise" 11 | ], 12 | "expr": true, 13 | "proto": true, 14 | "strict": true, 15 | "indent": 2, 16 | "camelcase": true, 17 | "node": true, 18 | "browser": false, 19 | "boss": true, 20 | "curly": true, 21 | "latedef": "nofunc", 22 | "debug": false, 23 | "devel": false, 24 | "eqeqeq": true, 25 | "evil": true, 26 | "forin": false, 27 | "immed": false, 28 | "laxbreak": false, 29 | "newcap": true, 30 | "noarg": true, 31 | "noempty": false, 32 | "quotmark": false, 33 | "nonew": false, 34 | "nomen": false, 35 | "onevar": false, 36 | "plusplus": false, 37 | "regexp": false, 38 | "undef": true, 39 | "unused": "vars", 40 | "sub": true, 41 | "trailing": true, 42 | "white": false, 43 | "eqnull": true, 44 | "esnext": true 45 | } 46 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: node_js 3 | 4 | node_js: 5 | - '0.10.26' 6 | - '0.10.36' 7 | - '0.12.0' 8 | 9 | sudo: false 10 | 11 | cache: 12 | directories: 13 | - node_modules 14 | 15 | install: 16 | - npm install 17 | - npm install -d 18 | 19 | script: 20 | - npm test 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ember-deploy-s3-index [![Build Status](https://travis-ci.org/Kerry350/ember-deploy-s3-index.svg?branch=master)](https://travis-ci.org/Kerry350/ember-deploy-s3-index) 2 | 3 | This is the S3-adapter implementation to use [Amazon S3](http://aws.amazon.com/s3) with 4 | [ember-deploy](https://github.com/levelbossmike/ember-deploy), for index page management rather than asset management. For S3 managed assets the default adapter already exists [here](https://github.com/LevelbossMike/ember-deploy-s3). 5 | 6 | # Modes 7 | 8 | The idea with this adapter is you may want to serve your `index.html` file directly from a bucket, or you may have a backend server setup. Technically the default assets adapter does upload your `index.html` file, but this adapter works on the premise of having a bucket(s) dedicated to your index files, so that you can take advantage of the revisioning and previewing capabilities. 9 | 10 | The adapter leaves two methods of handling the index files open to you (implementation comes down to you). With `direct` serving the idea is you have set your index bucket up as a 'static site' and when people navigate to your website they're hitting the bucket directly, and thus get served the `index.html` file contained within it. This is great if your Ember app relies on 3rd Party APIs, CORS or maybe doesn't communicate with an API at all (games etc). You still have revisioning capabilities here for rollbacks etc, and previewing capabilities by using one extra bucket. 11 | 12 | Amazon have documentation on setting up your bucket in this way [here](http://docs.aws.amazon.com/AmazonS3/latest/dev/WebsiteHosting.html) 13 | 14 | `proxy` serving assumes you have a backend server setup of some kind, and are using S3 as a storage mechanism rather than the host itself. Using this mode you would query your S3 bucket directly, and serve the results, just like lookups with the default Redis adapter. On application boot you could query for the `current` implementation which would be that represented by `index.html`, store this in memory, and serve on consequent requests. If a revision was requested via a query parameter you can query the bucket for the revision `:.html` and serve what's returned. 15 | 16 | The add-on uses one set of logic, but allows you the freedom to use those files in two ways. 17 | 18 | # File representation 19 | 20 | - `index.html` will always represent your activated revision, or `current`. 21 | - Versions look like this: `.html`, for example `ember-deploy:44f2f92.html` if using the default SHA tagging adapter and with a project name of 'Ember Deploy'. 22 | 23 | # `deploy.js` 24 | 25 | ``` 26 | module.exports = { 27 | development: { 28 | store: { 29 | type: "S3", 30 | accessKeyId: "ID", 31 | secretAccessKey: "KEY", 32 | bucket: "BUCKET", 33 | region: "ap-southeast-2", // if your bucket isn't in us-east-1 34 | acl: 'public-read', //optional, e.g. 'public-read', if ACL is not configured, it is not sent 35 | hostName: "my-index-bucket.s3-my-region.amazonaws.com", // To be set with 'direct' indexMode 36 | indexMode: "indirect", // Optional: 'direct' or 'indirect', 'direct' is used by default. 37 | prefix: "app-one/" // Optional: Allows a folder setup within the bucket, so that multiple apps can be stored in one bucket (or maybe things like A/B testing grouped together). Use with 'indirect' indexMode only. 38 | }, 39 | 40 | assets: { 41 | type: "s3", 42 | accessKeyId: "ID", 43 | secretAccessKey: "KEY", 44 | bucket: "BUCKET", 45 | region: "ap-southeast-2" // if your bucket isn't in us-east-1 46 | } 47 | } 48 | } 49 | ``` 50 | 51 | # `acl` 52 | 53 | This configuration option allows you to specify a permissions used for uploaded files to S3, in the form of a ["canned ACL"](http://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html#canned-acl). 54 | 55 | As an *alternative* to using this `acl` option, you can configure a policy on the bucket: 56 | 57 | In the S3 section of the AWS console, Right click your bucket -> properties -> permissions -> Add bucket policy. Paste the following in: 58 | 59 | ``` 60 | { 61 | "Statement": [ 62 | { 63 | "Sid": "AllowPublicRead", 64 | "Effect": "Allow", 65 | "Principal": { 66 | "AWS": "*" 67 | }, 68 | "Action": "s3:GetObject", 69 | "Resource": "arn:aws:s3:::/*" 70 | } 71 | ] 72 | } 73 | ``` 74 | 75 | 76 | # `indexMode` 77 | 78 | Direct assumes you're serving your app directly from the bucket, using the static site hosting mode. It uses `putBucketWebsite` to update the Index Document equal to the revision. Indirect doesn't make any strict assumptions, but you'd probably be using the bucket as a storage mechanism only. With indirect an `index.html` file is updated to match the contents of a revision, so at any point there's a concept of 'current' for a server to query against. 79 | 80 | # Assumptions 81 | 82 | This adapter assumes you are using a dedicated index bucket. Amazon S3 doesn't charge for buckets themselves but for usage so this shouldn't create an issue. This allows us to easily list all revisions, and set a `manifestSize`. I may extend this in the future to allow a 'mixed content' bucket, but this would mean testing to ensure files are a valid index file before assuming they're part of the list, and this could get messy. 83 | 84 | # TODOS 85 | 86 | - Improve tests 87 | 88 | # Guide 89 | 90 | For detailed instructions on how to use all of these addons to deploy an app to S3, with revisioning and previewing capabilities there's an article [here](http://kerrygallagher.co.uk/deploying-an-ember-cli-application-to-amazon-s3/). 91 | 92 | # Using History-Location 93 | You can deploy your Ember application to S3 and still use the history-api for pretty URLs. This needs some configuration tweaking in your bucket's static-website-hosting options in the AWS console though. You can use S3's `Redirection Rules`-feature to redirect user's to the correct route based on the URL they are requesting from your app: 94 | 95 | ``` 96 | 97 | 98 | 99 | 404 100 | 101 | 102 | 103 | #/ 104 | 105 | 106 | 107 | ``` 108 | -------------------------------------------------------------------------------- /config/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function() { 4 | return {}; 5 | }; 6 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var S3Adapter = require('./lib/s3-adapter'); 4 | 5 | module.exports = { 6 | name: 'ember-deploy-s3-index', 7 | type: 'ember-deploy-addon', 8 | 9 | adapters: { 10 | index: { 11 | 'S3': S3Adapter 12 | } 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /lib/s3-adapter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var CoreObject = require('core-object'); 4 | var Promise = require('ember-cli/lib/ext/promise'); 5 | var SilentError = require('silent-error'); 6 | var AWS = require('aws-sdk'); 7 | var chalk = require('chalk'); 8 | 9 | var green = chalk.green; 10 | var white = chalk.white; 11 | 12 | var DEFAULT_MANIFEST_SIZE = 5; 13 | 14 | module.exports = CoreObject.extend({ 15 | init: function() { 16 | CoreObject.prototype.init.apply(this, arguments); 17 | 18 | if (!this.config) { 19 | throw new SilentError('You must supply a config'); 20 | } 21 | 22 | this.client = this.S3 || new AWS.S3(this.config); 23 | this.manifestSize = this.manifestSize || DEFAULT_MANIFEST_SIZE; 24 | this.indexMode = this.config.indexMode || 'direct'; 25 | this.indexPrefix = this.config.prefix || ''; 26 | 27 | // Using `prefix` to store index in a folder needs "indirect" as `indexMode` 28 | if (this.indexPrefix.indexOf('/') >= 0 && this.indexMode !== 'indirect') { 29 | throw new Error("You need to set 'indirect' indexMode for using prefix to store revisions in a folder"); 30 | } 31 | }, 32 | 33 | /* Public methods */ 34 | 35 | upload: function(buffer) { 36 | var key = this.taggingAdapter.createTag(); 37 | return this._upload(buffer, key); 38 | }, 39 | 40 | activate: function(revision) { 41 | var revisionWithPrefix = this.indexPrefix.concat(revision); 42 | 43 | return this._getBucketContents() 44 | .then(this._getBucketRevisions.bind(this)) 45 | .then(this._activateRevision.bind(this, revisionWithPrefix)) 46 | .then(this._printSuccessMessage.bind(this, 'Revision activated')) 47 | .catch(function(err) { 48 | var message = this._getFormattedErrorMessage('There was an error activating that revision', err); 49 | return this._printErrorMessage(message); 50 | }.bind(this)); 51 | }, 52 | 53 | list: function() { 54 | return this._list() 55 | .then(this._printBucketRevisions.bind(this)) 56 | .catch(function(err) { 57 | var message = this._getFormattedErrorMessage('There was an error calling list()', err); 58 | return this._printErrorMessage(message); 59 | }.bind(this)); 60 | }, 61 | 62 | /* Private methods */ 63 | 64 | _list: function() { 65 | return this._getBucketContents() 66 | .then(this._sortBucketContent.bind(this)) 67 | .then(this._removeCurrentRevisionFromContents.bind(this)) 68 | .then(this._getBucketRevisions.bind(this)); 69 | }, 70 | 71 | _sortBucketContent: function(data) { 72 | data.Contents = data.Contents.sort(function(a, b) { 73 | return new Date(b.LastModified) - new Date(a.LastModified); 74 | }); 75 | return data; 76 | }, 77 | 78 | /** 79 | * Gets all current revisions based on the bucket's content 80 | * @param {Array} bucketContents - Bucket objects 81 | * @returns {Array} 82 | */ 83 | _getBucketRevisions: function(bucketContents) { 84 | // Assumes bucket only contains index revisions, and therefore all files are '.html' 85 | var revisions = bucketContents.Contents.map(function(item) { 86 | return item.Key.substring(0, (item.Key.length - 5)); 87 | }); 88 | 89 | return revisions; 90 | }, 91 | 92 | _printBucketRevisions: function(revisions) { 93 | var header = green('Found the following revisions: \n'); 94 | 95 | var revisionsList = revisions.reduce(function(prev, current, index) { 96 | return prev + '\n\n' + (index + 1) + ') ' + current.replace(this.indexPrefix, ''); 97 | }.bind(this), ''); 98 | 99 | var footer = green('\n\nUse activate() to activate one of these revisions'); 100 | var message = header + revisionsList + footer; 101 | return this._printSuccessMessage(message); 102 | }, 103 | 104 | /** 105 | * Gets the contents of the bucket specified in the config 106 | * @returns {RSVP.Promise} 107 | */ 108 | _getBucketContents: function() { 109 | return new Promise(function(resolve, reject) { 110 | this.client.listObjects({ 111 | Bucket: this.config.bucket, 112 | Prefix: this.indexPrefix 113 | }, function(err, data) { 114 | if (err) { 115 | reject(err); 116 | } else { 117 | resolve(data); 118 | } 119 | }.bind(this)); 120 | }.bind(this)); 121 | }, 122 | 123 | /** 124 | * Takes a revision number, i.e. ember-app:41d59aa, and sets the 125 | * index.html file contents equal to that of the revision. Will 126 | * error if trying to activate a revision that doesn't exist. 127 | * @returns {RSVP.Promise} 128 | */ 129 | _activateRevision: function(revision, currentRevisions) { 130 | if (currentRevisions.indexOf(revision) > -1) { 131 | return new Promise(function(resolve, reject) { 132 | this._getFileContents(revision + '.html') 133 | .then(function(contents) { 134 | if (this.indexMode === 'direct') { 135 | return this._updateBucketWebsite(revision); 136 | } else { 137 | var params = this._getUploadParams('index', contents) 138 | return this._setFileContents(params); 139 | } 140 | }.bind(this)) 141 | .then(resolve) 142 | .catch(function(err) { 143 | reject(err); 144 | }); 145 | }.bind(this)); 146 | } else { 147 | throw new Error("Revision doesn't exist :("); 148 | } 149 | }, 150 | 151 | /** 152 | * Gets the contents of a file. Resolves with a Buffer. 153 | * @param {string} fileName - name of the file you would like the contents of 154 | * @returns {RSVP.Promise} 155 | */ 156 | _getFileContents: function(fileName) { 157 | return new Promise(function(resolve, reject) { 158 | this.client.getObject({ 159 | Bucket: this.config.bucket, 160 | Key: fileName 161 | }, function(err, data) { 162 | if (err) { 163 | reject(err); 164 | } else { 165 | resolve(data.Body); 166 | } 167 | }); 168 | }.bind(this)); 169 | }, 170 | 171 | /** 172 | * Uploads / updates objects in the bucket 173 | * Resolves with a Buffer. 174 | * @param {Object} params - params for object 175 | * @returns {RSVP.Promise} 176 | */ 177 | _setFileContents: function(params) { 178 | return new Promise(function(resolve, reject) { 179 | this.client.putObject(params, function(err, data) { 180 | if (err) { 181 | reject(err); 182 | } else { 183 | resolve(); 184 | } 185 | }.bind(this)); 186 | }.bind(this)); 187 | }, 188 | 189 | /** 190 | * Uploads our index.html contents to S3 191 | * @param {buffer} value - index.html contents 192 | * @param {string} key - key provided by the tagging adapter 193 | * @returns {RSVP.Promise} 194 | */ 195 | _upload: function(value, key) { 196 | return this._uploadIfNotAlreadyInManifest(value, key) 197 | .then(this._cleanupBucket.bind(this)) 198 | .then(this._deploySuccessMessage.bind(this, key)) 199 | .then(this._printSuccessMessage.bind(this)) 200 | .catch(function() { 201 | var message = this._deployErrorMessage(); 202 | return this._printErrorMessage(message); 203 | }.bind(this)); 204 | }, 205 | 206 | /** 207 | * Ensures bucket holds no more than the specified number of revisions 208 | * @returns {RSVP.Promise} 209 | */ 210 | _cleanupBucket: function() { 211 | return new Promise(function(resolve, reject) { 212 | this._getBucketContents() 213 | .then(this._removeCurrentRevisionFromContents.bind(this)) 214 | .then(function(data) { 215 | if ((data.Contents.length - this.manifestSize) > 0) { 216 | var itemsToDelete = this._getItemsForDeletion(data); 217 | this._deleteItemsFromBucket(itemsToDelete) 218 | .then(resolve) 219 | .catch(reject); 220 | } else { 221 | resolve(); 222 | } 223 | }.bind(this)) 224 | .catch(function(err) { 225 | reject(err); 226 | }); 227 | }.bind(this)); 228 | }, 229 | 230 | /** 231 | * Returns items that are in excess of the manifestSize 232 | * @param {Object} data - data returned from bucket 233 | * @returns {Array} 234 | */ 235 | _getItemsForDeletion: function(data) { 236 | var numerOfItemsToDelete = data.Contents.length - this.manifestSize; 237 | var sortedContents = data.Contents.sort(function(a, b) { 238 | return new Date(b.LastModified) - new Date(a.LastModified); 239 | }); 240 | var itemsToDelete = sortedContents.slice((sortedContents.length - numerOfItemsToDelete)).map(function(item) { 241 | return item.Key; 242 | }); 243 | return itemsToDelete; 244 | }, 245 | 246 | /** 247 | * Removes one or more items from the bucket 248 | * @param {Array} itemKeys - array of bucket object keys 249 | * @returns {RSVP.Promise} 250 | */ 251 | _deleteItemsFromBucket: function(itemKeys) { 252 | return new Promise(function(resolve, reject) { 253 | this.client.deleteObjects({ 254 | Bucket: this.config.bucket, 255 | Delete: { 256 | Objects: itemKeys.map(function(item) { 257 | return {Key: item}; 258 | }) 259 | } 260 | }, function(err, data) { 261 | if (err) { 262 | reject(err); 263 | } else { 264 | resolve(); 265 | } 266 | }); 267 | }.bind(this)); 268 | }, 269 | 270 | /** 271 | * Removes our 'current' representation from data set 272 | * @param {Object} data - data returned from bucket 273 | * @returns {Object} 274 | */ 275 | _removeCurrentRevisionFromContents: function(data) { 276 | for (var i = 0, len = data.Contents.length; i < len; i++) { 277 | var fileName = data.Contents[i].Key.replace(this.indexPrefix, ''); 278 | 279 | if (fileName === 'index.html') { 280 | data.Contents.splice(i, 1); 281 | break; 282 | } 283 | } 284 | 285 | return data; 286 | }, 287 | 288 | /** 289 | * Uploads our index.html contents to S3 if the revision doesn't exist 290 | * @param {string} indexHTML - index.html contents 291 | * @returns {RSVP.Promise} 292 | */ 293 | _uploadIfNotAlreadyInManifest: function(value, key) { 294 | return new Promise(function(resolve, reject) { 295 | this._list() 296 | .then(function(revisions) { 297 | if (revisions.indexOf(key) < 0) { 298 | var params = this._getUploadParams(key, value); 299 | this.client.putObject(params, function(err, data) { 300 | if (err) { 301 | this._logUploadError(reject, err); 302 | } else { 303 | this._logUploadSuccess(resolve); 304 | } 305 | }.bind(this)); 306 | } else { 307 | reject(); 308 | } 309 | }.bind(this)); 310 | }.bind(this)); 311 | }, 312 | 313 | /** 314 | * Updates website index document to point to a new revision. 315 | * @param {string} revision - revision to update index to 316 | * @returns {RSVP.Promise} 317 | */ 318 | _updateBucketWebsite: function(revision){ 319 | return new Promise(function(resolve, reject) { 320 | var params = this._getWebsiteParams(revision); 321 | this.client.putBucketWebsite(params, function(err, data) { 322 | if (err) { 323 | reject(err); 324 | } else if (!data.Body) { 325 | reject(new SilentError('No data received: please verify correct "indexMode" config.')); 326 | } else { 327 | resolve(data.Body); 328 | } 329 | }); 330 | }.bind(this)); 331 | }, 332 | 333 | /** 334 | * Generates parameters for bucket website configuration. 335 | * @param {string} revision - revision to update index to 336 | * @returns {Object} 337 | */ 338 | _getWebsiteParams: function(revision){ 339 | return { 340 | Bucket: this.config.bucket, /* required */ 341 | WebsiteConfiguration: { /* required */ 342 | ErrorDocument: { 343 | Key: 'error.html' /* required */ 344 | }, 345 | IndexDocument: { 346 | Suffix: revision + '.html' /* required */ 347 | }, 348 | 349 | RoutingRules: [ 350 | { 351 | Redirect: { /* required */ 352 | HostName: this.config.hostName, 353 | ReplaceKeyPrefixWith: '#/', 354 | }, 355 | Condition: { 356 | HttpErrorCodeReturnedEquals: '404', 357 | } 358 | }, 359 | ] 360 | } 361 | }; 362 | }, 363 | 364 | _getUploadParams: function(key, value) { 365 | var params = { 366 | Bucket: this.config.bucket, 367 | Key: this.indexPrefix + key + '.html', 368 | Body: value, 369 | ContentType: 'text/html', 370 | CacheControl: 'max-age=0, no-cache' 371 | }; 372 | 373 | if (this.config.acl) { 374 | params.ACL = this.config.acl; 375 | } 376 | return params; 377 | }, 378 | 379 | _deploySuccessMessage: function(revisionKey) { 380 | var success = green('\nUpload successful!\n\n'); 381 | var uploadMessage = white('Uploaded revision: ') + green(revisionKey); 382 | return success + uploadMessage; 383 | }, 384 | 385 | _printSuccessMessage: function(message) { 386 | return this.ui.writeLine(message); 387 | }, 388 | 389 | _printErrorMessage: function(message) { 390 | return Promise.reject(new SilentError(message)); 391 | }, 392 | 393 | _deployErrorMessage: function() { 394 | var failure = '\nUpload failed!\n'; 395 | var suggestion = 'Did you try to upload an already uploaded revision?\n\n'; 396 | var solution = 'Please run `' + green('ember deploy:list') + '` to ' + 'investigate.'; 397 | return failure + suggestion + solution; 398 | }, 399 | 400 | _logUploadError: function(reject, error) { 401 | var errorMessage = 'Unable to sync: ' + error.stack; 402 | reject(new SilentError(errorMessage)); 403 | }, 404 | 405 | _logUploadSuccess: function(resolve) { 406 | this.ui.writeLine('Index file was successfully uploaded'); 407 | resolve(); 408 | }, 409 | 410 | _getFormattedErrorMessage: function(message, error) { 411 | message = message + '\n\n'; 412 | error = (error) ? (error.stack || error.message + '\n') : ''; 413 | return message + error; 414 | } 415 | }); 416 | -------------------------------------------------------------------------------- /node_tests/helpers/mockS3.js: -------------------------------------------------------------------------------- 1 | function MockS3() { 2 | // 2, 3, 1 order 3 | this.bucketContents = { 4 | Contents: [ 5 | { 6 | Key: '1.html', 7 | LastModified: 'Mon Apr 20 2015 11:23:56 GMT+0100 (BST)', 8 | }, 9 | 10 | { 11 | Key: '2.html', 12 | LastModified: 'Mon Apr 20 2015 11:25:56 GMT+0100 (BST)', 13 | }, 14 | 15 | { 16 | Key: '3.html', 17 | LastModified: 'Mon Apr 20 2015 11:24:56 GMT+0100 (BST)', 18 | } 19 | ] 20 | } 21 | } 22 | 23 | MockS3.prototype = { 24 | listObjects: function(opts, cb) { 25 | return cb(null, this.bucketContents); 26 | }, 27 | 28 | putObject: function(opts, cb) { 29 | return cb(null, null); 30 | }, 31 | 32 | deleteObjects: function(opts, cb) { 33 | return cb(null, null); 34 | }, 35 | 36 | getObject: function(opts, cb) { 37 | return cb(null, {Body: 'Some content'}); 38 | }, 39 | 40 | putBucketWebsite: function(opts, cb) { 41 | return cb(null, {Body: 'Some content'}); 42 | } 43 | }; 44 | 45 | module.exports = MockS3; 46 | -------------------------------------------------------------------------------- /node_tests/runner.js: -------------------------------------------------------------------------------- 1 | var glob = require('glob'); 2 | var Mocha = require('mocha'); 3 | 4 | var mocha = new Mocha({ 5 | timeout: 6000, 6 | reporter: 'spec' 7 | }); 8 | 9 | var directory = 'node_tests'; 10 | 11 | var files = glob(directory + '/**/*-test.js', { sync: true }); 12 | 13 | files.forEach(function(file) { 14 | mocha.addFile(file); 15 | }); 16 | 17 | mocha.run(function(failures) { 18 | process.on('exit', function() { 19 | process.exit(failures); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /node_tests/unit/s3-adapter-test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var chai = require('chai'); 4 | var sinon = require('sinon'); 5 | var expect = chai.expect; 6 | var S3Adapter = require('../../lib/s3-adapter'); 7 | var MockS3 = require('./../helpers/mockS3'); 8 | var MockUI = require('ember-cli/tests/helpers/mock-ui'); 9 | var adapter; 10 | 11 | var sinonChai = require("sinon-chai"); 12 | chai.use(sinonChai); 13 | 14 | var chaiAsPromised = require("chai-as-promised"); 15 | chai.use(chaiAsPromised); 16 | 17 | var BUFFER = 'A buffer'; 18 | var SHA_KEY = 'Spongebob'; 19 | var EXISTING_KEY = '1'; 20 | var BUCKET_NAME = 'Rusty'; 21 | var HOST_NAME = 'Hosty'; 22 | 23 | describe('S3 Adapter tests', function() { 24 | beforeEach('Reset adapter instance', function(done) { 25 | adapter = new S3Adapter({ 26 | config: { 27 | bucket: BUCKET_NAME, 28 | hostName: HOST_NAME 29 | }, 30 | S3: new MockS3(), 31 | ui: new MockUI(), 32 | taggingAdapter: { 33 | createTag: function() { 34 | return SHA_KEY 35 | } 36 | } 37 | }); 38 | done(); 39 | }); 40 | 41 | describe('Config option tests', function() { 42 | context('Config supplied', function() { 43 | it('Does not throw an error', function() { 44 | expect(function() { 45 | new S3Adapter({config: {}}); 46 | }).to.not.throw(Error); 47 | }); 48 | }); 49 | 50 | context('Config not supplied', function() { 51 | it('Throws an error', function() { 52 | expect(function() { 53 | new S3Adapter() 54 | }).to.throw(Error); 55 | }); 56 | }); 57 | 58 | context('prefix option has been set', function() { 59 | it('Should throw an error when indexMode is not equal to indirect', function() { 60 | expect(function() { 61 | new S3Adapter({config: { 62 | prefix: '/kittens', 63 | indexMode: 'direct' 64 | }}); 65 | }).to.throw(Error); 66 | }); 67 | }); 68 | }); 69 | 70 | describe('#upload()', function() { 71 | it('Should call #_upload() with a buffer and the key', function() { 72 | adapter._upload = sinon.spy(); 73 | adapter.upload(BUFFER) 74 | expect(adapter._upload).to.have.been.calledWith(BUFFER, SHA_KEY); 75 | }); 76 | 77 | context('Revision has already been uploaded', function() { 78 | it('Should be rejected', function() { 79 | return expect(adapter._upload(BUFFER, EXISTING_KEY)).to.be.rejected; 80 | }); 81 | }); 82 | 83 | context('Revision has not been uploaded', function() { 84 | it('Should upload the revision', function(done) { 85 | sinon.spy(adapter.S3, 'putObject'); 86 | return adapter.upload(BUFFER) 87 | .then(function() { 88 | expect(adapter.S3.putObject).to.have.been.called; 89 | done(); 90 | }); 91 | }); 92 | 93 | it('Should use the correct upload params', function(done) { 94 | var expectedParams = { 95 | Bucket: BUCKET_NAME, 96 | Key: SHA_KEY + '.html', 97 | Body: BUFFER, 98 | ContentType: 'text/html', 99 | CacheControl: 'max-age=0, no-cache' 100 | }; 101 | 102 | sinon.spy(adapter, '_getUploadParams'); 103 | 104 | return adapter.upload(BUFFER) 105 | .then(function() { 106 | expect(adapter._getUploadParams).to.have.been.called; 107 | expect(adapter._getUploadParams.returnValues[0]).to.eql(expectedParams); 108 | done(); 109 | }); 110 | }); 111 | }); 112 | 113 | context('Bucket holds more items than manifest limit', function() { 114 | it('Should remove excess items', function(done) { 115 | adapter.manifestSize = 1; 116 | sinon.spy(adapter.S3, 'deleteObjects'); 117 | return adapter.upload(BUFFER) 118 | .then(function() { 119 | expect(adapter.S3.deleteObjects).to.have.been.called; 120 | expect(adapter.S3.deleteObjects.args[0][0].Delete.Objects.length).to.equal(2); 121 | done(); 122 | }); 123 | }); 124 | }); 125 | }); 126 | 127 | describe('#activate()', function() { 128 | context('Activating a revision that does not exist', function() { 129 | it('Should throw an error', function() { 130 | return expect(adapter.activate('4')).to.be.rejectedWith(Error, "Revision doesn't exist :("); 131 | }); 132 | }); 133 | 134 | context('Activating a revision that does exist', function() { 135 | context('indexMode === direct', function() { 136 | it('Should call #_updateBucketWebsite()', function(done) { 137 | sinon.spy(adapter, '_updateBucketWebsite'); 138 | return adapter.activate('1') 139 | .then(function() { 140 | expect(adapter._updateBucketWebsite).to.have.been.called; 141 | done(); 142 | }); 143 | }); 144 | 145 | it('Should call S3s #putBucketWebsite() with the correct parameters', function(done) { 146 | var expectedParams = { 147 | Bucket: BUCKET_NAME, /* required */ 148 | WebsiteConfiguration: { /* required */ 149 | ErrorDocument: { 150 | Key: 'error.html' /* required */ 151 | }, 152 | IndexDocument: { 153 | Suffix: '1.html' /* required */ 154 | }, 155 | 156 | RoutingRules: [ 157 | { 158 | Redirect: { /* required */ 159 | HostName: HOST_NAME, 160 | ReplaceKeyPrefixWith: '#/', 161 | }, 162 | Condition: { 163 | HttpErrorCodeReturnedEquals: '404', 164 | } 165 | }, 166 | ] 167 | } 168 | }; 169 | 170 | sinon.spy(adapter.S3, 'putBucketWebsite'); 171 | return adapter.activate('1') 172 | .then(function() { 173 | expect(adapter.S3.putBucketWebsite).to.have.been.called; 174 | expect(adapter.S3.putBucketWebsite.args[0][0]).to.eql(expectedParams); 175 | done(); 176 | }); 177 | }); 178 | }); 179 | 180 | context('indexMode === indirect', function() { 181 | it('Should call S3s #putObject() with the correct parameters', function(done) { 182 | var expectedParams = { 183 | Bucket: BUCKET_NAME, 184 | Key: 'index.html', 185 | Body: 'Some content', 186 | ContentType: 'text/html', 187 | CacheControl: 'max-age=0, no-cache' 188 | }; 189 | 190 | adapter.indexMode = 'indirect'; 191 | sinon.spy(adapter.S3, 'putObject'); 192 | 193 | return adapter.activate('1') 194 | .then(function() { 195 | expect(adapter.S3.putObject).to.have.been.called; 196 | expect(adapter.S3.putObject.args[0][0]).to.eql(expectedParams); 197 | done(); 198 | }); 199 | }); 200 | }); 201 | 202 | context('prefix option has been set', function() { 203 | it('Should use the prefix in the key', function() { 204 | adapter.indexMode = 'indirect'; 205 | adapter.indexPrefix = 'kittens/'; 206 | 207 | expect(adapter._getUploadParams('index').Key).to.equal('kittens/index.html'); 208 | }); 209 | }); 210 | }); 211 | }); 212 | 213 | describe('#list', function() { 214 | beforeEach(function() { 215 | return adapter.list(); 216 | }); 217 | 218 | afterEach(function() { 219 | adapter.ui.output = ''; 220 | }); 221 | 222 | it('Should output the revisions', function(done) { 223 | var output = ['1) 2', '2) 3', '3) 1']; 224 | output.forEach(function(portion) { 225 | expect(adapter.ui.output).to.contain(portion); 226 | }); 227 | done(); 228 | }); 229 | }); 230 | 231 | describe('#_sortBucketContent', function() { 232 | it('Should sort the contents by LastModified date', function(done) { 233 | expect(adapter._sortBucketContent(adapter.S3.bucketContents).Contents[0].Key).to.equal('2.html'); 234 | expect(adapter._sortBucketContent(adapter.S3.bucketContents).Contents[1].Key).to.equal('3.html'); 235 | expect(adapter._sortBucketContent(adapter.S3.bucketContents).Contents[2].Key).to.equal('1.html'); 236 | done(); 237 | }); 238 | }); 239 | 240 | describe('#_cleanupBucket', function() { 241 | it('Should keep bucket items within the manifest limit by delegating to S3s deleteObjects', function(done) { 242 | adapter.manifestSize = 1; 243 | sinon.spy(adapter.S3, 'deleteObjects'); 244 | return adapter 245 | ._cleanupBucket() 246 | .then(function() { 247 | expect(adapter.S3.deleteObjects).to.have.been.called; 248 | expect(adapter.S3.deleteObjects.args[0][0].Delete.Objects.length).to.equal(2); 249 | done(); 250 | }); 251 | }); 252 | }); 253 | 254 | describe('#_getItemsForDeletion', function() { 255 | it('Should return the correct items to delete, based on sort order', function(done) { 256 | adapter.manifestSize = 1; 257 | expect(adapter._getItemsForDeletion(adapter.S3.bucketContents)[0]).to.equal('3.html'); 258 | expect(adapter._getItemsForDeletion(adapter.S3.bucketContents)[1]).to.equal('1.html'); 259 | done(); 260 | }); 261 | }); 262 | 263 | describe('#_removeCurrentRevisionFromContents', function() { 264 | it('Should remove the current revision from the bucket contents', function(done) { 265 | adapter.S3.bucketContents.Contents.push({Key: 'index.html'}); 266 | adapter._removeCurrentRevisionFromContents(adapter.S3.bucketContents); 267 | expect(adapter.S3.bucketContents.Contents.length).to.equal(3); 268 | var isInContent = adapter.S3.bucketContents.Contents.filter(function(el, index, arr) { 269 | return el.Key === 'index.html'; 270 | }); 271 | expect(isInContent.length).to.equal(0); 272 | done(); 273 | }); 274 | }); 275 | }); 276 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ember-deploy-s3-index", 3 | "version": "0.4.0", 4 | "description": "ember-deploy index-adapter for Amazon S3", 5 | "scripts": { 6 | "test": "node node_tests/runner.js" 7 | }, 8 | "keywords": [ 9 | "ember-addon" 10 | ], 11 | "author": "Kerry Gallagher", 12 | "license": "MIT", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/Kerry350/ember-deploy-s3-index.git" 16 | }, 17 | "devDependencies": { 18 | "chai": "^2.2.0", 19 | "chai-as-promised": "^5.0.0", 20 | "ember-cli": "^0.1.15", 21 | "glob": "^4.4.2", 22 | "mocha": "^2.1.0", 23 | "sinon": "^1.14.1", 24 | "sinon-chai": "^2.7.0" 25 | }, 26 | "dependencies": { 27 | "aws-sdk": "^2.1.10", 28 | "chalk": "^0.5.1", 29 | "core-object": "^1.1.0", 30 | "silent-error": "^1.0.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /vendor/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kerry350/ember-deploy-s3-index/d25817a0c0849392e786c4eb4222629905aba190/vendor/.gitkeep --------------------------------------------------------------------------------