diff --git a/samples/README.md b/samples/README.md index f96472363..13c44bfe3 100644 --- a/samples/README.md +++ b/samples/README.md @@ -18,6 +18,7 @@ * [Files](#files) * [Notifications](#notifications) * [Requester Pays](#requester-pays) + * [Bucket Lock](#bucket-lock) ## Before you begin @@ -270,5 +271,55 @@ For more information, see https://cloud.google.com/storage/docs [requesterPays_5_docs]: https://cloud.google.com/storage/docs [requesterPays_5_code]: requesterPays.js +### Bucket Lock + +View the [source code][bucketLock_6_code]. + +[![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-storage&page=editor&open_in_editor=samples/bucketLock.js,samples/README.md) + +__Usage:__ `node bucketLock.js --help` + +``` +bucketLock.js + +Commands: + bucketLock.js set-retention-policy Defines a retention policy on a given bucket. + bucketLock.js remove-retention-policy Removes a retention policy on a given bucket if the + policy is unlocked. + bucketLock.js get-retention-policy Get a retention policy for a given bucket. + bucketLock.js enable-default-event-based-hold Enable default event-based hold for a given bucket. + bucketLock.js disable-default-event-based-hold Disable default event-based hold for a given bucket. + bucketLock.js set-event-based-hold Set an event-based hold for a given file. + bucketLock.js release-event-based-hold Release an event-based hold for a given file. + + bucketLock.js set-temporary-hold Set a temporary hold for a given file. + bucketLock.js release-temporary-hold Release a temporary hold for a given file. + +Options: + --version Show version number [boolean] + --help Show help [boolean] + +Examples: + node bucketLock.js set-retention-policy my-bucket 5 Defines a retention policy of 5 seconds on a + "my-bucket". + node bucketLock.js remove-retention-policy my-bucket Removes a retention policy from "my-bucket". + node bucketLock.js get-retention-policy my-bucket Get the retention policy for "my-bucket". + node bucketLock.js enable-default-event-based-hold my-bucket Enable a default event-based hold for "my-bucket". + node bucketLock.js disable-default-event-based-hold Disable a default-event based hold for "my-bucket". + my-bucket + node bucketLock.js get-default-event-based-hold my-bucket Get the value of a default-event-based hold for + "my-bucket". + node bucketLock.js set-event-based-hold my-bucket my-file Sets an event-based hold on "my-file". + node bucketLock.js release-event-based-hold my-bucket Releases an event-based hold on "my-file". + my-file + node bucketLock.js set-temporary-hold my-bucket my-file Sets a temporary hold on "my-file". + node bucketLock.js release-temporary-hold my-bucket my-file Releases a temporary hold on "my-file". + +For more information, see https://cloud.google.com/storage/docs +``` + +[bucketLock_6_docs]: https://cloud.google.com/storage/docs +[bucketLock_6_code]: bucketLock.js + [shell_img]: https://gstatic.com/cloudssh/images/open-btn.png [shell_link]: https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-storage&page=editor&open_in_editor=samples/README.md diff --git a/samples/bucketLock.js b/samples/bucketLock.js new file mode 100644 index 000000000..31110e1c5 --- /dev/null +++ b/samples/bucketLock.js @@ -0,0 +1,468 @@ +/** + * Copyright 2018, Google, Inc. + * 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. + */ + +/** + * This application demonstrates how to use Bucket Lock operations on buckets + * and objects using the Google Cloud Storage API. + * + * For more information read the documentation + * at https://cloud.google.com/storage/docs/bucket-lock + */ + +'use strict'; +function setRetentionPolicy(bucketName, retentionPeriod) { + // [START storage_set_retention_policy] + // Imports the Google Cloud client library + const {Storage} = require('@google-cloud/storage'); + + // Creates a client + const storage = new Storage(); + + storage + .bucket(bucketName) + .setRetentionPeriod(retentionPeriod) + .then(response => { + const metadata = response[0]; + console.log( + `Bucket ${bucketName} retention period set for ${ + metadata.retentionPolicy.retentionPeriod + } seconds.` + ); + }) + .catch(err => { + console.error('ERROR:', err); + }); + // [END storage_set_retention_policy] +} + +function getRetentionPolicy(bucketName) { + // [START storage_get_retention_policy] + // Imports the Google Cloud client library + const {Storage} = require('@google-cloud/storage'); + + // Creates a client + const storage = new Storage(); + storage + .bucket(bucketName) + .getMetadata() + .then(results => { + const metadata = results[0]; + if (metadata.hasOwnProperty('retentionPolicy')) { + const retentionPolicy = metadata.retentionPolicy; + console.log('A retention policy exists!'); + console.log(`Period: ${retentionPolicy.retentionPeriod}`); + console.log(`Effective time: ${retentionPolicy.effectiveTime}`); + if (retentionPolicy.hasOwnProperty('isLocked')) { + console.log('Policy is locked'); + } else { + console.log('Policy is unlocked'); + } + } + }) + .catch(err => { + console.error('ERROR:', err); + }); + // [END storage_get_retention_policy] +} + +function removeRetentionPolicy(bucketName) { + // [START storage_remove_retention_policy] + // Imports the Google Cloud client library + const {Storage} = require('@google-cloud/storage'); + + // Creates a client + const storage = new Storage(); + storage + .bucket(bucketName) + .getMetadata() + .then(results => { + const metadata = results[0]; + if ( + metadata.hasOwnProperty('retentionPolicy') && + metadata.retentionPolicy.hasOwnProperty('isLocked') + ) { + console.log( + 'Unable to remove retention period as retention policy is locked.' + ); + return null; + } else { + return storage + .bucket(bucketName) + .removeRetentionPeriod() + .then(() => { + console.log(`Removed bucket ${bucketName} retention policy.`); + }); + } + }) + .catch(err => { + console.error('ERROR:', err); + }); + // [END storage_remove_retention_policy] +} + +function lockRetentionPolicy(bucketName) { + // [START storage_lock_retention_policy] + // Imports the Google Cloud client library + const {Storage} = require('@google-cloud/storage'); + + // Creates a client + const storage = new Storage(); + // get_bucket gets the current metageneration value for the bucket, + // required by lock_retention_policy. + storage + .bucket(bucketName) + .getMetadata() + .then(results => { + const unlockedMetadata = results[0]; + // Warning: Once a retention policy is locked it cannot be unlocked + // and retention period can only be increased. + return storage + .bucket(bucketName) + .lock(unlockedMetadata.metageneration) + .then(results => { + const lockedMetadata = results[0]; + console.log(`Retention policy for ${bucketName} is now locked.`); + console.log( + `Retention policy effective as of ${ + lockedMetadata.retentionPolicy.effectiveTime + }` + ); + }); + }) + .catch(err => { + console.error('ERROR:', err); + }); + // [END storage_lock_retention_policy] +} + +function enableDefaultEventBasedHold(bucketName) { + // [START storage_enable_default_event_based_hold] + // Imports the Google Cloud client library + const {Storage} = require('@google-cloud/storage'); + + // Creates a client + const storage = new Storage(); + + /** + * TODO(developer): Uncomment the following lines before running the sample. + */ + // const bucketName = 'Name of a bucket, e.g. my-bucket'; + + // Enables a default event-based hold for the bucket. + storage + .bucket(bucketName) + .setMetadata({ + defaultEventBasedHold: true, + }) + .then(() => { + console.log(`Default event-based hold was enabled for ${bucketName}.`); + }) + .catch(err => { + console.error('ERROR:', err); + }); + // [END storage_enable_default_event_based_hold] +} + +function disableDefaultEventBasedHold(bucketName) { + // [START storage_disable_default_event_based_hold] + // Imports the Google Cloud client library + const {Storage} = require('@google-cloud/storage'); + + // Creates a client + const storage = new Storage(); + + /** + * TODO(developer): Uncomment the following lines before running the sample. + */ + // const bucketName = 'Name of a bucket, e.g. my-bucket'; + + // Disables a default event-based hold for a bucket. + storage + .bucket(bucketName) + .setMetadata({ + defaultEventBasedHold: false, + }) + .then(() => { + console.log(`Default event-based hold was disabled for ${bucketName}.`); + }) + .catch(err => { + console.error('ERROR:', err); + }); + // [END storage_disable_default_event_based_hold] +} + +function getDefaultEventBasedHold(bucketName) { + // [START storage_get_default_event_based_hold] + // Imports the Google Cloud client library + const {Storage} = require('@google-cloud/storage'); + + // Creates a client + const storage = new Storage(); + + /** + * TODO(developer): Uncomment the following lines before running the sample. + */ + // const bucketName = 'Name of a bucket, e.g. my-bucket'; + + // get bucketName metadata + storage + .bucket(bucketName) + .getMetadata() + .then(results => { + const metadata = results[0]; + console.log( + `Default event-based hold: ${metadata.defaultEventBasedHold}.` + ); + }) + .catch(err => { + console.error('ERROR:', err); + }); + // [END storage_get_default_event_based_hold] +} + +function setEventBasedHold(bucketName, fileName) { + // [START storage_set_event_based_hold] + // Imports the Google Cloud client library + const {Storage} = require('@google-cloud/storage'); + + // Creates a client + const storage = new Storage(); + + /** + * TODO(developer): Uncomment the following lines before running the sample. + */ + // const bucketName = 'Name of a bucket, e.g. my-bucket'; + // const filename = 'File to access, e.g. file.txt'; + + // Set event-based hold + storage + .bucket(bucketName) + .file(fileName) + .setMetadata({ + eventBasedHold: true, + }) + .then(() => { + console.log(`Event-based hold was set for ${fileName}.`); + }) + .catch(err => { + console.error('ERROR:', err); + }); + // [END storage_set_event_based_hold] +} + +function releaseEventBasedHold(bucketName, fileName) { + // [START storage_release_event_based_hold] + // Imports the Google Cloud client library + const {Storage} = require('@google-cloud/storage'); + + // Creates a client + const storage = new Storage(); + + /** + * TODO(developer): Uncomment the following lines before running the sample. + */ + // const bucketName = 'Name of a bucket, e.g. my-bucket'; + // const filename = 'File to access, e.g. file.txt'; + + storage + .bucket(bucketName) + .file(fileName) + .setMetadata({ + eventBasedHold: false, + }) + .then(() => { + console.log(`Event-based hold was released for ${fileName}.`); + }) + .catch(err => { + console.error('ERROR:', err); + }); + // [END storage_release_event_based_hold] +} + +function setTemporarydHold(bucketName, fileName) { + // [START storage_set_temporary_hold] + // Imports the Google Cloud client library + const {Storage} = require('@google-cloud/storage'); + + // Creates a client + const storage = new Storage(); + + /** + * TODO(developer): Uncomment the following lines before running the sample. + */ + // const bucketName = 'Name of a bucket, e.g. my-bucket'; + // const filename = 'File to access, e.g. file.txt'; + + storage + .bucket(bucketName) + .file(fileName) + .setMetadata({ + temporaryHold: true, + }) + .then(() => { + console.log(`Temporary hold was set for ${fileName}.`); + }) + .catch(err => { + console.error('ERROR:', err); + }); + // [END storage_set_temporary_hold] +} + +function releaseTemporaryHold(bucketName, fileName) { + // [START storage_release_temporary_hold] + // Imports the Google Cloud client library + const {Storage} = require('@google-cloud/storage'); + + // Creates a client + const storage = new Storage(); + + /** + * TODO(developer): Uncomment the following lines before running the sample. + */ + // const bucketName = 'Name of a bucket, e.g. my-bucket'; + // const filename = 'File to access, e.g. file.txt'; + + storage + .bucket(bucketName) + .file(fileName) + .setMetadata({ + temporaryHold: false, + }) + .then(() => { + console.log(`Temporary hold was released for ${fileName}.`); + }) + .catch(err => { + console.error('ERROR:', err); + }); + // [END storage_release_temporary_hold] +} + +require(`yargs`) + .demand(1) + .command( + `set-retention-policy `, + `Defines a retention policy on a given bucket.`, + {}, + opts => setRetentionPolicy(opts.bucketName, opts.period) + ) + .command( + `remove-retention-policy `, + `Removes a retention policy on a given bucket if the policy is unlocked.`, + {}, + opts => removeRetentionPolicy(opts.bucketName) + ) + .command( + `get-retention-policy `, + `Get a retention policy for a given bucket.`, + {}, + opts => getRetentionPolicy(opts.bucketName) + ) + .command( + `lock-retention-policy `, + `Lock a retention policy for a given bucket.`, + {}, + opts => lockRetentionPolicy(opts.bucketName) + ) + .command( + `enable-default-event-based-hold `, + `Enable default event-based hold for a given bucket.`, + {}, + opts => enableDefaultEventBasedHold(opts.bucketName) + ) + .command( + `disable-default-event-based-hold `, + `Disable default event-based hold for a given bucket.`, + {}, + opts => disableDefaultEventBasedHold(opts.bucketName) + ) + .command( + `get-default-event-based-hold `, + `Get default event-based hold for a given bucket.`, + {}, + opts => getDefaultEventBasedHold(opts.bucketName) + ) + .command( + `set-event-based-hold `, + `Set an event-based hold for a given file.`, + {}, + opts => setEventBasedHold(opts.bucketName, opts.fileName) + ) + .command( + `release-event-based-hold `, + `Release an event-based hold for a given file.`, + {}, + opts => releaseEventBasedHold(opts.bucketName, opts.fileName) + ) + .command( + `set-temporary-hold `, + `Set a temporary hold for a given file.`, + {}, + opts => setTemporarydHold(opts.bucketName, opts.fileName) + ) + .command( + `release-temporary-hold `, + `Release a temporary hold for a given file.`, + {}, + opts => releaseTemporaryHold(opts.bucketName, opts.fileName) + ) + .example( + `node $0 set-retention-policy my-bucket 5`, + `Defines a retention policy of 5 seconds on a "my-bucket".` + ) + .example( + `node $0 remove-retention-policy my-bucket`, + `Removes a retention policy from "my-bucket".` + ) + .example( + `node $0 get-retention-policy my-bucket`, + `Get the retention policy for "my-bucket".` + ) + .example( + `node $0 lock-retention-policy my-bucket`, + `Lock the retention policy for "my-bucket".` + ) + .example( + `node $0 enable-default-event-based-hold my-bucket`, + `Enable a default event-based hold for "my-bucket".` + ) + .example( + `node $0 disable-default-event-based-hold my-bucket`, + `Disable a default-event based hold for "my-bucket".` + ) + .example( + `node $0 get-default-event-based-hold my-bucket`, + `Get the value of a default-event-based hold for "my-bucket".` + ) + .example( + `node $0 set-event-based-hold my-bucket my-file`, + `Sets an event-based hold on "my-file".` + ) + .example( + `node $0 release-event-based-hold my-bucket my-file`, + `Releases an event-based hold on "my-file".` + ) + .example( + `node $0 set-temporary-hold my-bucket my-file`, + `Sets a temporary hold on "my-file".` + ) + .example( + `node $0 release-temporary-hold my-bucket my-file`, + `Releases a temporary hold on "my-file".` + ) + .wrap(120) + .recommendCommands() + .epilogue(`For more information, see https://cloud.google.com/storage/docs`) + .help() + .strict().argv; diff --git a/samples/files.js b/samples/files.js index 1b0ee7da7..ffd3d81af 100644 --- a/samples/files.js +++ b/samples/files.js @@ -247,8 +247,12 @@ async function getMetadata(bucketName, filename) { console.log(`Content-disposition: ${metadata.contentDisposition}`); console.log(`Content-encoding: ${metadata.contentEncoding}`); console.log(`Content-language: ${metadata.contentLanguage}`); - console.log(`Metadata: ${metadata.metadata}`); console.log(`Media link: ${metadata.mediaLink}`); + console.log(`KMS Key Name: ${metadata.kmsKeyName}`); + console.log(`Temporary Hold: ${metadata.temporaryHold}`); + console.log(`Event-based hold: ${metadata.eventBasedHold}`); + console.log(`Effective Expiration Time: ${metadata.effectiveExpirationTime}`); + console.log(`Metadata: ${metadata.metadata}`); // [END storage_get_metadata] } diff --git a/samples/system-test/bucketLock.test.js b/samples/system-test/bucketLock.test.js new file mode 100644 index 000000000..5afe73da0 --- /dev/null +++ b/samples/system-test/bucketLock.test.js @@ -0,0 +1,180 @@ +/** + * Copyright 2017, Google, Inc. + * 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'; + +const path = require(`path`); +const {Storage} = require(`@google-cloud/storage`); +const test = require(`ava`); +const tools = require(`@google-cloud/nodejs-repo-tools`); +const uuid = require(`uuid`); + +const storage = new Storage(); +const cwd = path.join(__dirname, `..`); +const cmd = `node bucketLock.js`; +const bucketName = `nodejs-storage-samples-${uuid.v4()}`; +const bucket = storage.bucket(bucketName); +const fileName = `test.txt`; + +const uploadFilePath = path.join(cwd, `resources`, fileName); + +test.before(tools.checkCredentials); +test.before(async () => { + await bucket.create(); +}); +test.before(async () => { + await bucket.upload(uploadFilePath); +}); + +test.after.always(async () => { + try { + await bucket.deleteFiles({force: true}); + } catch (err) {} // ignore error + try { + await bucket.delete(); + } catch (err) {} // ignore error +}); + +test.beforeEach(tools.stubConsole); +test.afterEach.always(tools.restoreConsole); + +test.serial(`should set a retention policy on a bucket`, async t => { + const retentionPeriod = 5; + const results = await tools.runAsyncWithIO( + `${cmd} set-retention-policy ${bucketName} ${retentionPeriod}`, + cwd + ); + t.regex( + results.stdout + results.stderr, + new RegExp( + `Bucket ${bucketName} retention period set for ${retentionPeriod} seconds.` + ) + ); +}); + +test.serial(`should get a retention policy on a bucket`, async t => { + const results = await tools.runAsyncWithIO( + `${cmd} get-retention-policy ${bucketName}`, + cwd + ); + t.regex( + results.stdout + results.stderr, + new RegExp(`A retention policy exists!`) + ); +}); + +test.serial(`should enable default event-based hold on a bucket`, async t => { + const results = await tools.runAsyncWithIO( + `${cmd} enable-default-event-based-hold ${bucketName}`, + cwd + ); + t.regex( + results.stdout + results.stderr, + new RegExp(`Default event-based hold was enabled for ${bucketName}.`) + ); +}); + +test.serial(`should get default event-based hold on a bucket`, async t => { + const results = await tools.runAsyncWithIO( + `${cmd} get-default-event-based-hold ${bucketName}`, + cwd + ); + t.regex( + results.stdout + results.stderr, + new RegExp(`Default event-based hold: true.`) + ); +}); + +test.serial(`should disable default event-based hold on a bucket`, async t => { + const results = await tools.runAsyncWithIO( + `${cmd} disable-default-event-based-hold ${bucketName}`, + cwd + ); + t.regex( + results.stdout + results.stderr, + new RegExp(`Default event-based hold was disabled for ${bucketName}.`) + ); +}); + +test.serial(`should set an event-based hold on a file`, async t => { + const results = await tools.runAsyncWithIO( + `${cmd} set-event-based-hold ${bucketName} ${fileName}`, + cwd + ); + t.regex( + results.stdout + results.stderr, + new RegExp(`Event-based hold was set for ${fileName}.`) + ); +}); + +test.serial(`should release an event-based hold on a file`, async t => { + const results = await tools.runAsyncWithIO( + `${cmd} release-event-based-hold ${bucketName} ${fileName}`, + cwd + ); + t.regex( + results.stdout + results.stderr, + new RegExp(`Event-based hold was released for ${fileName}.`) + ); +}); + +test.serial(`should remove a retention policy on a bucket`, async t => { + const results = await tools.runAsyncWithIO( + `${cmd} remove-retention-policy ${bucketName}`, + cwd + ); + t.regex( + results.stdout + results.stderr, + new RegExp(`Removed bucket ${bucketName} retention policy.`) + ); +}); + +test.serial(`should set an temporary hold on a file`, async t => { + const results = await tools.runAsyncWithIO( + `${cmd} set-temporary-hold ${bucketName} ${fileName}`, + cwd + ); + t.regex( + results.stdout + results.stderr, + new RegExp(`Temporary hold was set for ${fileName}.`) + ); +}); + +test.serial(`should release an temporary hold on a file`, async t => { + const results = await tools.runAsyncWithIO( + `${cmd} release-temporary-hold ${bucketName} ${fileName}`, + cwd + ); + t.regex( + results.stdout + results.stderr, + new RegExp(`Temporary hold was released for ${fileName}.`) + ); +}); + +test.serial(`should lock a bucket with a retention policy`, async t => { + const retentionPeriod = 5; + await tools.runAsyncWithIO( + `${cmd} set-retention-policy ${bucketName} ${retentionPeriod}`, + cwd + ); + const results = await tools.runAsyncWithIO( + `${cmd} lock-retention-policy ${bucketName}`, + cwd + ); + t.regex( + results.stdout + results.stderr, + new RegExp(`Retention policy for ${bucketName} is now locked.`) + ); +}); diff --git a/src/bucket.ts b/src/bucket.ts index 2a2a30b89..fa4c24420 100644 --- a/src/bucket.ts +++ b/src/bucket.ts @@ -1876,6 +1876,48 @@ class Bucket extends ServiceObject { }); } + /** + * Lock a previously-defined retention policy. This will prevent changes to + * the policy. + * + * @throws {Error} if a metageneration is not provided. + * + * @param {Number|String} metageneration The bucket's metageneration. This is + * accesssible from calling {@link File#getMetadata}. + * @param {SetBucketMetadataCallback} [callback] Callback function. + * @returns {Promise} + * + * @example + * const storage = require('@google-cloud/storage')(); + * const bucket = storage.bucket('albums'); + * + * const metageneration = 2; + * + * bucket.lock(metageneration, function(err, apiResponse) {}); + * + * //- + * // If the callback is omitted, we'll return a Promise. + * //- + * bucket.lock(metageneration).then(function(data) { + * const apiResponse = data[0]; + * }); + */ + lock(metageneration, callback?) { + if (!is.number(metageneration) && !is.string(metageneration)) { + throw new Error('A metageneration must be provided.'); + } + + this.request( + { + method: 'POST', + uri: '/lockRetentionPolicy', + qs: { + ifMetagenerationMatch: metageneration, + }, + }, + callback); + } + /** * @typedef {array} MakeBucketPrivateResponse * @property {File[]} 0 List of files made private. @@ -2148,6 +2190,34 @@ class Bucket extends ServiceObject { return new Notification(this, id); } + /** + * Remove an already-existing retention policy from this bucket, if it is not + * locked. + * + * @param {SetBucketMetadataCallback} [callback] Callback function. + * @returns {Promise} + * + * @example + * const storage = require('@google-cloud/storage')(); + * const bucket = storage.bucket('albums'); + * + * bucket.removeRetentionPeriod(function(err, apiResponse) {}); + * + * //- + * // If the callback is omitted, we'll return a Promise. + * //- + * bucket.removeRetentionPeriod().then(function(data) { + * const apiResponse = data[0]; + * }); + */ + removeRetentionPeriod(callback?) { + this.setMetadata( + { + retentionPolicy: null, + }, + callback); + } + /** * Makes request and applies userProject query parameter if necessary. * @@ -2281,6 +2351,13 @@ class Bucket extends ServiceObject { * }, function(err, apiResponse) {}); * * //- + * // Set the default event-based hold value for new objects in this bucket. + * //- + * bucket.setMetadata({ + * defaultEventBasedHold: true + * }, function(err, apiResponse) {}); + * + * //- * // If the callback is omitted, we'll return a Promise. * //- * bucket.setMetadata(metadata).then(function(data) { @@ -2314,6 +2391,51 @@ class Bucket extends ServiceObject { }); } + /** + * Lock all objects contained in the bucket, based on their creation time. Any + * attempt to overwrite or delete objects younger than the retention period + * will result in a `PERMISSION_DENIED` error. + * + * An unlocked retention policy can be modified or removed from the bucket via + * {@link File#removeRetentionPeriod} and {@link File#setRetentionPeriod}. A + * locked retention policy cannot be removed or shortened in duration for the + * lifetime of the bucket. Attempting to remove or decrease period of a locked + * retention policy will result in a `PERMISSION_DENIED` error. You can still + * increase the policy. + * + * @param {*} duration In seconds, the minimum retention time for all objects + * contained in this bucket. + * @param {SetBucketMetadataCallback} [callback] Callback function. + * @returns {Promise} + * + * @example + * const storage = require('@google-cloud/storage')(); + * const bucket = storage.bucket('albums'); + * + * const DURATION_SECONDS = 15780000; // 6 months. + * + * //- + * // Lock the objects in this bucket for 6 months. + * //- + * bucket.setRetentionPeriod(DURATION_SECONDS, function(err, apiResponse) {}); + * + * //- + * // If the callback is omitted, we'll return a Promise. + * //- + * bucket.setRetentionPeriod(DURATION_SECONDS).then(function(data) { + * const apiResponse = data[0]; + * }); + */ + setRetentionPeriod(duration, callback?) { + this.setMetadata( + { + retentionPolicy: { + retentionPeriod: duration, + }, + }, + callback); + } + /** * @callback SetStorageClassCallback * @param {?Error} err Request error, if any. diff --git a/src/file.ts b/src/file.ts index 7d6769886..d7797d6c8 100644 --- a/src/file.ts +++ b/src/file.ts @@ -313,7 +313,8 @@ export type DownloadResponse = [Buffer]; * @param err Request error, if any. * @param contents The contents of a File. */ -export type DownloadCallback = (err: Error|undefined, contents: Buffer) => void; +export type DownloadCallback = (err: RequestError|null, contents: Buffer) => + void; export interface DownloadOptions extends CreateReadStreamOptions { destination?: string; @@ -1730,6 +1731,62 @@ class File extends ServiceObject { (this.parent as ServiceObject).get.call(this, options, callback); } + /** + * @typedef {array} GetExpirationDateResponse + * @property {date} 0 A Date object representing the earliest time this file's + * retention policy will expire. + */ + /** + * @callback GetExpirationDateCallback + * @param {?Error} err Request error, if any. + * @param {date} expirationDate A Date object representing the earliest time + * this file's retention policy will expire. + */ + /** + * If this bucket has a retention policy defined, use this method to get a + * Date object representing the earliest time this file will expire. + * + * @param {GetExpirationDateCallback} [callback] Callback function. + * @returns {Promise} + * + * @example + * const storage = require('@google-cloud/storage')(); + * const myBucket = storage.bucket('my-bucket'); + * + * const file = myBucket.file('my-file'); + * + * file.getExpirationDate(function(err, expirationDate) { + * // expirationDate is a Date object. + * }); + */ + getExpirationDate(callback?) { + this.getMetadata((err, metadata, apiResponse) => { + if (err) { + callback(err, null, apiResponse); + return; + } + + if (!metadata.retentionExpirationTime) { + const error = new Error('An expiration time is not available.'); + callback(error, null, apiResponse); + return; + } + + callback(null, new Date(metadata.retentionExpirationTime), apiResponse); + }); + } + + /** + * @typedef {array} GetFileMetadataResponse + * @property {object} 0 The {@link File} metadata. + * @property {object} 1 The full API response. + */ + /** + * @callback GetFileMetadataCallback + * @param {?Error} err Request error, if any. + * @param {object} metadata The {@link File} metadata. + * @param {object} apiResponse The full API response. + */ /** * Get the file's metadata. * @@ -2536,7 +2593,7 @@ class File extends ServiceObject { * //- * file.save(contents).then(function() {}); */ - save(data, options, callback?) { + save(data, options?, callback?) { if (is.fn(options)) { callback = options; options = {}; @@ -2606,13 +2663,31 @@ class File extends ServiceObject { * }); * * //- + * // Set a temporary hold on this file from its bucket's retention period + * // configuration. + * // + * file.setMetadata({ + * temporaryHold: true + * }, function(err, apiResponse) {}); + * + * //- + * // Alternatively, you may set a temporary hold. This will follow the same + * // behavior as an event-based hold, with the exception that the bucket's + * // retention policy will not renew for this file from the time the hold is + * // released. + * //- + * file.setMetadata({ + * eventBasedHold: true + * }, function(err, apiResponse) {}); + * + * //- * // If the callback is omitted, we'll return a Promise. * //- * file.setMetadata(metadata).then(function(data) { * const apiResponse = data[0]; * }); */ - setMetadata(metadata, options, callback?) { + setMetadata(metadata, options?, callback?) { if (is.fn(options)) { callback = options; options = {}; diff --git a/src/index.ts b/src/index.ts index a0b9ddcfb..ad2ac137e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,6 +51,7 @@ export interface CreateBucketRequest { nearline?: boolean; regional?: boolean; requesterPays?: boolean; + retentionPolicy?: object; userProject?: string; } @@ -386,6 +387,17 @@ class Storage extends Service { * storage.createBucket('new-bucket', metadata, callback); * * //- + * // Create a bucket with a retention policy of 6 months. + * //- + * const metadata = { + * retentionPolicy: { + * retentionPeriod: 15780000 // 6 months in seconds. + * } + * }; + * + * storage.createBucket('new-bucket', metadata, callback); + * + * //- * // Enable versioning on a new bucket. * //- * const metadata = { diff --git a/system-test/storage.ts b/system-test/storage.ts index 17358602c..80f453292 100644 --- a/system-test/storage.ts +++ b/system-test/storage.ts @@ -29,9 +29,10 @@ import * as through from 'through2'; import * as tmp from 'tmp'; import * as uuid from 'uuid'; import {util, ApiError, InstanceResponseCallback, BodyResponseCallback} from '@google-cloud/common'; -import {Storage, Bucket} from '../src'; +import {Storage, Bucket, File} from '../src'; import {DeleteBucketCallback} from '../src/bucket'; import * as nock from 'nock'; +import {DeleteFileCallback} from '../src/file'; // block all attempts to chat with the metadata server (kokoro runs on GCE) nock('http://metadata.google.internal') @@ -117,6 +118,7 @@ describe('storage', () => { GOOGLE_APPLICATION_CREDENTIALS = process.env.GOOGLE_APPLICATION_CREDENTIALS; delete process.env.GOOGLE_APPLICATION_CREDENTIALS; + delete require.cache[require.resolve('../src')]; const {Storage} = require('../src'); storageWithoutAuth = new Storage(); @@ -895,6 +897,249 @@ describe('storage', () => { }); }); + describe('bucket retention policies', () => { + const RETENTION_DURATION_SECONDS = 10; + + describe('bucket', () => { + it('should create a bucket with a retention policy', done => { + const bucket = storage.bucket(generateName()); + + async.series( + [ + next => { + storage.createBucket( + bucket.name, { + retentionPolicy: { + retentionPeriod: RETENTION_DURATION_SECONDS, + }, + }, + err => { + if (err) { + next(err); + return; + } + + next(); + }); + }, + next => bucket.getMetadata(err => next(err)), + ], + err => { + assert.ifError(err); + assert.strictEqual( + bucket.metadata.retentionPolicy.retentionPeriod, + `${RETENTION_DURATION_SECONDS}`); + done(); + }); + }); + + it('should set a retention policy', done => { + const bucket = storage.bucket(generateName()); + + async.series( + [ + next => bucket.create(next), + next => + bucket.setRetentionPeriod(RETENTION_DURATION_SECONDS, next), + next => bucket.getMetadata(err => next(err)), + ], + err => { + assert.ifError(err); + assert.strictEqual( + bucket.metadata.retentionPolicy.retentionPeriod, + `${RETENTION_DURATION_SECONDS}`); + done(); + }); + }); + + it('should lock the retention period', done => { + const bucket = storage.bucket(generateName()); + + async.series( + [ + next => bucket.create(next), + next => + bucket.setRetentionPeriod(RETENTION_DURATION_SECONDS, next), + next => bucket.getMetadata(err => next(err)), + next => { + bucket.lock(bucket.metadata.metageneration, next); + }, + next => bucket.setRetentionPeriod( + RETENTION_DURATION_SECONDS / 2, next), + ], + err => { + if (!err) { + done(new Error('Expected an error.')); + return; + } + + // tslint:disable-next-line:no-any + assert.strictEqual((err as any).code, 403); + done(); + }); + }); + + it('should remove a retention period', done => { + const bucket = storage.bucket(generateName()); + + async.series( + [ + next => bucket.create(next), + next => + bucket.setRetentionPeriod(RETENTION_DURATION_SECONDS, next), + next => bucket.removeRetentionPeriod(next), + next => bucket.getMetadata(err => next(err)), + ], + err => { + assert.ifError(err); + assert.strictEqual(bucket.metadata.retentionPolicy, undefined); + done(); + }); + }); + }); + + describe('file', () => { + const BUCKET = storage.bucket(generateName()); + const FILE = BUCKET.file(generateName()); + + before(done => { + BUCKET.create( + { + retentionPolicy: { + retentionPeriod: 1, + }, + }, + err => { + if (err) { + done(err); + return; + } + + FILE.save('data', done); + }); + }); + + afterEach(() => { + return FILE.setMetadata({temporaryHold: null, eventBasedHold: null}); + }); + + after(() => { + return FILE.delete(); + }); + + it('should set and release an event-based hold', done => { + async.series( + [ + next => FILE.setMetadata({eventBasedHold: true}, next), + next => { + assert.strictEqual(FILE.metadata.eventBasedHold, true); + next(); + }, + next => FILE.setMetadata({eventBasedHold: false}, next), + next => { + assert.strictEqual(FILE.metadata.eventBasedHold, false); + next(); + } + ], + done); + }); + + it('should set and release a temporary hold', done => { + async.series( + [ + next => FILE.setMetadata({temporaryHold: true}, next), + next => { + assert.strictEqual(FILE.metadata.temporaryHold, true); + next(); + }, + next => FILE.setMetadata({temporaryHold: false}, next), + next => { + assert.strictEqual(FILE.metadata.temporaryHold, false); + next(); + } + ], + done); + }); + + it('should get an expiration date', done => { + FILE.getExpirationDate((err, expirationDate) => { + assert.ifError(err); + assert(expirationDate instanceof Date); + done(); + }); + }); + }); + + describe('operations on held objects', () => { + const BUCKET = storage.bucket(generateName()); + const FILES: File[] = []; + + const RETENTION_PERIOD_SECONDS = 5; // Each test has this much time! + + function createFile(callback) { + const file = BUCKET.file(generateName()); + FILES.push(file); + + file.save('data', err => { + if (err) { + callback(err); + return; + } + + callback(null, file); + }); + } + + function deleteFiles(callback) { + async.each(FILES, (file, next) => { + file.setMetadata({temporaryHold: null}, err => { + if (err) { + next(err); + return; + } + file.delete(next as DeleteFileCallback); + }); + }, callback); + } + + before(done => { + BUCKET.create( + { + retentionPolicy: { + retentionPeriod: RETENTION_PERIOD_SECONDS, + }, + }, + done); + }); + + after(done => { + setTimeout(deleteFiles, RETENTION_PERIOD_SECONDS * 1000, done); + }); + + it('should block an overwrite request', done => { + createFile((err, file) => { + assert.ifError(err); + + file.save('new data', err => { + assert.strictEqual(err.code, 403); + done(); + }); + }); + }); + + it('should block a delete request', done => { + createFile((err, file) => { + assert.ifError(err); + + file.delete(err => { + assert.strictEqual(err.code, 403); + done(); + }); + }); + }); + }); + }); + describe('requester pays', () => { const HAS_2ND_PROJECT = is.defined(process.env.GCN_STORAGE_2ND_PROJECT_ID); let bucket; @@ -1320,7 +1565,7 @@ describe('storage', () => { writeStream.on('error', done); writeStream.on('finish', () => { - let data = Buffer.from(''); + let data = Buffer.from('', 'utf8'); file.createReadStream() .on('error', done) @@ -1602,6 +1847,11 @@ describe('storage', () => { it('should not download from the unencrypted file', done => { unencryptedFile.download(err => { + if (!err) { + done(new Error('Expected an error.')); + return; + } + assert(err!.message.indexOf([ 'The target object is encrypted by a', 'customer-supplied encryption key.', diff --git a/test/bucket.ts b/test/bucket.ts index 71de024af..140a974a5 100644 --- a/test/bucket.ts +++ b/test/bucket.ts @@ -1567,6 +1567,34 @@ describe('Bucket', () => { }); }); + describe('lock', () => { + it('should throw if a metageneration is not provided', () => { + const expectedError = new RegExp('A metageneration must be provided.'); + + assert.throws(() => { + bucket.lock(assert.ifError); + }, expectedError); + }); + + it('should make the correct request', done => { + const metageneration = 8; + + bucket.request = (reqOpts, callback) => { + assert.deepStrictEqual(reqOpts, { + method: 'POST', + uri: '/lockRetentionPolicy', + qs: { + ifMetagenerationMatch: metageneration, + }, + }); + + callback(); // done() + }; + + bucket.lock(metageneration, done); + }); + }); + describe('makePrivate', () => { it('should set predefinedAcl & privatize files', done => { let didSetPredefinedAcl = false; @@ -1728,6 +1756,20 @@ describe('Bucket', () => { }); }); + describe('removeRetentionPeriod', () => { + it('should call setMetadata correctly', done => { + bucket.setMetadata = (metadata, callback) => { + assert.deepStrictEqual(metadata, { + retentionPolicy: null, + }); + + callback(); // done() + }; + + bucket.removeRetentionPeriod(done); + }); + }); + describe('request', () => { const USER_PROJECT = 'grape-spaceship-123'; @@ -1901,6 +1943,24 @@ describe('Bucket', () => { }); }); + describe('setRetentionPeriod', () => { + it('should call setMetadata correctly', done => { + const duration = 90000; + + bucket.setMetadata = (metadata, callback) => { + assert.deepStrictEqual(metadata, { + retentionPolicy: { + retentionPeriod: duration, + }, + }); + + callback(); // done() + }; + + bucket.setRetentionPeriod(duration, done); + }); + }); + describe('setStorageClass', () => { const STORAGE_CLASS = 'NEW_STORAGE_CLASS'; const OPTIONS = {}; diff --git a/test/file.ts b/test/file.ts index e4167bbbc..ebb101c3e 100644 --- a/test/file.ts +++ b/test/file.ts @@ -1958,6 +1958,66 @@ describe('File', () => { }); }); + describe('getExpirationDate', () => { + it('should refresh metadata', done => { + file.getMetadata = () => { + done(); + }; + + file.getExpirationDate(assert.ifError); + }); + + it('should return error from getMetadata', done => { + const error = new Error('Error.'); + const apiResponse = {}; + + file.getMetadata = callback => { + callback(error, null, apiResponse); + }; + + file.getExpirationDate((err, expirationDate, apiResponse_) => { + assert.strictEqual(err, error); + assert.strictEqual(expirationDate, null); + assert.strictEqual(apiResponse_, apiResponse); + done(); + }); + }); + + it('should return an error if there is no expiration time', done => { + const apiResponse = {}; + + file.getMetadata = callback => { + callback(null, {}, apiResponse); + }; + + file.getExpirationDate((err, expirationDate, apiResponse_) => { + assert.strictEqual(err.message, `An expiration time is not available.`); + assert.strictEqual(expirationDate, null); + assert.strictEqual(apiResponse_, apiResponse); + done(); + }); + }); + + it('should return the expiration time as a Date object', done => { + const expirationTime = new Date(); + + const apiResponse = { + retentionExpirationTime: expirationTime.toJSON(), + }; + + file.getMetadata = callback => { + callback(null, apiResponse, apiResponse); + }; + + file.getExpirationDate((err, expirationDate, apiResponse_) => { + assert.ifError(err); + assert.deepStrictEqual(expirationDate, expirationTime); + assert.strictEqual(apiResponse_, apiResponse); + done(); + }); + }); + }); + describe('getMetadata', () => { it('should make the correct request', done => { extend(file.parent, {