diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9edc3b6072d..7234ee3538b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,7 @@ To run the system tests, first create and configure a project in the Google Deve - **GCLOUD_TESTS_PROJECT_ID**: Developers Console project's ID (e.g. bamboo-shift-455) - **GCLOUD_TESTS_KEY**: The path to the JSON key file. +- ***GCLOUD_TESTS_DNS_DOMAIN*** (*optional*): A domain you own managed by Google Cloud DNS (expected format: `'gcloud-node.com.'`). Install the [gcloud command-line tool][gcloudcli] to your machine and use it to create the indexes used in the datastore system tests with indexes found in `system-test/data/index/yaml`: diff --git a/README.md b/README.md index 6b593284305..18cec69f1ab 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This client supports the following Google Cloud Platform services: * [Google BigQuery](#google-bigquery) * [Google Cloud Datastore](#google-cloud-datastore) +* [Google Cloud DNS](#google-cloud-dns) * [Google Cloud Storage](#google-cloud-storage) * [Google Cloud Pub/Sub](#google-cloud-pubsub-beta) (Beta) * [Google Cloud Search](#google-cloud-search-alpha) (Alpha) @@ -165,6 +166,46 @@ dataset.save({ ``` +## Google Cloud DNS + +- [API Documentation][gcloud-dns-docs] +- [Official Documentation][cloud-dns-docs] + +#### Preview + +```js +var gcloud = require('gcloud'); + +// Authorizing on a per-API-basis. You don't need to do this if you auth on a +// global basis (see Authorization section above). + +var dns = gcloud.dns({ + keyFilename: '/path/to/keyfile.json', + projectId: 'my-project' +}); + +// Create a managed zone. +dns.createZone('my-new-zone', { + dnsName: 'my-domain.com.' +}, function(err, zone) {}); + +// Reference an existing zone. +var zone = dns.zone('my-existing-zone'); + +// Create an NS record. +var nsRecord = zone.record('ns', { + ttl: 86400, + name: 'my-domain.com.', + data: 'ns-cloud1.googledomains.com.' +}); + +zone.addRecord(nsRecord, function(err, change) {}); + +// Create a zonefile from the records in your zone. +zone.export('/zonefile.zone', function(err) {}); +``` + + ## Google Cloud Storage - [API Documentation][gcloud-storage-docs] @@ -319,6 +360,7 @@ Apache 2.0 - See [COPYING](COPYING) for more information. [gcloud-docs]: https://googlecloudplatform.github.io/gcloud-node/#/docs [gcloud-bigquery-docs]: https://googlecloudplatform.github.io/gcloud-node/#/docs/bigquery [gcloud-datastore-docs]: https://googlecloudplatform.github.io/gcloud-node/#/docs/datastore +[gcloud-dns-docs]: https://googlecloudplatform.github.io/gcloud-node/#/docs/dns [gcloud-pubsub-docs]: https://googlecloudplatform.github.io/gcloud-node/#/docs/pubsub [gcloud-search-docs]: https://googlecloudplatform.github.io/gcloud-node/#/docs/search [gcloud-storage-docs]: https://googlecloudplatform.github.io/gcloud-node/#/docs/storage @@ -339,6 +381,8 @@ Apache 2.0 - See [COPYING](COPYING) for more information. [cloud-datastore-docs]: https://cloud.google.com/datastore/docs [cloud-datastore-activation]: https://cloud.google.com/datastore/docs/activate +[cloud-dns-docs]: https://cloud.google.com/dns/docs + [cloud-pubsub-docs]: https://cloud.google.com/pubsub/docs [cloud-search-docs]: https://cloud.google.com/search/ diff --git a/test/search.index.js b/docs/json/master/dns/.gitkeep similarity index 100% rename from test/search.index.js rename to docs/json/master/dns/.gitkeep diff --git a/docs/site/components/docs/docs-values.js b/docs/site/components/docs/docs-values.js index 2b30f1da72e..3bd5b3d4e39 100644 --- a/docs/site/components/docs/docs-values.js +++ b/docs/site/components/docs/docs-values.js @@ -73,6 +73,25 @@ angular.module('gcloud.docs') ] }, + dns: { + title: 'DNS', + _url: '{baseUrl}/dns', + pages: [ + { + title: 'Zone', + url: '/zone' + }, + { + title: 'Record', + url: '/record' + }, + { + title: 'Change', + url: '/change' + } + ] + }, + pubsub: { title: 'PubSub', _url: '{baseUrl}/pubsub', @@ -158,6 +177,9 @@ angular.module('gcloud.docs') '>=0.10.0': ['bigquery'], // introduce search api. - '>=0.16.0': ['search'] + '>=0.16.0': ['search'], + + // introduce dns api. + '>=0.18.0': ['dns'] } }); diff --git a/lib/common/util.js b/lib/common/util.js index e5ed5cf3e53..f675a0af91b 100644 --- a/lib/common/util.js +++ b/lib/common/util.js @@ -249,6 +249,55 @@ function getType(value) { return Object.prototype.toString.call(value).match(/\s(\w+)\]/)[1]; } +/** + * Iterate through an array, invoking a function by the provided name. + * + * @param {string} name - The name of the function that exists as a property on + * each member of the iterated array. + * @return {function} + * + * @example + * var people = [ + * { + * getName: function() { return 'Stephen'; } + * }, + * { + * getName: function() { return 'Dave'; } + * } + * }; + * + * var names = people.map(exec('getName')); + * // names = [ 'Stephen', 'Dave' ] + * + * //- + * // Aguments can also be provided. + * //- + * var people = [ + * { + * getName: function(prefix) { return prefix + ' Stephen'; } + * }, + * { + * getName: function(prefix) { return prefix + ' Dave'; } + * } + * ]; + * + * var names = people.map(exec('getName', 'Mr.')); + * // names = [ 'Mr. Stephen', 'Mr. Dave' ]; + */ +function exec(name) { + var initialArguments = [].slice.call(arguments, 1); + + return function(item) { + if (util.is(item[name], 'function')) { + var invokedArguments = [].slice.call(arguments, 1); + return item[name].apply(item, initialArguments.concat(invokedArguments)); + } + return item[name]; + }; +} + +util.exec = exec; + /** * Used in an Array iterator usually, this will return the value of a property * in an object by its name. diff --git a/lib/dns/change.js b/lib/dns/change.js new file mode 100644 index 00000000000..e7a56f6133e --- /dev/null +++ b/lib/dns/change.js @@ -0,0 +1,87 @@ +/*! + * Copyright 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/*! + * @module dns/change + */ + +'use strict'; + +/** + * @constructor + * @alias module:dns/change + * + * @param {module:dns/zone} zone - The parent zone object. + * @param {string} id - ID of the change. + * + * @example + * var gcloud = require('gcloud'); + * + * var dns = gcloud.dns({ + * keyFilename: '/path/to/keyfile.json', + * projectId: 'grape-spaceship-123' + * }); + * + * var zone = dns.zone('zone-id'); + * var change = zone.change('change-id'); + */ +function Change(zone, id) { + this.zoneName = zone.name; + this.id = id; + + this.metadata = {}; + this.makeReq_ = zone.dns.makeReq_.bind(zone.dns); +} + +/** + * Get the metadata for the change in the zone. + * + * @param {function} callback - The callback function. + * @param {?error} callback.err - An API error. + * @param {?object} callback.metadata - Metadata of the change from the API. + * @param {object} callback.apiResponse - Raw API response. + * + * @example + * change.getMetadata(function(err, metadata, apiResponse) { + * if (!err) { + * // metadata = { + * // kind: 'dns#change', + * // additions: [{...}], + * // deletions: [{...}], + * // startTime: '2015-07-21T14:40:06.056Z', + * // id: '1', + * // status: 'done' + * // } + * } + * }); + */ +Change.prototype.getMetadata = function(callback) { + var self = this; + var path = '/managedZones/' + this.zoneName + '/changes/' + this.id; + + this.makeReq_('GET', path, null, null, function(err, resp) { + if (err) { + callback(err, null, resp); + return; + } + + self.metadata = resp; + + callback(null, self.metadata, resp); + }); +}; + +module.exports = Change; diff --git a/lib/dns/index.js b/lib/dns/index.js new file mode 100644 index 00000000000..92a2f63c5ce --- /dev/null +++ b/lib/dns/index.js @@ -0,0 +1,250 @@ +/*! + * Copyright 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/*! + * @module dns + */ + +'use strict'; + +var extend = require('extend'); + +/** + * @type {module:common/streamrouter} + * @private + */ +var streamRouter = require('../common/stream-router.js'); + +/** + * @type {module:common/util} + * @private + */ +var util = require('../common/util.js'); + +/** + * @type {module:dns/zone} + * @private + */ +var Zone = require('./zone.js'); + +/** + * @const {string} Base URL for DNS API. + * @private + */ +var DNS_BASE_URL = 'https://www.googleapis.com/dns/v1/projects/'; + +/** + * @const {array} Required scopes for the DNS API. + * @private + */ +var SCOPES = [ + 'https://www.googleapis.com/auth/ndev.clouddns.readwrite', + 'https://www.googleapis.com/auth/cloud-platform' +]; + +/** + * [Google Cloud DNS](https://cloud.google.com/dns) is a reliable, resilient, + * low-latency DNS serving from Google’s worldwide network of Anycast DNS + * servers. + * + * @constructor + * @alias module:dns + * + * @param {object} options - [Configuration object](#/docs/?method=gcloud). + * + * @example + * var gcloud = require('gcloud')({ + * keyFilename: '/path/to/keyfile.json', + * projectId: 'grape-spaceship-123' + * }); + * + * var dns = gcloud.dns(); + */ +function DNS(options) { + if (!(this instanceof DNS)) { + return new DNS(options); + } + + options = options || {}; + + if (!options.projectId) { + throw util.missingProjectIdError; + } + + this.makeAuthorizedRequest_ = util.makeAuthorizedRequestFactory({ + credentials: options.credentials, + keyFile: options.keyFilename, + scopes: SCOPES, + email: options.email + }); + + this.projectId_ = options.projectId; +} + +/** + * Create a managed zone. + * + * @throws {error} If a zone name is not provided. + * @throws {error} If a zone dnsName is not provided. + * + * @param {string} name - Unique name for the zone. E.g. "my-zone" + * @param {object} config - Configuration object. + * @param {string} config.dnsName - DNS name for the zone. E.g. "example.com." + * @param {string=} config.description - Description text for the zone. + * @param {function} callback - The callback function. + * @param {?error} callback.err - An API error. + * @param {?module:dns/zone} callback.zone - A new {module:dns/zone} object. + * @param {object} callback.apiResponse - Raw API response. + * + * @example + * dns.createZone('my-awesome-zone', { + * dnsName: 'example.com.', // note the period at the end of the domain. + * description: 'This zone is awesome!' + * }, function(err, zone, apiResponse) { + * if (!err) { + * // The zone was created successfully. + * } + * }); + */ +DNS.prototype.createZone = function(name, config, callback) { + var self = this; + + if (!name) { + throw new Error('A zone name is required.'); + } + + if (!config || !config.dnsName) { + throw new Error('A zone dnsName is required.'); + } + + config.name = name; + + // Required by the API. + config.description = config.description || ''; + + this.makeReq_('POST', '/managedZones', null, config, function(err, resp) { + if (err) { + callback(err, null, resp); + return; + } + + var zone = self.zone(resp.name); + zone.metadata = resp; + + callback(null, zone, resp); + }); +}; + +/** + * Gets a list of managed zones for the project. + * + * @param {object=} query - Query object. + * @param {number} query.maxResults - Maximum number of results to return. + * @param {string} query.pageToken - Page token. + * @param {function} callback - The callback function. + * @param {?error} callback.err - An API error. + * @param {?module:dns/zone[]} callback.zones - An array of {module:dns/zone} + * objects. + * @param {object} callback.apiResponse - Raw API response. + * + * @example + * dns.getZones(function(err, zones, apiResponse) {}); + */ +DNS.prototype.getZones = function(query, callback) { + var self = this; + + if (util.is(query, 'function')) { + callback = query; + query = {}; + } + + this.makeReq_('GET', '/managedZones', query, null, function(err, resp) { + if (err) { + callback(err, null, null, resp); + return; + } + + var zones = (resp.managedZones || []).map(function(zone) { + var zoneInstance = self.zone(zone.name); + zoneInstance.metadata = zone; + return zoneInstance; + }); + + var nextQuery = null; + + if (resp.nextPageToken) { + nextQuery = extend({}, query, { + pageToken: resp.nextPageToken + }); + } + + callback(null, zones, nextQuery, resp); + }); +}; + +/** + * Create a zone object representing an existing managed zone. + * + * @throws {error} If a zone name is not provided. + * + * @param {string} name - The unique name of the zone. + * @return {module:dns/zone} + * + * @example + * var zone = dns.zone('my-zone'); + */ +DNS.prototype.zone = function(name) { + if (!name) { + throw new Error('A zone name is required.'); + } + + return new Zone(this, name); +}; + +/** + * Make a new request object from the provided arguments and wrap the callback + * to intercept non-successful responses. + * + * @private + * + * @param {string} method - Action. + * @param {string} path - Request path. + * @param {*} query - Request query object. + * @param {*} body - Request body contents. + * @param {function} callback - The callback function. + */ +DNS.prototype.makeReq_ = function(method, path, query, body, callback) { + var reqOpts = { + method: method, + qs: query, + uri: DNS_BASE_URL + this.projectId_ + path + }; + + if (body) { + reqOpts.json = body; + } + + this.makeAuthorizedRequest_(reqOpts, callback); +}; + +/*! Developer Documentation + * + * These methods can be used with either a callback or as a readable object + * stream. `streamRouter` is used to add this dual behavior. + */ +streamRouter.extend(DNS, 'getZones'); + +module.exports = DNS; diff --git a/lib/dns/record.js b/lib/dns/record.js new file mode 100644 index 00000000000..379c6d31d15 --- /dev/null +++ b/lib/dns/record.js @@ -0,0 +1,167 @@ +/*! + * Copyright 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/*! + * @module dns/record + */ + +'use strict'; + +var extend = require('extend'); + +/** + * @type {module:common/util} + * @private + */ +var util = require('../common/util.js'); + +/** + * Create a Resource Record object. + * + * @constructor + * @alias module:dns/record + * + * @param {object} type - The record type, e.g. `A`, `AAAA`, `MX`. + * @param {object} metadata - The metadata of this record. + * @param {string} metadata.name - The name of the record, e.g. + * `www.example.com.`. + * @param {string[]} metadata.data - Defined in + * [RFC 1035, section 5](https://goo.gl/9EiM0e) and + * [RFC 1034, section 3.6.1](https://goo.gl/Hwhsu9). + * @param {number} metadata.ttl - Seconds that the resource is cached by + * resolvers. + * + * @example + * var gcloud = require('gcloud'); + * + * var dns = gcloud.dns({ + * projectId: 'grape-spaceship-123' + * }); + * + * var zone = dns.zone('my-awesome-zone'); + * + * var record = zone.record('a', { + * name: 'example.com.', + * ttl: 86400, + * data: '1.2.3.4' + * }); + */ +function Record(zone, type, metadata) { + this.zone_ = zone; + + this.type = type; + this.metadata = metadata; + + extend(this, this.toJSON()); + + if (this.rrdatas) { + this.data = this.rrdatas; + delete this.rrdatas; + } +} + +/** + * Create a Record instance from a resource record set in a zone file. + * + * @private + * + * @param {module:dns/zone} zone [description] + * @param {string} type - The record type, e.g. `A`, `AAAA`, `MX`. + * @param {object} bindData - Metadata parsed from dns-zonefile. Properties vary + * based on the type of record. + * @return {module:dns/record} + */ +Record.fromZoneRecord_ = function(zone, type, bindData) { + var typeToZoneFormat = { + a: '{ip}', + aaaa: '{ip}', + cname: '{alias}', + mx: '{preference} {host}', + ns: '{host}', + soa: '{mname} {rname} {serial} {retry} {refresh} {expire} {minimum}', + spf: '{data}', + srv: '{priority} {weight} {port} {target}', + txt: '{txt}' + }; + + var metadata = { + data: util.format(typeToZoneFormat[type.toLowerCase()], bindData), + name: bindData.name, + ttl: bindData.ttl + }; + + return new Record(zone, type, metadata); +}; + +/** + * Delete this record by creating a change on your zone. This is a convenience + * method for: + * + * zone.createChange({ + * delete: record + * }, function(err, change, apiResponse) {}); + * + * @param {function} callback - The callback function. + * @param {?error} callback.err - An API error. + * @param {?module:dns/change} callback.change - A {module:dns/change} object. + * @param {object} callback.apiResponse - Raw API response. + * + * @example + * record.delete(function(err, change, apiResponse) { + * if (!err) { + * // Delete change modification was created. + * } + * }); + */ +Record.prototype.delete = function(callback) { + this.zone_.deleteRecords(this, callback); +}; + +/** + * Serialize the record instance to the format the API expects. + * + * @private + */ +Record.prototype.toJSON = function() { + var recordObject = extend({}, this.metadata, { + type: this.type.toUpperCase() + }); + + if (recordObject.data) { + recordObject.rrdatas = util.arrayize(recordObject.data); + delete recordObject.data; + } + + return recordObject; +}; + +/** + * Convert the record to a string, formatted for a zone file. + * + * @private + * + * @return {string} + */ +Record.prototype.toString = function() { + var json = this.toJSON(); + + return (json.rrdatas || [{}]).map(function(data) { + json.rrdata = data; + return util.format('{name} {ttl} IN {type} {rrdata}', json); + }).join('\n'); +}; + +module.exports = Record; diff --git a/lib/dns/zone.js b/lib/dns/zone.js new file mode 100644 index 00000000000..6a853695f2c --- /dev/null +++ b/lib/dns/zone.js @@ -0,0 +1,816 @@ +/*! + * Copyright 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/*! + * @module dns/zone + */ + +'use strict'; + +var extend = require('extend'); +var fs = require('fs'); +var zonefile = require('dns-zonefile'); + +/** + * @type {module:dns/change} + * @private + */ +var Change = require('./change.js'); + +/** + * @type {module:dns/record} + * @private + */ +var Record = require('./record.js'); + +/** + * @type {module:common/streamrouter} + * @private + */ +var streamRouter = require('../common/stream-router.js'); + +/** + * @type {module:common/util} + * @private + */ +var util = require('../common/util.js'); + +/** + * A Zone object is used to interact with your project's managed zone. It will + * help you add or delete records, delete your zone, and many other convenience + * methods. + * + * @constructor + * @alias module:dns/zone + * + * @example + * var gcloud = require('gcloud'); + * + * var dns = gcloud.dns({ + * keyFilename: '/path/to/keyfile.json', + * projectId: 'grape-spaceship-123' + * }); + * + * var zone = dns.zone('zone-id'); + */ +function Zone(dns, name) { + this.dns = dns; + this.name = name; + this.metadata = {}; + + this.makeReq_ = this.dns.makeReq_.bind(dns); +} + +/** + * Add records to this zone. This is a convenience wrapper around + * {module:dns/zone#createChange}. + * + * @param {module:dns/record|module:dns/record[]} record - The record objects to + * add. + * @param {?error} callback.err - An API error. + * @param {?module:dns/change} callback.change - A {module:dns/change} object. + * @param {object} callback.apiResponse - Raw API response. + */ +Zone.prototype.addRecords = function(records, callback) { + this.createChange({ + add: records + }, callback); +}; + +/** + * Create a reference to an existing change object in this zone. + * + * @throws {error} If an id is not provided. + * + * @param {string} id - The change id. + * @return {module:dns/change} + * + * @example + * var change = zone.change('change-id'); + */ +Zone.prototype.change = function(id) { + if (!id) { + throw new Error('A change id is required.'); + } + + return new Change(this, id); +}; + +/** + * Create a change of resource record sets for the zone. + * + * @param {object} options - The configuration object. + * @param {module:dns/record|module:dns/record[]} options.add - Record objects + * to add to this zone. + * @param {module:dns/record|module:dns/record[]} options.delete - Record + * objects to delete from this zone. Be aware that the resource records here + * must match exactly to be deleted. + * @param {function} callback - The callback function. + * @param {?error} callback.err - An API error. + * @param {?module:dns/change} callback.change - A {module:dns/change} object. + * @param {object} callback.apiResponse - Raw API response. + * + * @example + * var oldARecord = zone.record('a', { + * name: 'example.com.', + * data: '1.2.3.4', + * ttl: 86400 + * }); + * + * var newARecord = zone.record('a', { + * name: 'example.com.', + * data: '5.6.7.8', + * ttl: 86400 + * }); + * + * zone.createChange({ + * add: newARecord, + * delete: oldARecord + * }, function(err, change, apiResponse) { + * if (!err) { + * // The change was created successfully. + * } + * }); + */ +Zone.prototype.createChange = function(options, callback) { + var self = this; + + if (!options || !options.add && !options.delete) { + throw new Error('Cannot create a change with no additions or deletions.'); + } + + var body = extend({}, options, { + additions: util.arrayize(options.add).map(util.exec('toJSON')), + deletions: util.arrayize(options.delete).map(util.exec('toJSON')) + }); + + delete body.add; + delete body.delete; + + var path = '/managedZones/' + this.name + '/changes'; + + this.makeReq_('POST', path, null, body, function(err, resp) { + if (err) { + callback(err, null, resp); + return; + } + + var change = self.change(resp.id); + change.metadata = resp; + + callback(null, change, resp); + }); +}; + +/** + * Delete the zone. + * + * Only empty zones can be deleted. Set options.force to `true` to call + * {module:dns/zone#empty} before deleting the zone. Two API calls will then be + * made (one to empty, another to delete), which means this is not an + * atomic request. + * + * @param {object=} options - Configuration object. + * @param {boolean} options.force - Empty the zone before deleting. + * @param {function} callback - The callback function. + * @param {?error} callback.err - An API error. + * @param {object} callback.apiResponse - Raw API response. + * + * @example + * zone.delete(function(err, apiResponse) { + * if (!err) { + * // The zone is now deleted. + * } + * }); + * + * //- + * // Use `force` to first empty the zone before deleting it. + * //- + * zone.delete({ + * force: true + * }, function(err, apiResponse) { + * if (!err) { + * // The zone is now deleted. + * } + * }); + */ +Zone.prototype.delete = function(options, callback) { + if (util.is(options, 'function')) { + callback = options; + options = {}; + } + + if (options.force) { + this.empty(this.delete.bind(this, callback)); + return; + } + + var path = '/managedZones/' + this.name; + this.makeReq_('DELETE', path, null, null, function(err, resp) { + callback(err, resp); + }); +}; + +/** + * Delete records from this zone. This is a convenience wrapper around + * {module:dns/zone#createChange}. + * + * This method accepts {module:dns/record} objects or string record types in + * its place. This means that you can delete all A records or NS records, etc. + * If used this way, two API requests are made (one to get, then another to + * delete), which means the operation is not atomic and could + * result in unexpected changes. + * + * @param {module:dns/record|module:dns/record[]|string} record - If given a + * string, it is interpreted as a record type. All records that match that + * type will be retrieved and then deleted. You can also provide a + * {module:dns/record} object or array of objects. + * @param {?error} callback.err - An API error. + * @param {?module:dns/change} callback.change - A {module:dns/change} object. + * @param {object} callback.apiResponse - Raw API response. + * + * @example + * var oldARecord = zone.record('a', { + * name: 'example.com.', + * data: '1.2.3.4', + * ttl: 86400 + * }); + * + * var callback = function(err, change, apiResponse) { + * if (!err) { + * // Delete change modification was created. + * } + * }; + * + * zone.deleteRecords(oldARecord, callback); + * + * //- + * // Delete multiple records at once. + * //- + * var oldNs1Record = zone.record('ns', { + * name: 'example.com.', + * data: 'ns-cloud1.googledomains.com.', + * ttl: 86400 + * }); + * + * var oldNs2Record = zone.record('ns', { + * name: 'example.com.', + * data: 'ns-cloud2.googledomains.com.', + * ttl: 86400 + * }); + * + * zone.deleteRecords([ + * oldNs1Record, + * oldNs2Record + * ], callback); + * + * //- + * // Possibly a simpler way to perform the above change is deleting records by + * // type. + * //- + * zone.deleteRecords('ns', callback); + * + * //- + * // You can also delete records of multiple types. + * //- + * zone.deleteRecords(['ns', 'a'], callback); + */ +Zone.prototype.deleteRecords = function(records, callback) { + records = util.arrayize(records); + + if (util.is(records[0], 'string')) { + this.deleteRecordsByType_(records, callback); + return; + } + + this.createChange({ + delete: records + }, callback); +}; + +/** + * Emptying your zone means leaving only 'NS' and 'SOA' records. This method + * will first fetch the non-NS and non-SOA records from your zone using + * {module:dns/zone#getRecords}, then use {module:dns/zone#createChange} to + * create a deletion change. + * + * @param {function} callback - The callback function. + * @param {?error} callback.err - An API error. + * @param {?module:dns/change} callback.change - A {module:dns/change} object. + * @param {object} callback.apiResponse - Raw API response. + */ +Zone.prototype.empty = function(callback) { + var self = this; + + this.getRecords(function(err, records) { + if (err) { + callback(err); + return; + } + + var recordsToDelete = records.filter(function(record) { + return record.type !== 'NS' && record.type !== 'SOA'; + }); + + if (recordsToDelete.length === 0) { + callback(); + } else { + self.deleteRecords(recordsToDelete, callback); + } + }); +}; + +/** + * Provide a path to a zone file to copy records into the zone. + * + * @param {string} localPath - The fully qualified path to the zone file. + * @param {function} callback - The callback function. + * @param {?error} callback.err - An API or file system error. + * + * @example + * var zoneFilename = '/Users/stephen/zonefile.zone'; + * + * zone.export(zoneFilename, function(err) { + * if (!err) { + * // The zone file was created successfully. + * } + * }); + */ +Zone.prototype.export = function(localPath, callback) { + this.getRecords(function(err, records) { + if (err) { + callback(err); + return; + } + + var stringRecords = records.map(util.exec('toString')).join('\n'); + + fs.writeFile(localPath, stringRecords, 'utf-8', function(err) { + callback(err || null); + }); + }); +}; + +/** + * Get the list of changes associated with this zone. A change is an atomic + * update to a collection of records. + * + * @param {object=} query - The query object. + * @param {number} query.maxResults - Maximum number of results to return. + * @param {string} query.pageToken - The page token. + * @param {string} query.sort - Set to 'asc' for ascending, and 'desc' for + * descending or omit for no sorting. + * @param {function} callback - The callback function. + * @param {?error} callback.err - An API error. + * @param {?module:dns/change[]} callback.changes - An array of + * {module:dns/change} objects. + * @param {?object} callback.nextQuery - A query object representing the next + * page of results. + * @param {object} callback.apiResponse - Raw API response. + * + * @example + * var callback = function(err, changes, nextQuery, apiResponse) { + * // The `metadata` property is populated for you with the metadata at the + * // time of fetching. + * changes[0].metadata; + * + * // However, in cases where you are concerned the metadata could have + * // changed, use the `getMetadata` method. + * changes[0].getMetadata(function(err, metadata) {}); + + * if (nextQuery) { + * // nextQuery will be non-null if there are more results. + * zone.getChanges(nextQuery, callback); + * } + * }; + * + * zone.getChanges(callback); + * + * //- + * // Get the changes from your zone as a readable object stream. + * //- + * zone.getChanges() + * .on('error', console.error) + * .on('data', function(change) { + * // change is a Change object. + * }) + * .on('end', function() { + * // All changes retrieved. + * }); + * + * //- + * // If you anticipate many results, you can end a stream early to prevent + * // unnecessary processing and API requests. + * //- + * zone.getChanges() + * .on('data', function(change) { + * this.end(); + * }); + */ +Zone.prototype.getChanges = function(query, callback) { + var self = this; + + if (util.is(query, 'function')) { + callback = query; + query = {}; + } + + if (query.sort) { + query.sortOrder = query.sort === 'asc' ? 'ascending' : 'descending'; + delete query.sort; + } + + var path = '/managedZones/' + this.name + '/changes'; + + this.makeReq_('GET', path, query, null, function(err, resp) { + if (err) { + callback(err, null, null, resp); + return; + } + + var changes = (resp.changes || []).map(function(change) { + var changeInstance = self.change(change.id); + changeInstance.metadata = change; + return changeInstance; + }); + + var nextQuery = null; + if (resp.nextPageToken) { + nextQuery = extend({}, query, { + pageToken: resp.nextPageToken + }); + } + + callback(null, changes, nextQuery, resp); + }); +}; + +/** + * Get the metadata for the zone. + * + * @param {function} callback - The callback function. + * @param {?error} callback.err - An API error. + * @param {?object} callback.metadata - Metadata of the zone from the API. + * @param {object} callback.apiResponse - Raw API response. + * + * @example + * zone.getMetadata(function(err, metadata, apiResponse) {}); + */ +Zone.prototype.getMetadata = function(callback) { + var self = this; + var path = '/managedZones/' + this.name; + + this.makeReq_('GET', path, null, null, function(err, resp) { + if (err) { + callback(err, null, resp); + return; + } + + self.metadata = resp; + + callback(null, self.metadata, resp); + }); +}; + +/** + * Get the list of records for this zone. + * + * @param {object=} query - The query object. + * @param {string} query.name - Restricts the list to return only records with + * this fully qualified domain name. + * @param {string} query.type - Restricts the list to return only records of + * this type. If present, the "name" parameter must also be present. + * @param {number} query.maxResults - Maximum number of results to be returned. + * @param {string} query.pageToken - The page token. + * @param {function} callback - The callback function. + * @param {?error} callback.err - An API error. + * @param {?module:dns/record[]} callback.records - An array of + * {module:dns/record} objects. + * @param {?object} callback.nextQuery - A query object representing the next + * page of results. + * @param {object} callback.apiResponse - Raw API response. + * + * @example + * var callback = function(err, records, nextQuery, apiResponse) { + * if (!err) { + * // records is an array of Record objects. + * } + * + * if (nextQuery) { + * zone.getRecords(nextQuery, callback); + * } + * }; + * + * zone.getRecords(callback); + * + * //- + * // Provide a query for further customization. + * //- + * // Get the namespace records for example.com. + * var query = { + * name: 'example.com.', + * type: 'NS' + * }; + * + * zone.getRecords(query, callback); + * + * //- + * // Get the records from your zone as a readable object stream. + * //- + * zone.getRecords() + * .on('error', console.error) + * .on('data', function(record) { + * // record is a Record object. + * }) + * .on('end', function() { + * // All records retrieved. + * }); + * + * //- + * // If you anticipate many results, you can end a stream early to prevent + * // unnecessary processing and API requests. + * //- + * zone.getRecords() + * .on('data', function(change) { + * this.end(); + * }); + * + * //- + * // If you only want records of a specific type or types, provide them in + * // place of the query object. + * //- + * zone.getRecords('ns', function(err, records) { + * if (!err) { + * // records is an array of NS Record objects in your zone. + * } + * }); + * + * //- + * // You can also specify multiple record types. + * //- + * zone.getRecords(['ns', 'a', 'cname'], function(err, records) { + * if (!err) { + * // records is an array of NS, A, and CNAME records in your zone. + * } + * }); + */ +Zone.prototype.getRecords = function(query, callback) { + var self = this; + + if (util.is(query, 'function')) { + callback = query; + query = {}; + } + + if (util.is(query, 'string') || util.is(query, 'array')) { + var filterByTypes_ = {}; + + // For faster lookups, store the record types the user wants in an object. + util.arrayize(query).forEach(function(type) { + filterByTypes_[type.toUpperCase()] = true; + }); + + query = { + filterByTypes_: filterByTypes_ + }; + } + + var requestQuery = extend({}, query); + delete requestQuery.filterByTypes_; + + var path = '/managedZones/' + this.name + '/rrsets'; + this.makeReq_('GET', path, requestQuery, true, function(err, resp) { + if (err) { + callback(err, null, null, resp); + return; + } + + var records = (resp.rrsets || []).map(function(record) { + return self.record(record.type, record); + }); + + if (query.filterByTypes_) { + records = records.filter(function(record) { + return query.filterByTypes_[record.type]; + }); + } + + var nextQuery = null; + if (resp.nextPageToken) { + nextQuery = extend({}, query, { + pageToken: resp.nextPageToken + }); + } + + callback(null, records, nextQuery, resp); + }); +}; + +/** + * Copy the records from a zone file into this zone. + * + * @param {string} localPath - The fully qualified path to the zone file. + * @param {function} callback - The callback function. + * @param {?error} callback.err - An API or file system error. + * @param {?module:dns/change} callback.change - A {module:dns/change} object. + * @param {?object} callback.apiResponse - Raw API response. + * + * @example + * var zoneFilename = '/Users/dave/zonefile.zone'; + * + * zone.import(zoneFilename, function(err, change, apiResponse) { + * if (!err) { + * // The change was created successfully. + * } + * }); + */ +Zone.prototype.import = function(localPath, callback) { + var self = this; + + fs.readFile(localPath, 'utf-8', function(err, file) { + if (err) { + callback(err); + return; + } + + var parsedZonefile = zonefile.parse(file); + + var recordsToCreate = []; + function addRecordToCreate(record) { + var recordInstance = new Record.fromZoneRecord_(self, recordType, record); + recordsToCreate.push(recordInstance); + } + + for (var recordType in parsedZonefile) { + var recordTypeSet = util.arrayize(parsedZonefile[recordType]); + recordTypeSet.forEach(addRecordToCreate); + } + + self.addRecords(recordsToCreate, callback); + }); +}; + +/** + * A {module:dns/record} object can be used to construct a record you want to + * add to your zone, or to refer to an existing one. + * + * Note that using this method will not itself make any API requests. You will + * use the object returned in other API calls, for example to add a record to + * your zone or to delete an existing one. + * + * @param {string} type - The type of record to construct or the type of record + * you are referencing. + * @param {object} metadata - The metadata of this record. + * @param {string} metadata.name - The name of the record, e.g. + * `www.example.com.`. + * @param {string[]} metadata.data - Defined in + * [RFC 1035, section 5](https://goo.gl/9EiM0e) and + * [RFC 1034, section 3.6.1](https://goo.gl/Hwhsu9). + * @param {number} metadata.ttl - Seconds that the resource is cached by + * resolvers. + * @return {module:dns/record} + * + * @example + * //- + * // Reference an existing record to delete from your zone. + * //- + * var oldARecord = zone.record('a', { + * name: 'example.com.', + * data: '1.2.3.4', + * ttl: 86400 + * }); + * + * //- + * // Construct a record to add to your zone. + * //- + * var newARecord = zone.record('a', { + * name: 'example.com.', + * data: '5.6.7.8', + * ttl: 86400 + * }); + * + * //- + * // Use these records together to create a change. + * //- + * zone.createChange({ + * add: newARecord, + * delete: oldARecord + * }, function(err, change, apiResponse) {}); + */ +Zone.prototype.record = function(type, metadata) { + return new Record(this, type, metadata); +}; + +/** + * Provide a record type that should be deleted and replaced with other records. + * + * This is not an atomic request. Two API requests are made + * (one to get records of the type that you've requested, then another to + * replace them), which means the operation is not atomic and could result in + * unexpected changes. + * + * @param {string|string[]} recordTypes - Type(s) of records to replace. + * @param {module:dns/record|module:dns/record[]} newRecords - The record + * objects to add. + * @param {function} callback - The callback function. + * @param {?error} callback.err - An API error. + * @param {?module:dns/change} callback.change - A {module:dns/change} object. + * @param {?object} callback.apiResponse - Raw API response. + * + * @example + * var newNs1Record = zone.record('ns', { + * name: 'example.com.', + * data: 'ns-cloud1.googledomains.com.', + * ttl: 86400 + * }); + * + * var newNs2Record = zone.record('ns', { + * name: 'example.com.', + * data: 'ns-cloud2.googledomains.com.', + * ttl: 86400 + * }); + * + * var newNsRecords = [ + * newNs1Record, + * newNs2Record + * ]; + * + * zone.replaceRecords('ns', newNsRecords, function(err, change, apiResponse) { + * if (!err) { + * // The change was created successfully. + * } + * }); + */ +Zone.prototype.replaceRecords = function(recordType, newRecords, callback) { + var self = this; + + this.getRecords(recordType, function(err, recordsToDelete) { + if (err) { + callback(err); + return; + } + + self.createChange({ + add: newRecords, + delete: recordsToDelete + }, callback); + }); +}; + +/** + * Delete records from the zone matching an array of types. + * + * @private + * + * @param {string[]} recordTypes - Types of records to delete. Ex: 'NS', 'A'. + * @param {function} callback - The callback function. + * @param {?error} callback.err - An API error. + * @param {?module:dns/change} callback.change - A {module:dns/change} object. + * @param {?object} callback.apiResponse - Raw API response. + * + * @example + * zone.deleteRecordsByType_(['NS', 'A'], function(err, change, apiResponse) { + * if (!err) { + * // The change was created successfully. + * } + * }); + */ +Zone.prototype.deleteRecordsByType_ = function(recordTypes, callback) { + var self = this; + + this.getRecords(recordTypes, function(err, records) { + if (err) { + callback(err); + return; + } + + if (records.length === 0) { + callback(); + return; + } + + self.deleteRecords(records, callback); + }); +}; + +/*! Developer Documentation + * + * These methods can be used with either a callback or as a readable object + * stream. `streamRouter` is used to add this dual behavior. + */ +streamRouter.extend(Zone, ['getChanges', 'getRecords']); + +module.exports = Zone; diff --git a/lib/index.js b/lib/index.js index 0a03374ac49..dedd6f70f80 100644 --- a/lib/index.js +++ b/lib/index.js @@ -32,6 +32,12 @@ var BigQuery = require('./bigquery'); */ var Datastore = require('./datastore'); +/** + * @type {module:dns} + * @private + */ +var DNS = require('./dns'); + /** * @type {module:pubsub} * @private @@ -122,6 +128,10 @@ function gcloud(config) { return new BigQuery(util.extendGlobalConfig(config, options)); }, datastore: new Datastore(config), + dns: function(options) { + options = options || {}; + return new DNS(util.extendGlobalConfig(config, options)); + }, pubsub: function(options) { options = options || {}; return new PubSub(util.extendGlobalConfig(config, options)); @@ -176,6 +186,8 @@ gcloud.bigquery = BigQuery; */ gcloud.datastore = Datastore; +gcloud.dns = DNS; + /** * **Experimental** * diff --git a/package.json b/package.json index bda8f7ee671..25a0c518b64 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "buffer-equal": "0.0.1", "concat-stream": "^1.5.0", "configstore": "^1.0.0", + "dns-zonefile": "0.1.9", "duplexify": "^3.2.0", "extend": "^2.0.0", "google-auth-library": "^0.9.4", diff --git a/scripts/docs.sh b/scripts/docs.sh index 24a477ad27c..49020e714d4 100755 --- a/scripts/docs.sh +++ b/scripts/docs.sh @@ -21,6 +21,11 @@ ./node_modules/.bin/dox < lib/bigquery/job.js > docs/json/master/bigquery/job.json & ./node_modules/.bin/dox < lib/bigquery/table.js > docs/json/master/bigquery/table.json & +./node_modules/.bin/dox < lib/dns/change.js > docs/json/master/dns/change.json & +./node_modules/.bin/dox < lib/dns/index.js > docs/json/master/dns/index.json & +./node_modules/.bin/dox < lib/dns/record.js > docs/json/master/dns/record.json & +./node_modules/.bin/dox < lib/dns/zone.js > docs/json/master/dns/zone.json & + ./node_modules/.bin/dox < lib/datastore/dataset.js > docs/json/master/datastore/dataset.json & ./node_modules/.bin/dox < lib/datastore/index.js > docs/json/master/datastore/index.json & ./node_modules/.bin/dox < lib/datastore/query.js > docs/json/master/datastore/query.json & diff --git a/system-test/data/zonefile.zone b/system-test/data/zonefile.zone new file mode 100644 index 00000000000..9b123e1c583 --- /dev/null +++ b/system-test/data/zonefile.zone @@ -0,0 +1,2 @@ +{DNS_DOMAIN} 21600 IN SPF "v=spf1" "mx:{DNS_DOMAIN}" "-all" +{DNS_DOMAIN} 21600 IN TXT "google-site-verification=xxxxxxxxxxxxYYYYYYXXX" diff --git a/system-test/dns.js b/system-test/dns.js new file mode 100644 index 00000000000..97f8de94c3f --- /dev/null +++ b/system-test/dns.js @@ -0,0 +1,393 @@ +/** + * Copyright 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var assert = require('assert'); +var async = require('async'); +var fs = require('fs'); +var tmp = require('tmp'); +var uuid = require('node-uuid'); + +var env = require('./env.js'); +var DNS = require('../lib/dns'); +var util = require('../lib/common/util.js'); + +var dns = new DNS(env); +var DNS_DOMAIN = process.env.GCLOUD_TESTS_DNS_DOMAIN; + +// Only run the tests if there is a domain to test with. +(DNS_DOMAIN ? describe : describe.skip)('dns', function() { + var ZONE; + var ZONENAME = 'test-zone-' + uuid.v4().substr(0, 18); + + var records = {}; + + function createRecords() { + records.a = ZONE.record('a', { + ttl: 86400, + name: DNS_DOMAIN, + data: '1.2.3.4' + }); + + records.aaaa = ZONE.record('aaaa', { + ttl: 86400, + name: DNS_DOMAIN, + data: '2607:f8b0:400a:801::1005' + }); + + records.cname = ZONE.record('cname', { + ttl: 86400, + name: 'mail.' + DNS_DOMAIN, + data: 'example.com.' + }); + + records.mx = ZONE.record('mx', { + ttl: 86400, + name: DNS_DOMAIN, + data: [ + '10 mail.' + DNS_DOMAIN, + '20 mail2.' + DNS_DOMAIN + ] + }); + + records.naptr = ZONE.record('naptr', { + ttl: 300, + name: '2.1.2.1.5.5.5.0.7.7.1.e164.arpa.', + data: [ + '100 10 \"u\" \"sip+E2U\" \"!^.*$!sip:information@foo.se!i\" .', + '102 10 \"u\" \"smtp+E2U\" \"!^.*$!mailto:information@foo.se!i\" .' + ] + }); + + records.ns = ZONE.record('ns', { + ttl: 86400, + name: DNS_DOMAIN, + data: 'ns-cloud1.googledomains.com.' + }); + + records.ptr = ZONE.record('ptr', { + ttl: 60, + name: '2.1.0.10.in-addr.arpa.', + data: 'server.' + DNS_DOMAIN + }); + + records.soa = ZONE.record('soa', { + ttl: 21600, + name: DNS_DOMAIN, + data: [ + 'ns-cloud1.googledomains.com.', + 'dns-admin.google.com.', + '1 21600 3600 1209600 300' + ].join(' ') + }); + + records.spf = ZONE.record('spf', { + ttl: 21600, + name: DNS_DOMAIN, + data: 'v=spf1 mx:' + DNS_DOMAIN.replace(/.$/, '') + ' -all' + }); + + records.srv = ZONE.record('srv', { + ttl: 21600, + name: 'sip.' + DNS_DOMAIN, + data: '0 5 5060 sip.' + DNS_DOMAIN + }); + + records.txt = ZONE.record('txt', { + ttl: 21600, + name: DNS_DOMAIN, + data: 'google-site-verification=xxxxxxxxxxxxYYYYYYXXX' + }); + } + + before(function(done) { + dns.getZones(function(err, zones) { + if (err) { + done(err); + return; + } + + async.each(zones, util.exec('delete', { force: true }), function(err) { + if (err) { + done(err); + return; + } + + dns.createZone(ZONENAME, { dnsName: DNS_DOMAIN }, function(err, zone) { + assert.ifError(err); + ZONE = zone; + createRecords(); + done(); + }); + }); + }); + }); + + after(function(done) { + ZONE.delete({ force: true }, done); + }); + + it('should create a zone', function(done) { + var tempName = 'test-zone-' + uuid.v4().substr(0, 18); + + dns.createZone(tempName, { dnsName: DNS_DOMAIN }, function(err, zone) { + assert.ifError(err); + assert.equal(zone.name, tempName); + zone.delete(done); + }); + }); + + it('should return 0 or more zones', function(done) { + dns.getZones(function(err, zones) { + assert.ifError(err); + assert(zones.length >= 0); + done(); + }); + }); + + describe('Zones', function() { + it('should get the metadata for a zone', function(done) { + ZONE.getMetadata(function(err, metadata) { + assert.ifError(err); + assert.equal(metadata.name, ZONENAME); + done(); + }); + }); + + it('should delete a zone', function(done) { + var name = 'test-zone-' + uuid.v4().substr(0, 18); + + dns.createZone(name, { dnsName: DNS_DOMAIN }, function(err, zone) { + assert.ifError(err); + zone.delete(done); + }); + }); + + it('should support all types of records', function(done) { + var recordsToCreate = [ + records.a, + records.aaaa, + records.cname, + records.mx, + // records.naptr, + records.ns, + // records.ptr, + records.soa, + records.spf, + records.srv, + records.txt + ]; + + ZONE.replaceRecords(['ns', 'soa'], recordsToCreate, done); + }); + + it('should import records from a zone file', function(done) { + var zoneFilename = require.resolve('./data/zonefile.zone'); + var zoneFileTemplate = fs.readFileSync(zoneFilename, 'utf-8'); + zoneFileTemplate = util.format(zoneFileTemplate, { + DNS_DOMAIN: DNS_DOMAIN + }); + + tmp.setGracefulCleanup(); + tmp.file(function _tempFileCreated(err, tmpFilePath) { + assert.ifError(err); + + fs.writeFileSync(tmpFilePath, zoneFileTemplate, 'utf-8'); + + ZONE.empty(function(err) { + assert.ifError(err); + + ZONE.import(tmpFilePath, function(err) { + assert.ifError(err); + + ZONE.getRecords(['spf', 'txt'], function(err, records) { + assert.ifError(err); + + var spfRecord = records.filter(function(record) { + return record.type === 'SPF'; + })[0]; + + assert.strictEqual( + spfRecord.toJSON().rrdatas[0], + '"v=spf1" "mx:' + DNS_DOMAIN + '" "-all"' + ); + + var txtRecord = records.filter(function(record) { + return record.type === 'TXT'; + })[0]; + + assert.strictEqual( + txtRecord.toJSON().rrdatas[0], + '"google-site-verification=xxxxxxxxxxxxYYYYYYXXX"' + ); + + done(); + }); + }); + }); + }); + }); + + it('should export records to a zone file', function(done) { + tmp.setGracefulCleanup(); + tmp.file(function tempFileCreated(err, tmpFilename) { + assert.ifError(err); + + async.series([ + function(next) { + ZONE.empty(next); + }, + + function(next) { + var recordsToCreate = [ + records.spf, + records.srv + ]; + + ZONE.addRecords(recordsToCreate, next); + }, + + function(next) { + ZONE.export(tmpFilename, next); + } + ], done); + }); + }); + + describe('changes', function() { + it('should create a change', function(done) { + var record = ZONE.record('srv', { + ttl: 3600, + name: DNS_DOMAIN, + data: '10 0 5222 127.0.0.1.' + }); + + ZONE.createChange({ add: record }, function(err, change) { + assert.ifError(err); + + var addition = change.metadata.additions[0]; + delete addition.kind; + assert.deepEqual(addition, record.toJSON()); + + done(); + }); + }); + + it('should get a list of changes', function(done) { + ZONE.getChanges(function(err, changes) { + assert.ifError(err); + assert(changes.length >= 0); + done(); + }); + }); + + it('should get metadata', function(done) { + ZONE.getChanges(function(err, changes) { + assert.ifError(err); + + var change = changes[0]; + var expectedMetadata = change.metadata; + + change.getMetadata(function(err, metadata) { + assert.ifError(err); + + delete metadata.status; + delete expectedMetadata.status; + assert.deepEqual(metadata, expectedMetadata); + + done(); + }); + }); + }); + }); + }); + + describe('Records', function() { + it('should return 0 or more records', function(done) { + ZONE.getRecords(function(err, records) { + assert.ifError(err); + assert(records.length >= 0); + done(); + }); + }); + + it('should cursor through records by type', function(done) { + var newRecords = [ + ZONE.record('cname', { + ttl: 86400, + name: '1.' + DNS_DOMAIN, + data: DNS_DOMAIN + }), + ZONE.record('cname', { + ttl: 86400, + name: '2.' + DNS_DOMAIN, + data: DNS_DOMAIN + }) + ]; + + ZONE.replaceRecords('cname', newRecords, function(err) { + assert.ifError(err); + + var callback = function(err, records, nextQuery) { + if (nextQuery) { + ZONE.getRecords(nextQuery, callback); + return; + } + + ZONE.deleteRecords(newRecords, done); + }; + + ZONE.getRecords({ + types: 'cname', + maxResults: 2 + }, callback); + }); + }); + + it('should replace records', function(done) { + var name = 'test-zone-' + uuid.v4().substr(0, 18); + + dns.createZone(name, { dnsName: DNS_DOMAIN }, function(err, zone) { + assert.ifError(err); + + zone.getRecords('ns', function(err, originalRecords) { + assert.ifError(err); + + var originalData = originalRecords[0].data; + + var newRecord = zone.record('ns', { + ttl: 3600, + name: DNS_DOMAIN, + data: ['ns1.nameserver.net.', 'ns2.nameserver.net.'] + }); + + zone.replaceRecords('ns', newRecord, function(err, change) { + assert.ifError(err); + + var deleted = change.metadata.deletions[0].rrdatas; + var added = change.metadata.additions[0].rrdatas; + + assert.deepEqual(deleted, originalData); + assert.deepEqual(added, newRecord.data); + + done(); + }); + }); + }); + }); + }); +}); diff --git a/test/common/util.js b/test/common/util.js index 38376f5bcce..f2658f4118f 100644 --- a/test/common/util.js +++ b/test/common/util.js @@ -932,6 +932,36 @@ describe('common/util', function() { }); }); + describe('exec', function() { + it('should execute the function specified', function() { + var people = [ + { + getName: function() { return 'Stephen'; } + }, + { + getName: function() { return 'Dave'; } + } + ]; + + assert.deepEqual(people.map(util.exec('getName')), ['Stephen', 'Dave']); + }); + + it('should accept arguments', function() { + var people = [ + { + getName: function(prefix) { return prefix + ' Stephen'; } + }, + { + getName: function(prefix) { return prefix + ' Dave'; } + } + ]; + + var expectedNames = ['Mr. Stephen', 'Mr. Dave']; + + assert.deepEqual(people.map(util.exec('getName', 'Mr.')), expectedNames); + }); + }); + describe('prop', function() { it('should return objects that match the property name', function() { var people = [ diff --git a/test/dns/change.js b/test/dns/change.js new file mode 100644 index 00000000000..a950ad64954 --- /dev/null +++ b/test/dns/change.js @@ -0,0 +1,130 @@ +/** + * Copyright 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var assert = require('assert'); +var extend = require('extend'); + +var Change = require('../../lib/dns/change.js'); +var util = require('../../lib/common/util.js'); + +describe('Change', function() { + var ZONE = { + name: 'zone-name', + dns: { + makeReq_: util.noop + } + }; + + var CHANGE_ID = 'change-id'; + + var change; + + beforeEach(function() { + change = new Change(ZONE, CHANGE_ID); + }); + + describe('instantiation', function() { + it('should localize the zone name', function() { + assert.strictEqual(change.zoneName, ZONE.name); + }); + + it('should localize the id', function() { + assert.strictEqual(change.id, CHANGE_ID); + }); + + it('should create a makeReq_ function from the Zone', function(done) { + var zone = extend({}, ZONE, { + dns: { + makeReq_: function() { + assert.strictEqual(this, zone.dns); + done(); + } + } + }); + + new Change(zone, CHANGE_ID).makeReq_(); + }); + }); + + describe('getMetadata', function() { + it('should make the correct API request', function(done) { + change.makeReq_ = function(method, path, query, body) { + assert.strictEqual(method, 'GET'); + + var expectedPath = util.format('/managedZones/{z}/changes/{c}', { + z: ZONE.name, + c: CHANGE_ID + }); + assert.strictEqual(path, expectedPath); + + assert.strictEqual(query, null); + assert.strictEqual(body, null); + + done(); + }; + + change.getMetadata(assert.ifError); + }); + + describe('error', function() { + var error = new Error('Error.'); + var apiResponse = { a: 'b', c: 'd' }; + + beforeEach(function() { + change.makeReq_ = function(method, path, query, body, callback) { + callback(error, apiResponse); + }; + }); + + it('should execute callback with error and API response', function(done) { + change.getMetadata(function(err, metadata, apiResponse_) { + assert.strictEqual(err, error); + assert.strictEqual(apiResponse_, apiResponse); + done(); + }); + }); + }); + + describe('success', function() { + var metadata = { e: 'f', g: 'h' }; + + beforeEach(function() { + change.makeReq_ = function(method, path, query, body, callback) { + callback(null, metadata, metadata); + }; + }); + + it('should update the metadata', function(done) { + change.getMetadata(function(err) { + assert.ifError(err); + assert.strictEqual(change.metadata, metadata); + done(); + }); + }); + + it('should execute callback with metadata & API resp', function(done) { + change.getMetadata(function(err, metadata_, apiResponse_) { + assert.ifError(err); + assert.strictEqual(metadata_, metadata); + assert.strictEqual(apiResponse_, metadata); + done(); + }); + }); + }); + }); +}); diff --git a/test/dns/index.js b/test/dns/index.js new file mode 100644 index 00000000000..2b5ab1e2038 --- /dev/null +++ b/test/dns/index.js @@ -0,0 +1,405 @@ +/** + * Copyright 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var assert = require('assert'); +var extend = require('extend'); +var mockery = require('mockery'); + +var util = require('../../lib/common/util.js'); + +var extended = false; +var fakeStreamRouter = { + extend: function(Class, methods) { + if (Class.name !== 'DNS') { + return; + } + + extended = true; + methods = util.arrayize(methods); + assert.equal(Class.name, 'DNS'); + assert.deepEqual(methods, ['getZones']); + } +}; + +var makeAuthorizedRequestFactoryOverride; +var fakeUtil = extend({}, util, { + makeAuthorizedRequestFactory: function() { + if (makeAuthorizedRequestFactoryOverride) { + return makeAuthorizedRequestFactoryOverride.apply(null, arguments); + } else { + return util.makeAuthorizedRequestFactory.apply(null, arguments); + } + } +}); + +function FakeZone() { + this.calledWith_ = arguments; +} + +describe('DNS', function() { + var DNS; + var dns; + + var PROJECT_ID = 'project-id'; + + before(function() { + mockery.registerMock('../common/stream-router.js', fakeStreamRouter); + mockery.registerMock('../common/util.js', fakeUtil); + mockery.registerMock('./zone.js', FakeZone); + mockery.enable({ + useCleanCache: true, + warnOnUnregistered: false + }); + + DNS = require('../../lib/dns/index.js'); + }); + + after(function() { + mockery.deregisterAll(); + mockery.disable(); + }); + + beforeEach(function() { + makeAuthorizedRequestFactoryOverride = null; + + dns = new DNS({ + projectId: PROJECT_ID + }); + }); + + describe('instantiation', function() { + it('should extend the correct methods', function() { + assert(extended); // See `fakeStreamRouter.extend` + }); + + it('should throw if an ID is not provided', function() { + assert.throws(function() { + new DNS(); + }, /Sorry, we cannot connect/); + }); + + it('should create an authorized request function', function(done) { + var options = { + projectId: 'projectId', + credentials: 'credentials', + email: 'email', + keyFilename: 'keyFile' + }; + + makeAuthorizedRequestFactoryOverride = function(options_) { + assert.deepEqual(options_, { + credentials: options.credentials, + email: options.email, + keyFile: options.keyFilename, + scopes: [ + 'https://www.googleapis.com/auth/ndev.clouddns.readwrite', + 'https://www.googleapis.com/auth/cloud-platform' + ] + }); + return done; + }; + + var dns = new DNS(options); + dns.makeAuthorizedRequest_(); + }); + + it('should localize the projectId', function() { + assert.equal(dns.projectId_, PROJECT_ID); + }); + }); + + describe('createZone', function() { + var zoneName = 'zone-name'; + var config = { dnsName: 'dns-name' }; + + it('should throw if a zone name is not provided', function() { + assert.throws(function() { + dns.createZone(); + }, /A zone name is required/); + }); + + it('should throw if a zone dnsname is not provided', function() { + assert.throws(function() { + dns.createZone(zoneName); + }, /A zone dnsName is required/); + + assert.throws(function() { + dns.createZone(zoneName, {}); + }, /A zone dnsName is required/); + }); + + it('should use a provided description', function(done) { + var cfg = extend({}, config, { description: 'description' }); + + dns.makeReq_ = function(method, path, query, body) { + assert.strictEqual(body.description, cfg.description); + done(); + }; + + dns.createZone(zoneName, cfg, assert.ifError); + }); + + it('should default a description to ""', function(done) { + dns.makeReq_ = function(method, path, query, body) { + assert.strictEqual(body.description, ''); + done(); + }; + + dns.createZone(zoneName, config, assert.ifError); + }); + + it('should make the correct API request', function(done) { + dns.makeReq_ = function(method, path, query, body) { + assert.strictEqual(method, 'POST'); + assert.strictEqual(path, '/managedZones'); + assert.strictEqual(query, null); + + var expectedBody = extend({}, config, { + name: zoneName, + description: '' + }); + assert.deepEqual(body, expectedBody); + + done(); + }; + + dns.createZone(zoneName, config, assert.ifError); + }); + + describe('error', function() { + var error = new Error('Error.'); + var apiResponse = { a: 'b', c: 'd' }; + + beforeEach(function() { + dns.makeReq_ = function(method, path, query, body, callback) { + callback(error, apiResponse); + }; + }); + + it('should execute callback with error and API response', function(done) { + dns.createZone(zoneName, config, function(err, zone, apiResponse_) { + assert.strictEqual(err, error); + assert.strictEqual(zone, null); + assert.strictEqual(apiResponse_, apiResponse); + done(); + }); + }); + }); + + describe('success', function() { + var apiResponse = { name: zoneName }; + var zone = {}; + + beforeEach(function() { + dns.makeReq_ = function(method, path, query, body, callback) { + callback(null, apiResponse); + }; + + dns.zone = function() { + return zone; + }; + }); + + it('should create a zone from the response', function(done) { + dns.zone = function(name) { + assert.strictEqual(name, apiResponse.name); + setImmediate(done); + return zone; + }; + + dns.createZone(zoneName, config, assert.ifError); + }); + + it('should execute callback with zone and API response', function(done) { + dns.createZone(zoneName, config, function(err, zone_, apiResponse_) { + assert.ifError(err); + assert.strictEqual(zone_, zone); + assert.strictEqual(apiResponse_, apiResponse); + + done(); + }); + }); + + it('should set the metadata to the response', function(done) { + dns.createZone(zoneName, config, function(err, zone) { + assert.strictEqual(zone.metadata, apiResponse); + done(); + }); + }); + }); + }); + + describe('getZones', function() { + it('should make the correct request', function(done) { + var query = { a: 'b', c: 'd' }; + + dns.makeReq_ = function(method, path, query_, body) { + assert.strictEqual(method, 'GET'); + assert.strictEqual(path, '/managedZones'); + assert.strictEqual(query, query); + assert.strictEqual(body, null); + + done(); + }; + + dns.getZones(query, assert.ifError); + }); + + it('should use an empty query if one was not provided', function(done) { + dns.makeReq_ = function(method, path, query) { + assert.equal(Object.keys(query).length, 0); + done(); + }; + + dns.getZones(assert.ifError); + }); + + describe('error', function() { + var error = new Error('Error.'); + var apiResponse = { a: 'b', c: 'd' }; + + beforeEach(function() { + dns.makeReq_ = function(method, path, query, body, callback) { + callback(error, apiResponse); + }; + }); + + it('should execute callback with error and API response', function(done) { + dns.getZones({}, function(err, zones, nextQuery, apiResponse_) { + assert.strictEqual(err, error); + assert.strictEqual(zones, null); + assert.strictEqual(nextQuery, null); + assert.strictEqual(apiResponse_, apiResponse); + + done(); + }); + }); + }); + + describe('success', function() { + var zone = { name: 'zone-1', a: 'b', c: 'd' }; + var apiResponse = { managedZones: [zone] }; + + beforeEach(function() { + dns.makeReq_ = function(method, path, query, body, callback) { + callback(null, apiResponse); + }; + + dns.zone = function() { + return zone; + }; + }); + + it('should create zones from the response', function(done) { + dns.zone = function(zoneName) { + assert.strictEqual(zoneName, zone.name); + setImmediate(done); + return zone; + }; + + dns.getZones({}, assert.ifError); + }); + + it('should set a nextQuery if necessary', function(done) { + var apiResponseWithNextPageToken = extend({}, apiResponse, { + nextPageToken: 'next-page-token' + }); + + var query = { a: 'b', c: 'd' }; + var originalQuery = extend({}, query); + + dns.makeReq_ = function(method, path, query, body, callback) { + callback(null, apiResponseWithNextPageToken); + }; + + dns.getZones(query, function(err, zones, nextQuery) { + assert.ifError(err); + + // Check the original query wasn't modified. + assert.deepEqual(query, originalQuery); + + assert.deepEqual(nextQuery, extend({}, query, { + pageToken: apiResponseWithNextPageToken.nextPageToken + })); + + done(); + }); + }); + + it('should execute callback with zones and API response', function(done) { + dns.getZones({}, function(err, zones, nextQuery, apiResponse_) { + assert.ifError(err); + + assert.strictEqual(zones[0], zone); + assert.strictEqual(nextQuery, null); + assert.strictEqual(apiResponse_, apiResponse); + + done(); + }); + }); + + it('should assign metadata to zones', function(done) { + dns.getZones({}, function(err, zones) { + assert.ifError(err); + assert.strictEqual(zones[0].metadata, zone); + done(); + }); + }); + }); + }); + + describe('zone', function() { + it('should throw if a name is not provided', function() { + assert.throws(function() { + dns.zone(); + }, /A zone name is required/); + }); + + it('should return a Zone', function() { + var newZoneName = 'new-zone-name'; + var newZone = dns.zone(newZoneName); + + assert(newZone instanceof FakeZone); + assert.strictEqual(newZone.calledWith_[0], dns); + assert.strictEqual(newZone.calledWith_[1], newZoneName); + }); + }); + + describe('makeReq_', function() { + it('should make correct authorized request', function(done) { + var method = 'POST'; + var path = '/'; + var query = 'query'; + var body = 'body'; + + dns.makeAuthorizedRequest_ = function(reqOpts, callback) { + assert.equal(reqOpts.method, method); + assert.equal(reqOpts.qs, query); + + var baseUri = 'https://www.googleapis.com/dns/v1/'; + assert.equal(reqOpts.uri, baseUri + 'projects/' + PROJECT_ID + path); + + assert.equal(reqOpts.json, body); + + callback(); + }; + + dns.makeReq_(method, path, query, body, done); + }); + }); +}); diff --git a/test/dns/record.js b/test/dns/record.js new file mode 100644 index 00000000000..854fa6081cf --- /dev/null +++ b/test/dns/record.js @@ -0,0 +1,330 @@ +/** + * Copyright 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var assert = require('assert'); +var extend = require('extend'); +var Record = require('../../lib/dns/record.js'); +var util = require('../../lib/common/util.js'); + +describe('Record', function() { + var record; + + var ZONE = { + deleteRecords: util.noop + }; + var TYPE = 'A'; + var METADATA = { + name: 'name', + data: [], + ttl: 86400 + }; + + beforeEach(function() { + record = new Record(ZONE, TYPE, METADATA); + }); + + describe('instantiation', function() { + it('should localize the zone instance', function() { + assert.strictEqual(record.zone_, ZONE); + }); + + it('should localize the type', function() { + assert.strictEqual(record.type, TYPE); + }); + + it('should localize the metadata', function() { + assert.strictEqual(record.metadata, METADATA); + }); + + it('should assign the parsed metadata', function() { + var parsedMetadata = record.toJSON(); + delete parsedMetadata.rrdatas; + + for (var prop in parsedMetadata) { + assert.strictEqual(record[prop], parsedMetadata[prop]); + } + }); + + it('should re-assign rrdatas to data', function() { + var originalRrdatas = []; + + var recordThatHadRrdatas = new Record(ZONE, TYPE, { + rrdatas: originalRrdatas + }); + + assert.strictEqual(recordThatHadRrdatas.rrdatas, undefined); + assert.strictEqual(recordThatHadRrdatas.data, originalRrdatas); + }); + }); + + describe('fromZoneRecord_', function() { + describe('a', function() { + var aRecord = { + ip: '0.0.0.0', + name: 'name', + ttl: 86400 + }; + + var expectedData = aRecord.ip; + + it('should parse an A record', function() { + var record = Record.fromZoneRecord_(ZONE, 'a', aRecord); + + assert.strictEqual(record.type, 'A'); + assert.deepEqual(record.metadata.data, expectedData); + assert.strictEqual(record.metadata.name, aRecord.name); + assert.strictEqual(record.metadata.ttl, aRecord.ttl); + }); + }); + + describe('aaaa', function() { + var aaaaRecord = { + ip: '2607:f8b0:400a:801::1005', + name: 'name', + ttl: 86400 + }; + + var expectedData = aaaaRecord.ip; + + it('should parse an AAAA record', function() { + var record = Record.fromZoneRecord_(ZONE, 'aaaa', aaaaRecord); + + assert.strictEqual(record.type, 'AAAA'); + assert.strictEqual(record.metadata.data, expectedData); + assert.strictEqual(record.metadata.name, aaaaRecord.name); + assert.strictEqual(record.metadata.ttl, aaaaRecord.ttl); + }); + }); + + describe('cname', function() { + var cnameRecord = { + alias: 'example.com.', + name: 'name', + ttl: 86400 + }; + + var expectedData = cnameRecord.alias; + + it('should parse a CNAME record', function() { + var record = Record.fromZoneRecord_(ZONE, 'cname', cnameRecord); + + assert.strictEqual(record.type, 'CNAME'); + assert.strictEqual(record.metadata.data, expectedData); + assert.strictEqual(record.metadata.name, cnameRecord.name); + assert.strictEqual(record.metadata.ttl, cnameRecord.ttl); + }); + }); + + describe('mx', function() { + var mxRecord = { + preference: 0, + host: 'mail', + name: 'name', + ttl: 86400 + }; + + var expectedData = mxRecord.preference + ' ' + mxRecord.host; + + it('should parse an MX record', function() { + var record = Record.fromZoneRecord_(ZONE, 'mx', mxRecord); + + assert.strictEqual(record.type, 'MX'); + assert.strictEqual(record.metadata.data, expectedData); + assert.strictEqual(record.metadata.name, mxRecord.name); + assert.strictEqual(record.metadata.ttl, mxRecord.ttl); + }); + }); + + describe('ns', function() { + var nsRecord = { + host: 'example.com', + name: 'name', + ttl: 86400 + }; + + var expectedData = nsRecord.host; + + it('should parse an NS record', function() { + var record = Record.fromZoneRecord_(ZONE, 'ns', nsRecord); + + assert.strictEqual(record.type, 'NS'); + assert.strictEqual(record.metadata.data, expectedData); + assert.strictEqual(record.metadata.name, nsRecord.name); + assert.strictEqual(record.metadata.ttl, nsRecord.ttl); + }); + }); + + describe('soa', function() { + var soaRecord = { + mname: 'ns1.nameserver.net.', + rname: 'hostmaster.mydomain.com.', + serial: 86400, + retry: 600, + refresh: 3600, + expire: 604800, + minimum: 86400, + name: 'name', + ttl: 86400 + }; + + var expectedData = [ + soaRecord.mname, + soaRecord.rname, + soaRecord.serial, + soaRecord.retry, + soaRecord.refresh, + soaRecord.expire, + soaRecord.minimum + ].join(' '); + + it('should parse an SOA record', function() { + var record = Record.fromZoneRecord_(ZONE, 'soa', soaRecord); + + assert.strictEqual(record.type, 'SOA'); + assert.strictEqual(record.metadata.data, expectedData); + assert.strictEqual(record.metadata.name, soaRecord.name); + assert.strictEqual(record.metadata.ttl, soaRecord.ttl); + }); + }); + + describe('spf', function() { + var spfRecord = { + data: '"v=spf1" "mx:example.com"', + name: 'name', + ttl: 86400 + }; + + var expectedData = spfRecord.data; + + it('should parse an SPF record', function() { + var record = Record.fromZoneRecord_(ZONE, 'spf', spfRecord); + + assert.strictEqual(record.type, 'SPF'); + assert.strictEqual(record.metadata.data, expectedData); + assert.strictEqual(record.metadata.name, spfRecord.name); + assert.strictEqual(record.metadata.ttl, spfRecord.ttl); + }); + }); + + describe('srv', function() { + var srvRecord = { + priority: 10, + weight: 0, + port: 5222, + target: 'jabber', + name: 'name', + ttl: 86400 + }; + + var expectedData = [ + srvRecord.priority, + srvRecord.weight, + srvRecord.port, + srvRecord.target + ].join(' '); + + it('should parse an SRV record', function() { + var record = Record.fromZoneRecord_(ZONE, 'srv', srvRecord); + + assert.strictEqual(record.type, 'SRV'); + assert.strictEqual(record.metadata.data, expectedData); + assert.strictEqual(record.metadata.name, srvRecord.name); + assert.strictEqual(record.metadata.ttl, srvRecord.ttl); + }); + }); + + describe('txt', function() { + var txtRecord = { + txt: 'txt-record-txt', + name: 'name', + ttl: 86400 + }; + + var expectedData = txtRecord.txt; + + it('should parse a TXT record', function() { + var record = Record.fromZoneRecord_(ZONE, 'txt', txtRecord); + + assert.strictEqual(record.type, 'TXT'); + assert.strictEqual(record.metadata.data, expectedData); + assert.strictEqual(record.metadata.name, txtRecord.name); + assert.strictEqual(record.metadata.ttl, txtRecord.ttl); + }); + }); + }); + + describe('delete', function() { + it('should call zone.deleteRecords', function(done) { + record.zone_.deleteRecords = function(records, callback) { + assert.strictEqual(records, record); + callback(); + }; + + record.delete(done); + }); + }); + + describe('toJSON', function() { + it('should format the data for the API', function() { + var expectedRecord = extend({}, METADATA, { + type: 'A', + rrdatas: METADATA.data + }); + delete expectedRecord.data; + + assert.deepEqual(record.toJSON(), expectedRecord); + }); + }); + + describe('toString', function() { + it('should format the data for a zonefile', function() { + var jsonRecord = extend({}, METADATA, { + type: TYPE, + rrdatas: ['example.com.', 'example2.com.'] + }); + + record.toJSON = function() { + return jsonRecord; + }; + + var expectedRecordString = [ + [ + jsonRecord.name, + jsonRecord.ttl, + 'IN', + TYPE, + jsonRecord.rrdatas[0] + ].join(' '), + + [ + jsonRecord.name, + jsonRecord.ttl, + 'IN', + TYPE, + jsonRecord.rrdatas[1] + ].join(' ') + ].join('\n'); + + // That's a bunch of silliness, but it generates simply: + // name 86400 IN A example.com. + // name 86400 IN A example2.com. + + assert.strictEqual(record.toString(), expectedRecordString); + }); + }); +}); diff --git a/test/dns/zone.js b/test/dns/zone.js new file mode 100644 index 00000000000..f5a016e5a15 --- /dev/null +++ b/test/dns/zone.js @@ -0,0 +1,977 @@ +/** + * Copyright 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var assert = require('assert'); +var extend = require('extend'); +var mockery = require('mockery'); +var util = require('../../lib/common/util.js'); + +var parseOverride; +var fakeDnsZonefile = { + parse: function() { + return (parseOverride || util.noop).apply(null, arguments); + } +}; + +var writeFileOverride; +var readFileOverride; +var fakeFs = { + readFile: function() { + return (readFileOverride || util.noop).apply(null, arguments); + }, + writeFile: function() { + return (writeFileOverride || util.noop).apply(null, arguments); + } +}; + +var extended = false; +var fakeStreamRouter = { + extend: function(Class, methods) { + if (Class.name !== 'Zone') { + return; + } + + extended = true; + methods = util.arrayize(methods); + assert.equal(Class.name, 'Zone'); + assert.deepEqual(methods, ['getChanges', 'getRecords']); + } +}; + +function FakeChange() { + this.calledWith_ = arguments; +} + +function FakeRecord() { + this.calledWith_ = arguments; +} +FakeRecord.fromZoneRecord_ = function() { + var record = new FakeRecord(); + record.calledWith_ = arguments; + return record; +}; + +describe('Zone', function() { + var Zone; + var zone; + + var DNS = { + makeReq_: function() {} + }; + var ZONE_NAME = 'zone-name'; + + before(function() { + mockery.registerMock('dns-zonefile', fakeDnsZonefile); + mockery.registerMock('fs', fakeFs); + mockery.registerMock('../common/stream-router.js', fakeStreamRouter); + mockery.registerMock('./change.js', FakeChange); + mockery.registerMock('./record.js', FakeRecord); + mockery.enable({ + useCleanCache: true, + warnOnUnregistered: false + }); + + Zone = require('../../lib/dns/zone.js'); + }); + + after(function() { + mockery.deregisterAll(); + mockery.disable(); + }); + + beforeEach(function() { + parseOverride = null; + readFileOverride = null; + writeFileOverride = null; + zone = new Zone(DNS, ZONE_NAME); + }); + + describe('instantiation', function() { + it('should extend the correct methods', function() { + assert(extended); // See `fakeStreamRouter.extend` + }); + + it('should localize the DNS instance', function() { + assert.strictEqual(zone.dns, DNS); + }); + + it('should localize the name', function() { + assert.strictEqual(zone.name, ZONE_NAME); + }); + + it('should create a makeReq_ function', function(done) { + var dns = { + makeReq_: function() { + assert.strictEqual(this, dns); + done(); + } + }; + + var zone = new Zone(dns, ZONE_NAME); + zone.makeReq_(); + }); + }); + + describe('addRecords', function() { + it('should create a change with additions', function(done) { + var records = ['a', 'b', 'c']; + + zone.createChange = function(options, callback) { + assert.strictEqual(options.add, records); + callback(); + }; + + zone.addRecords(records, done); + }); + }); + + describe('change', function() { + it('should throw if an ID is not provided', function() { + assert.throws(function() { + zone.change(); + }, /A change id is required/); + }); + + it('should return a Change object', function() { + var changeId = 'change-id'; + + var change = zone.change(changeId); + + assert(change instanceof FakeChange); + assert.strictEqual(change.calledWith_[0], zone); + assert.strictEqual(change.calledWith_[1], changeId); + }); + }); + + describe('createChange', function() { + it('should throw error if add or delete is not provided', function() { + assert.throws(function() { + zone.createChange({}, util.noop); + }, /Cannot create a change with no additions or deletions/); + }); + + it('should parse and rename add to additions', function(done) { + var recordsToAdd = [ + { toJSON: function() { return 'a'; } }, + { toJSON: function() { return 'a'; } } + ]; + var expectedAdditions = ['a', 'a']; + + zone.makeReq_ = function(method, path, query, body) { + assert.strictEqual(body.add, undefined); + assert.deepEqual(body.additions, expectedAdditions); + done(); + }; + + zone.createChange({ add: recordsToAdd }, assert.ifError); + }); + + it('should parse and rename delete to deletions', function(done) { + var recordsToDelete = [ + { toJSON: function() { return 'a'; } }, + { toJSON: function() { return 'a'; } } + ]; + var expectedDeletions = ['a', 'a']; + + zone.makeReq_ = function(method, path, query, body) { + assert.strictEqual(body.delete, undefined); + assert.deepEqual(body.deletions, expectedDeletions); + done(); + }; + + zone.createChange({ delete: recordsToDelete }, assert.ifError); + }); + + it('should make correct API request', function(done) { + zone.makeReq_ = function(method, path, query) { + assert.strictEqual(method, 'POST'); + assert.strictEqual(path, '/managedZones/' + ZONE_NAME + '/changes'); + assert.strictEqual(query, null); + done(); + }; + + zone.createChange({ add: [] }, assert.ifError); + }); + + describe('error', function() { + var error = new Error('Error.'); + var apiResponse = { a: 'b', c: 'd' }; + + beforeEach(function() { + zone.makeReq_ = function(method, path, query, body, callback) { + callback(error, apiResponse); + }; + }); + + it('should execute callback with error & API response', function(done) { + zone.createChange({ add: [] }, function(err, change, apiResponse_) { + assert.strictEqual(err, error); + assert.strictEqual(apiResponse_, apiResponse); + done(); + }); + }); + }); + + describe('success', function() { + var apiResponse = { id: 1, a: 'b', c: 'd' }; + + beforeEach(function() { + zone.makeReq_ = function(method, path, query, body, callback) { + callback(null, apiResponse); + }; + }); + + it('should execute callback with Change & API response', function(done) { + var change = {}; + + zone.change = function(id) { + assert.strictEqual(id, apiResponse.id); + return change; + }; + + zone.createChange({ add: [] }, function(err, change_, apiResponse_) { + assert.ifError(err); + + assert.strictEqual(change_, change); + assert.strictEqual(change_.metadata, apiResponse); + + assert.strictEqual(apiResponse_, apiResponse); + + done(); + }); + }); + }); + }); + + describe('delete', function() { + describe('force', function() { + it('should empty the zone', function(done) { + zone.empty = function() { + done(); + }; + + zone.delete({ force: true }, assert.ifError); + }); + + it('should try to delete again after emptying', function(done) { + zone.makeReq_ = function() { + done(); + }; + + zone.empty = function(callback) { + callback(); + }; + + zone.delete({ force: true }, assert.ifError); + }); + }); + + it('should make the correct API request', function(done) { + var error = new Error('Error.'); + var apiResponse = { a: 'b', c: 'd' }; + var ignoreThisArgument = { e: 'f', g: 'h' }; + + zone.makeReq_ = function(method, path, query, body, callback) { + assert.strictEqual(method, 'DELETE'); + assert.strictEqual(path, '/managedZones/' + ZONE_NAME); + assert.strictEqual(query, null); + assert.strictEqual(body, null); + callback(error, apiResponse, ignoreThisArgument); + }; + + zone.delete(function(err, apiResponse_) { + assert.strictEqual(arguments.length, 2); + assert.strictEqual(err, error); + assert.strictEqual(apiResponse_, apiResponse); + done(); + }); + }); + }); + + describe('deleteRecords', function() { + it('should delete records by type if a string is given', function(done) { + var recordsToDelete = 'ns'; + + zone.deleteRecordsByType_ = function(types, callback) { + assert.deepEqual(types, [recordsToDelete]); + callback(); + }; + + zone.deleteRecords(recordsToDelete, done); + }); + + it('should create a change if record objects given', function(done) { + var recordsToDelete = { a: 'b', c: 'd' }; + + zone.createChange = function(options, callback) { + assert.deepEqual(options.delete, [recordsToDelete]); + callback(); + }; + + zone.deleteRecords(recordsToDelete, done); + }); + }); + + describe('empty', function() { + it('should get all records', function(done) { + zone.getRecords = function() { + done(); + }; + + zone.empty(assert.ifError); + }); + + describe('error', function() { + var error = new Error('Error.'); + + beforeEach(function() { + zone.getRecords = function(callback) { + callback(error); + }; + }); + + it('should execute callback with error', function(done) { + zone.empty(function(err) { + assert.strictEqual(err, error); + done(); + }); + }); + }); + + describe('success', function() { + var records = [ + { type: 'A' }, + { type: 'AAAA' }, + { type: 'CNAME' }, + { type: 'MX' }, + { type: 'NAPTR' }, + { type: 'NS' }, + { type: 'PTR' }, + { type: 'SOA' }, + { type: 'SPF' }, + { type: 'SRV' }, + { type: 'TXT' } + ]; + + var expectedRecordsToDelete = records.filter(function(record) { + return record.type !== 'NS' && record.type !== 'SOA'; + }); + + beforeEach(function() { + zone.getRecords = function(callback) { + callback(null, records); + }; + }); + + it('should execute callback if no records matched', function(done) { + zone.getRecords = function(callback) { + callback(null, []); + }; + + zone.empty(done); + }); + + it('should delete non-NS and non-SOA records', function(done) { + zone.deleteRecords = function(recordsToDelete, callback) { + assert.deepEqual(recordsToDelete, expectedRecordsToDelete); + callback(); + }; + + zone.empty(done); + }); + }); + }); + + describe('export', function() { + var path = './zonefile'; + + var records = [ + { toString: function() { return 'a'; } }, + { toString: function() { return 'a'; } }, + { toString: function() { return 'a'; } }, + { toString: function() { return 'a'; } }, + ]; + + var expectedZonefileContents = 'a\na\na\na'; + + beforeEach(function() { + zone.getRecords = function(callback) { + callback(null, records); + }; + }); + + describe('get records', function() { + describe('error', function() { + var error = new Error('Error.'); + + it('should execute callback with error', function(done) { + zone.getRecords = function(callback) { + callback(error); + }; + + zone.export(path, function(err) { + assert.strictEqual(err, error); + done(); + }); + }); + }); + + describe('success', function() { + it('should get all records', function(done) { + zone.getRecords = function() { + done(); + }; + + zone.export(path, assert.ifError); + }); + }); + }); + + describe('write file', function() { + it('should write correct zone file', function(done) { + writeFileOverride = function(path_, content, encoding) { + assert.strictEqual(path_, path); + assert.strictEqual(content, expectedZonefileContents); + assert.strictEqual(encoding, 'utf-8'); + + done(); + }; + + zone.export(path, assert.ifError); + }); + + describe('error', function() { + var error = new Error('Error.'); + + beforeEach(function() { + writeFileOverride = function(path, content, encoding, callback) { + callback(error); + }; + }); + + it('should execute the callback with an error', function(done) { + zone.export(path, function(err) { + assert.strictEqual(err, error); + done(); + }); + }); + }); + + describe('success', function() { + beforeEach(function() { + writeFileOverride = function(path, content, encoding, callback) { + callback(); + }; + }); + + it('should execute the callback', function(done) { + zone.export(path, function(err) { + assert.ifError(err); + done(); + }); + }); + }); + }); + }); + + describe('getChanges', function() { + it('should accept only a callback', function(done) { + zone.makeReq_ = function(method, path, query) { + assert.strictEqual(Object.keys(query).length, 0); + done(); + }; + + zone.getChanges(assert.ifError); + }); + + it('should accept a sort', function(done) { + var query = { sort: 'desc' }; + + zone.makeReq_ = function(method, path, query) { + assert.strictEqual(query.sortOrder, 'descending'); + assert.strictEqual(query.sort, undefined); + + done(); + }; + + zone.getChanges(query, assert.ifError); + }); + + it('should make the correct API request', function(done) { + var query = { a: 'b', c: 'd' }; + + zone.makeReq_ = function(method, path, query_, body) { + assert.strictEqual(method, 'GET'); + assert.strictEqual(path, '/managedZones/' + ZONE_NAME + '/changes'); + assert.strictEqual(query_, query); + assert.strictEqual(body, null); + + done(); + }; + + zone.getChanges(query, assert.ifError); + }); + + describe('error', function() { + var error = new Error('Error.'); + var apiResponse = { a: 'b', c: 'd' }; + + beforeEach(function() { + zone.makeReq_ = function(method, path, query, body, callback) { + callback(error, apiResponse); + }; + }); + + it('should execute callback with error & API response', function(done) { + zone.getChanges({}, function(err, changes, nextQuery, apiResponse_) { + assert.strictEqual(err, error); + assert.strictEqual(apiResponse_, apiResponse); + done(); + }); + }); + }); + + describe('success', function() { + var apiResponse = { + changes: [{ id: 1 }] + }; + + beforeEach(function() { + zone.makeReq_ = function(method, path, query, body, callback) { + callback(null, apiResponse); + }; + }); + + it('should build a nextQuery if necessary', function(done) { + var nextPageToken = 'next-page-token'; + var apiResponseWithNextPageToken = extend({}, apiResponse, { + nextPageToken: nextPageToken + }); + var expectedNextQuery = { + pageToken: nextPageToken + }; + + zone.makeReq_ = function(method, path, query, body, callback) { + callback(null, apiResponseWithNextPageToken); + }; + + zone.getChanges({}, function(err, changes, nextQuery) { + assert.ifError(err); + + assert.deepEqual(nextQuery, expectedNextQuery); + + done(); + }); + }); + + it('should execute callback with Changes & API response', function(done) { + var change = {}; + + zone.change = function(id) { + assert.strictEqual(id, apiResponse.changes[0].id); + return change; + }; + + zone.getChanges({}, function(err, changes, nextQuery, apiResponse_) { + assert.ifError(err); + + assert.strictEqual(changes[0], change); + assert.strictEqual(changes[0].metadata, apiResponse.changes[0]); + + assert.strictEqual(apiResponse_, apiResponse); + + done(); + }); + }); + }); + }); + + describe('getMetadata', function() { + it('should make the correct API request', function(done) { + zone.makeReq_ = function(method, path, query, body) { + assert.strictEqual(method, 'GET'); + assert.strictEqual(path, '/managedZones/' + ZONE_NAME); + assert.strictEqual(query, null); + assert.strictEqual(body, null); + + done(); + }; + + zone.getMetadata(assert.ifError); + }); + + describe('error', function() { + var error = new Error('Error.'); + var apiResponse = { a: 'b', c: 'd' }; + + beforeEach(function() { + zone.makeReq_ = function(method, path, query, body, callback) { + callback(error, apiResponse); + }; + }); + + it('should execute callback with error and API response', function(done) { + zone.getMetadata(function(err, metadata, apiResponse_) { + assert.strictEqual(err, error); + assert.strictEqual(apiResponse_, apiResponse); + done(); + }); + }); + }); + + describe('success', function() { + var apiResponse = { a: 'b', c: 'd' }; + + beforeEach(function() { + zone.makeReq_ = function(method, path, query, body, callback) { + callback(null, apiResponse); + }; + }); + + it('should update the metadata to the API response', function(done) { + zone.getMetadata(function(err) { + assert.ifError(err); + assert.strictEqual(zone.metadata, apiResponse); + done(); + }); + }); + + it('should exec callback with metadata and API response', function(done) { + zone.getMetadata(function(err, metadata, apiResponse_) { + assert.ifError(err); + assert.strictEqual(metadata, apiResponse); + assert.strictEqual(apiResponse_, apiResponse); + done(); + }); + }); + }); + }); + + describe('getRecords', function() { + describe('error', function() { + var error = new Error('Error.'); + var apiResponse = { a: 'b', c: 'd' }; + + beforeEach(function() { + zone.makeReq_ = function(method, path, query, body, callback) { + callback(error, apiResponse); + }; + }); + + it('should execute callback with error & API response', function(done) { + zone.getChanges({}, function(err, changes, nextQuery, apiResponse_) { + assert.strictEqual(err, error); + assert.strictEqual(apiResponse_, apiResponse); + done(); + }); + }); + }); + + describe('success', function() { + var apiResponse = { + rrsets: [{ type: 'NS' }] + }; + + beforeEach(function() { + zone.makeReq_ = function(method, path, query, body, callback) { + callback(null, apiResponse); + }; + }); + + it('should execute callback with nextQuery if necessary', function(done) { + var nextPageToken = 'next-page-token'; + var apiResponseWithNextPageToken = extend({}, apiResponse, { + nextPageToken: nextPageToken + }); + var expectedNextQuery = { pageToken: nextPageToken }; + + zone.makeReq_ = function(method, path, query, body, callback) { + callback(null, apiResponseWithNextPageToken); + }; + + zone.getRecords({}, function(err, records, nextQuery) { + assert.ifError(err); + + assert.deepEqual(nextQuery, expectedNextQuery); + + done(); + }); + }); + + it('should execute callback with Records & API response', function(done) { + var record = {}; + + zone.record = function(type, recordObject) { + assert.strictEqual(type, apiResponse.rrsets[0].type); + assert.strictEqual(recordObject, apiResponse.rrsets[0]); + return record; + }; + + zone.getRecords({}, function(err, records, nextQuery, apiResponse_) { + assert.ifError(err); + + assert.strictEqual(records[0], record); + + assert.strictEqual(apiResponse_, apiResponse); + + done(); + }); + }); + + describe('filtering', function() { + it('should accept a string type', function(done) { + var types = ['MX', 'CNAME']; + + zone.getRecords(types, function(err, records) { + assert.ifError(err); + + assert.strictEqual(records.length, 0); + + done(); + }); + }); + + it('should accept an array of types', function(done) { + var type = 'MX'; + + zone.getRecords(type, function(err, records) { + assert.ifError(err); + + assert.strictEqual(records.length, 0); + + done(); + }); + }); + + it('should not send filterByTypes_ in API request', function(done) { + zone.makeReq_ = function(method, path, query) { + assert.strictEqual(query.filterByTypes_, undefined); + done(); + }; + + zone.getRecords('NS', assert.ifError); + }); + }); + }); + }); + + describe('import', function() { + var path = './zonefile'; + + it('should read from the file', function(done) { + readFileOverride = function(path_, encoding) { + assert.strictEqual(path, path); + assert.strictEqual(encoding, 'utf-8'); + done(); + }; + + zone.import(path, assert.ifError); + }); + + describe('error', function() { + var error = new Error('Error.'); + + beforeEach(function() { + readFileOverride = function(path, encoding, callback) { + callback(error); + }; + }); + + it('should execute the callback', function(done) { + zone.import(path, function(err) { + assert.strictEqual(err, error); + done(); + }); + }); + }); + + describe('success', function() { + var recordType = 'ns'; + var parsedZonefile = {}; + parsedZonefile[recordType] = { a: 'b', c: 'd' }; + + beforeEach(function() { + parseOverride = function() { + return parsedZonefile; + }; + + readFileOverride = function(path, encoding, callback) { + callback(); + }; + }); + + it('should add records', function(done) { + zone.addRecords = function(recordsToCreate, callback) { + assert.strictEqual(recordsToCreate.length, 1); + + var recordToCreate = recordsToCreate[0]; + + assert(recordToCreate instanceof FakeRecord); + + var args = recordToCreate.calledWith_; + assert.strictEqual(args[0], zone); + assert.strictEqual(args[1], recordType); + assert.strictEqual(args[2], parsedZonefile[recordType]); + + callback(); + }; + + zone.import(path, done); + }); + }); + }); + + describe('record', function() { + it('should return a Record object', function() { + var type = 'a'; + var metadata = { a: 'b', c: 'd' }; + + var record = zone.record(type, metadata); + + assert(record instanceof FakeRecord); + + var args = record.calledWith_; + assert.strictEqual(args[0], zone); + assert.strictEqual(args[1], type); + assert.strictEqual(args[2], metadata); + }); + }); + + describe('replaceRecords', function() { + it('should get records', function(done) { + var recordType = 'ns'; + + zone.getRecords = function(recordType_) { + assert.strictEqual(recordType_, recordType); + done(); + }; + + zone.replaceRecords(recordType, [], assert.ifError); + }); + + describe('error', function() { + var error = new Error('Error.'); + + beforeEach(function() { + zone.getRecords = function(recordType, callback) { + callback(error); + }; + }); + + it('should execute callback with error', function(done) { + zone.replaceRecords('a', [], function(err) { + assert.strictEqual(err, error); + done(); + }); + }); + }); + + describe('success', function() { + var recordsToCreate = [ + { a: 'b', c: 'd' }, + { a: 'b', c: 'd' }, + { a: 'b', c: 'd' } + ]; + + var recordsToDelete = [ + { a: 'b', c: 'd' }, + { a: 'b', c: 'd' }, + { a: 'b', c: 'd' } + ]; + + beforeEach(function() { + zone.getRecords = function(recordType, callback) { + callback(null, recordsToDelete); + }; + }); + + it('should create a change', function(done) { + zone.createChange = function(options, callback) { + assert.strictEqual(options.add, recordsToCreate); + assert.strictEqual(options.delete, recordsToDelete); + + callback(); + }; + + zone.replaceRecords('a', recordsToCreate, done); + }); + }); + }); + + describe('deleteRecordsByType_', function() { + it('should get records', function(done) { + var recordType = 'ns'; + + zone.getRecords = function(recordType_) { + assert.strictEqual(recordType_, recordType); + done(); + }; + + zone.deleteRecordsByType_(recordType, assert.ifError); + }); + + describe('error', function() { + var error = new Error('Error.'); + + beforeEach(function() { + zone.getRecords = function(recordType, callback) { + callback(error); + }; + }); + + it('should execute callback with error', function(done) { + zone.deleteRecordsByType_('a', function(err) { + assert.strictEqual(err, error); + done(); + }); + }); + }); + + describe('success', function() { + var recordsToDelete = [ + { a: 'b', c: 'd' }, + { a: 'b', c: 'd' }, + { a: 'b', c: 'd' } + ]; + + beforeEach(function() { + zone.getRecords = function(recordType, callback) { + callback(null, recordsToDelete); + }; + }); + + it('should execute callback if no records matched', function(done) { + zone.getRecords = function(recordType, callback) { + callback(null, []); + }; + + zone.deleteRecordsByType_('a', done); + }); + + it('should delete records', function(done) { + zone.deleteRecords = function(records, callback) { + assert.strictEqual(records, recordsToDelete); + + callback(); + }; + + zone.deleteRecordsByType_('a', done); + }); + }); + }); +});