diff --git a/commands/deploy.js b/commands/deploy.js index 44098bf4..20bea965 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -5,6 +5,7 @@ var _ = require('lodash'); var acquireRefs = require('../lib/acquireRefs'); var chalk = require('chalk'); var checkDupHostingKeys = require('../lib/checkDupHostingKeys'); +var checkValidTargetFilters = require('../lib/checkValidTargetFilters'); var Command = require('../lib/command'); var deploy = require('../lib/deploy'); var logger = require('../lib/logger'); @@ -19,7 +20,10 @@ module.exports = new Command('deploy') .description('deploy code and assets to your Firebase project') .option('-p, --public ', 'override the Hosting public directory specified in firebase.json') .option('-m, --message ', 'an optional message describing this deploy') - .option('--only ', 'only deploy to specified, comma-separated targets (e.g. "hosting,storage")') + .option('--only ', 'only deploy to specified, comma-separated targets (e.g. "hosting,storage"). For functions, ' + + 'can specify filters with colons to scope function deploys to only those functions (e.g. "--only functions:func1,functions:func2"). ' + + 'When filtering based on export groups (the exported module object keys), use dots to specify group names ' + + '(e.g. "--only functions:group1.subgroup1,functions:group2)"') .option('--except ', 'deploy to all targets except specified (e.g. "database")') .before(requireConfig) .before(function(options) { @@ -38,16 +42,15 @@ module.exports = new Command('deploy') }); }) .before(checkDupHostingKeys) + .before(checkValidTargetFilters) .action(function(options) { var targets = VALID_TARGETS.filter(function(t) { return options.config.has(t); }); - if (options.only && options.except) { - return utils.reject('Cannot specify both --only and --except', {exit: 1}); - } - if (options.only) { - targets = _.intersection(targets, options.only.split(',')); + targets = _.intersection(targets, options.only.split(',').map(function(opt) { + return opt.split(':')[0]; + })); } else if (options.except) { targets = _.difference(targets, options.except.split(',')); } diff --git a/lib/checkValidTargetFilters.js b/lib/checkValidTargetFilters.js new file mode 100644 index 00000000..dea87129 --- /dev/null +++ b/lib/checkValidTargetFilters.js @@ -0,0 +1,36 @@ +'use strict'; + +var _ = require('lodash'); +var RSVP = require('rsvp'); +var FirebaseError = require('./error'); + +module.exports = function(options) { + function numFilters(targetTypes) { + return _.filter(options.only, function(opt) { + var optChunks = opt.split(':'); + return _.includes(targetTypes, optChunks[0]) && optChunks.length > 1; + }).length; + } + function targetContainsFilter(targetTypes) { + return numFilters(targetTypes) > 1; + } + function targetDoesNotContainFilter(targetTypes) { + return numFilters(targetTypes) === 0; + } + + return new RSVP.Promise(function(resolve, reject) { + if (!options.only) { + return resolve(); + } + if (options.except) { + return reject(new FirebaseError('Cannot specify both --only and --except', {exit: 1})); + } + if (targetContainsFilter(['database', 'storage', 'hosting'])) { + return reject(new FirebaseError('Filters specified with colons (e.g. --only functions:func1,functions:func2) are only supported for functions', {exit: 1})); + } + if (targetContainsFilter(['functions']) && targetDoesNotContainFilter(['functions'])) { + return reject(new FirebaseError('Cannot specify "--only functions" and "--only functions:" at the same time', {exit: 1})); + } + return resolve(); + }); +}; diff --git a/lib/deploy/functions/release.js b/lib/deploy/functions/release.js index 99acd45d..690e76d9 100644 --- a/lib/deploy/functions/release.js +++ b/lib/deploy/functions/release.js @@ -49,6 +49,67 @@ module.exports = function(context, options, payload) { }); } + function _functionMatchesGroup(functionName, groupChunks) { + return _.isEqual(groupChunks, functionName.split('-').slice(0, groupChunks.length)); + } + + function _getFilterGroups() { + if (!options.only) { + return []; + } + + var opts; + return _.chain(options.only.split(',')) + .filter(function(filter) { + opts = filter.split(':'); + return opts[0] === 'functions' && opts[1]; + }).map(function(filter) { + return filter.split(':')[1].split('.'); + }).value(); + } + + function _getReleaseNames(uploadNames, existingNames, functionFilterGroups) { + if (functionFilterGroups.length === 0) { + return uploadNames; + } + + var allFunctions = _.union(uploadNames, existingNames); + return _.filter(allFunctions, function(functionName) { + return _.some(_.map(functionFilterGroups, function(groupChunks) { + return _functionMatchesGroup(functionName, groupChunks); + })); + }); + } + + function logFilters(existingNames, releaseNames, functionFilterGroups) { + if (functionFilterGroups.length === 0) { + return; + } + + logger.debug('> [functions] filtering triggers to: ' + JSON.stringify(releaseNames, null, 2)); + track('Functions Deploy with Filter', '', releaseNames.length); + + if (existingNames.length > 0) { + utils.logBullet(chalk.bold.cyan('functions: ') + 'current functions in project: ' + existingNames.join(', ')); + } + if (releaseNames.length > 0) { + utils.logBullet(chalk.bold.cyan('functions: ') + 'uploading functions in project: ' + releaseNames.join(', ')); + } + + var allFunctions = _.union(releaseNames, existingNames); + var unmatchedFilters = _.chain(functionFilterGroups) + .filter(function(filterGroup) { + return !_.some(_.map(allFunctions, function(functionName) { + return _functionMatchesGroup(functionName, filterGroup); + })); + }).map(function(group) { + return group.join('-'); + }).value(); + if (unmatchedFilters.length > 0) { + utils.logWarning(chalk.bold.yellow('functions: ') + 'the following filters were specified but do not match any functions in the project: ' + unmatchedFilters.join(', ')); + } + } + function _reportResults(successfulCalls, failedCalls) { function logFailedOps(ops) { _.forEach(ops, function(operation) { @@ -194,9 +255,14 @@ module.exports = function(context, options, payload) { }; var existingNames = _.map(existingFunctions, pluckName); + var functionFilterGroups = _getFilterGroups(); + var releaseNames = _getReleaseNames(uploadedNames, existingNames, functionFilterGroups); + + logFilters(existingNames, releaseNames, functionFilterGroups); var addOps = _.chain(uploadedNames) .difference(existingNames) + .intersection(releaseNames) .map(function(functionName) { var functionInfo = _.find(functionsInfo, {'name': functionName}); return _prepFunctionOp(functionInfo).then(function(functionTrigger) { @@ -218,6 +284,7 @@ module.exports = function(context, options, payload) { var updateOps = _.chain(uploadedNames) .intersection(existingNames) + .intersection(releaseNames) .map(function(functionName) { var functionInfo = _.find(functionsInfo, {'name': functionName}); return _prepFunctionOp(functionInfo, functionName).then(function(functionTrigger) { @@ -267,6 +334,8 @@ module.exports = function(context, options, payload) { }); }).value(); + // If not using function filters, then `deleteReleaseNames` should be equivalent to existingNames so that intersection is a noop + var deleteReleaseNames = functionFilterGroups.length > 0 ? releaseNames : existingNames; var deleteOps = _.chain(existingFunctions) .filter(function(o) { @@ -274,6 +343,7 @@ module.exports = function(context, options, payload) { }) // only delete functions uploaded via firebase-tools .map(pluckName) .difference(uploadedNames) + .intersection(deleteReleaseNames) .map(function(functionName) { utils.logBullet(chalk.bold.cyan('functions: ') + 'deleting function ' + chalk.bold(functionName) + '...'); _startTimer(functionName, 'delete'); diff --git a/scripts/test-functions-deploy.js b/scripts/test-functions-deploy.js index 81e4d8dc..77870a44 100644 --- a/scripts/test-functions-deploy.js +++ b/scripts/test-functions-deploy.js @@ -93,6 +93,17 @@ var testCreateUpdate = function() { }); }; +var testCreateUpdateWithFilter = function() { + fs.copySync(functionsSource, tmpDir + '/functions/index.js'); + return new RSVP.Promise(function(resolve) { + exec(localFirebase + ' deploy --only functions:dbAction,functions:httpsAction', {'cwd': tmpDir}, function(err, stdout) { + console.log(stdout); + expect(err).to.be.null; + resolve(checkFunctionsListMatch(['dbAction', 'httpsAction'])); + }); + }); +}; + var testDelete = function() { return new RSVP.Promise(function(resolve) { exec('> functions/index.js &&' + localFirebase + ' deploy', {'cwd': tmpDir}, function(err, stdout) { @@ -103,6 +114,27 @@ var testDelete = function() { }); }; +var testDeleteWithFilter = function() { + return new RSVP.Promise(function(resolve) { + exec('> functions/index.js &&' + localFirebase + ' deploy --only functions:dbAction', {'cwd': tmpDir}, function(err, stdout) { + console.log(stdout); + expect(err).to.be.null; + resolve(checkFunctionsListMatch(['httpsAction'])); + }); + }); +}; + +var testUnknownFilter = function() { + return new RSVP.Promise(function(resolve) { + exec('> functions/index.js &&' + localFirebase + ' deploy --only functions:unknownFilter', {'cwd': tmpDir}, function(err, stdout) { + console.log(stdout); + expect(stdout).to.contain('the following filters were passed but don\'t match any functions in current project or currently being exported: unknownFilter'); + expect(err).to.be.null; + resolve(checkFunctionsListMatch(['httpsAction'])); + }); + }); +}; + var waitForAck = function(uuid, testDescription) { return Promise.race([ new Promise(function(resolve) { @@ -204,6 +236,15 @@ var main = function() { return testDelete(); }).then(function() { console.log(chalk.green('\u2713 Test passed: deleting functions')); + return testCreateUpdateWithFilter(); + }).then(function() { + console.log(chalk.green('\u2713 Test passed: creating functions with filters')); + return testDeleteWithFilter(); + }).then(function() { + console.log(chalk.green('\u2713 Test passed: deleting functions with filters')); + return testUnknownFilter(); + }).then(function() { + console.log(chalk.green('\u2713 Test passed: threw warning when passing filter with unknown identifier')); }).catch(function(err) { console.log(chalk.red('Error while running tests: '), err); return RSVP.resolve();