diff --git a/samples/getSoftDeletedBucket.js b/samples/getSoftDeletedBucket.js new file mode 100644 index 000000000..2cd3e8b39 --- /dev/null +++ b/samples/getSoftDeletedBucket.js @@ -0,0 +1,52 @@ +/** + * Copyright 2024 Google LLC + * + * 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. + */ + +function main(bucketName = 'my-bucket', generation = 123456789) { + // [START storage_get_soft_deleted_bucket] + /** + * TODO(developer): Uncomment the following lines before running the sample. + */ + // The ID of your GCS bucket + // const bucketName = 'your-unique-bucket-name'; + + // The generation of the bucket to restore + // const generation = 123456789; + + // Imports the Google Cloud client library + const {Storage} = require('@google-cloud/storage'); + + // Creates a client + const storage = new Storage(); + + async function getSoftDeletedBucket() { + const options = { + generation: generation, + softDeleted: true, + }; + + const [metadata] = await storage.bucket(bucketName).getMetadata(options); + + console.log(`Bucket: ${metadata.name}`); + console.log(`Generation: ${metadata.generation}`); + console.log(`SoftDeleteTime: ${metadata.softDeleteTime}`); + console.log(`HardDeleteTime: ${metadata.hardDeleteTime}`); + } + + getSoftDeletedBucket().catch(console.error); + // [END storage_get_soft_deleted_bucket] +} + +main(...process.argv.slice(2)); diff --git a/samples/listSoftDeletedBucket.js b/samples/listSoftDeletedBucket.js new file mode 100644 index 000000000..679bd6caf --- /dev/null +++ b/samples/listSoftDeletedBucket.js @@ -0,0 +1,42 @@ +/** + * Copyright 2024 Google LLC + * + * 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. + */ + +function main() { + // [START storage_list_soft_deleted_buckets] + // Imports the Google Cloud client library + const {Storage} = require('@google-cloud/storage'); + + // Creates a client + const storage = new Storage(); + + async function listSoftDeletedBuckets() { + const options = { + softDeleted: true, + }; + + const [buckets] = await storage.getBuckets(options); + + console.log('Buckets:'); + buckets.forEach(bucket => { + console.log(bucket.name); + }); + } + + listSoftDeletedBuckets().catch(console.error); + // [END storage_list_soft_deleted_buckets] +} + +main(...process.argv.slice(2)); diff --git a/samples/restoreSoftDeletedBucket.js b/samples/restoreSoftDeletedBucket.js new file mode 100644 index 000000000..c6a2bbff5 --- /dev/null +++ b/samples/restoreSoftDeletedBucket.js @@ -0,0 +1,48 @@ +/** + * Copyright 2024 Google LLC + * + * 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. + */ + +function main(bucketName = 'my-bucket', generation = 123456789) { + // [START storage_restore_soft_deleted_bucket] + /** + * TODO(developer): Uncomment the following lines before running the sample. + */ + // The ID of your GCS bucket + // const bucketName = 'your-unique-bucket-name'; + + // The generation of the bucket to restore + // const generation = 123456789; + + // Imports the Google Cloud client library + const {Storage} = require('@google-cloud/storage'); + + // Creates a client + const storage = new Storage(); + + async function restoreSoftDeletedBucket() { + const options = { + generation: generation, + }; + + await storage.bucket(bucketName).restore(options); + + console.log(`Soft deleted bucket ${bucketName} was restored.`); + } + + restoreSoftDeletedBucket().catch(console.error); + // [END storage_restore_soft_deleted_bucket] +} + +main(...process.argv.slice(2)); diff --git a/src/bucket.ts b/src/bucket.ts index 73df3374d..9139ce89e 100644 --- a/src/bucket.ts +++ b/src/bucket.ts @@ -293,6 +293,10 @@ export interface GetLabelsCallback { (err: Error | null, labels: object | null): void; } +export interface RestoreOptions { + generation: string; + projection?: 'full' | 'noAcl'; +} export interface BucketMetadata extends BaseMetadata { acl?: AclMetadata[] | null; autoclass?: { @@ -335,6 +339,7 @@ export interface BucketMetadata extends BaseMetadata { logBucket?: string; logObjectPrefix?: string; }; + generation?: string; metageneration?: string; name?: string; objectRetention?: { @@ -351,6 +356,8 @@ export interface BucketMetadata extends BaseMetadata { retentionPeriod?: string | number; } | null; rpo?: string; + softDeleteTime?: string; + hardDeleteTime?: string; softDeletePolicy?: { retentionDurationSeconds?: string | number; readonly effectiveTime?: string; @@ -3250,6 +3257,27 @@ class Bucket extends ServiceObject { ); } + /** + * @typedef {object} RestoreOptions Options for Bucket#restore(). See an + * {@link https://cloud.google.com/storage/docs/json_api/v1/buckets/restore#resource| Object resource}. + * @param {number} [generation] If present, selects a specific revision of this object. + * @param {string} [projection] Specifies the set of properties to return. If used, must be 'full' or 'noAcl'. + */ + /** + * Restores a soft-deleted bucket + * @param {RestoreOptions} options Restore options. + * @returns {Promise} + */ + async restore(options: RestoreOptions): Promise { + const [bucket] = await this.request({ + method: 'POST', + uri: '/restore', + qs: options, + }); + + return bucket as Bucket; + } + makePrivate( options?: MakeBucketPrivateOptions ): Promise; @@ -4550,7 +4578,7 @@ paginator.extend(Bucket, 'getFiles'); * that a callback is omitted. */ promisifyAll(Bucket, { - exclude: ['cloudStorageURI', 'request', 'file', 'notification'], + exclude: ['cloudStorageURI', 'request', 'file', 'notification', 'restore'], }); /** diff --git a/src/storage.ts b/src/storage.ts index 6bd9f37a8..88651f222 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -105,6 +105,8 @@ export interface BucketOptions { kmsKeyName?: string; preconditionOpts?: PreconditionOptions; userProject?: string; + generation?: number; + softDeleted?: boolean; } export interface Cors { @@ -185,6 +187,8 @@ export interface GetBucketsRequest { maxResults?: number; pageToken?: string; userProject?: string; + softDeleted?: boolean; + generation?: number; } export interface HmacKeyResourceResponse { @@ -1242,6 +1246,8 @@ export class Storage extends Service { * representing part of the larger set of results to view. * @property {string} [userProject] The ID of the project which will be billed * for the request. + * @param {boolean} [softDeleted] If true, returns the soft-deleted object. + * Object `generation` is required if `softDeleted` is set to True. */ /** * @typedef {array} GetBucketsResponse diff --git a/system-test/storage.ts b/system-test/storage.ts index 6c33cf7ac..820ec5a2a 100644 --- a/system-test/storage.ts +++ b/system-test/storage.ts @@ -791,149 +791,211 @@ describe('storage', function () { }); describe('soft-delete', () => { - let bucket: Bucket; - let hnsBucket: Bucket; - const SOFT_DELETE_RETENTION_SECONDS = 7 * 24 * 60 * 60; //7 days in seconds; + describe('buckets', () => { + let bucket: Bucket; + const SOFT_DELETE_RETENTION_SECONDS = 7 * 24 * 60 * 60; //7 days in seconds; + let generation: string; - beforeEach(async () => { - bucket = storage.bucket(generateName()); - await bucket.create(); - await bucket.setMetadata({ - softDeletePolicy: { - retentionDurationSeconds: SOFT_DELETE_RETENTION_SECONDS, - }, + before(async () => { + bucket = storage.bucket(generateName()); + await bucket.create(); + await bucket.setMetadata({ + softDeletePolicy: { + retentionDurationSeconds: SOFT_DELETE_RETENTION_SECONDS, + }, + }); + + const [metadata] = await bucket.getMetadata(); + generation = metadata!.generation!; + await bucket.delete(); }); - hnsBucket = storage.bucket(generateName()); - await storage.createBucket(hnsBucket.name, { - hierarchicalNamespace: {enabled: true}, - iamConfiguration: { - uniformBucketLevelAccess: { - enabled: true, - }, - }, - softDeletePolicy: { - retentionDurationSeconds: SOFT_DELETE_RETENTION_SECONDS, - }, + after(async () => { + await bucket.delete(); }); - }); - afterEach(async () => { - await bucket.deleteFiles({force: true, versions: true}); - await bucket.delete(); - await hnsBucket.deleteFiles({force: true, versions: true}); - await hnsBucket.delete(); - }); + it('should be listing soft-deleted buckets', async () => { + const [buckets] = await storage.getBuckets({softDeleted: true}); + assert(buckets.length > 0); - it('should set softDeletePolicy correctly', async () => { - const metadata = await bucket.getMetadata(); - assert(metadata[0].softDeletePolicy); - assert(metadata[0].softDeletePolicy.effectiveTime); - assert.deepStrictEqual( - metadata[0].softDeletePolicy.retentionDurationSeconds, - SOFT_DELETE_RETENTION_SECONDS.toString() - ); - }); + buckets.forEach(bucket => { + assert(bucket.name); + assert(bucket.metadata.generation); + assert(bucket.metadata.softDeleteTime); + assert(bucket.metadata.hardDeleteTime); + }); + }); - it('should LIST soft-deleted files', async () => { - const f1 = bucket.file('file1'); - const f2 = bucket.file('file2'); - await f1.save('file1'); - await f2.save('file2'); - await f1.delete(); - await f2.delete(); - const [notSoftDeletedFiles] = await bucket.getFiles(); - assert.strictEqual(notSoftDeletedFiles.length, 0); - const [softDeletedFiles] = await bucket.getFiles({softDeleted: true}); - assert.strictEqual(softDeletedFiles.length, 2); - }); - - it('should GET a soft-deleted file', async () => { - const f1 = bucket.file('file3'); - await f1.save('file3'); - const [metadata] = await f1.getMetadata(); - await f1.delete(); - const [softDeletedFile] = await f1.get({ - softDeleted: true, - generation: parseInt(metadata.generation?.toString() || '0'), - }); - assert(softDeletedFile); - assert.strictEqual( - softDeletedFile.metadata.generation, - metadata.generation - ); - }); + it('should GET a soft-deleted bucket', async () => { + const [softDeletedBucket] = await storage + .bucket(bucket.name) + .getMetadata({ + softDeleted: true, + generation: generation, + }); + assert(softDeletedBucket); + assert.strictEqual(softDeletedBucket.generation, generation); + assert(softDeletedBucket.softDeleteTime); + assert(softDeletedBucket.hardDeleteTime); + }); - it('should restore a soft-deleted file', async () => { - const f1 = bucket.file('file4'); - await f1.save('file4'); - const [metadata] = await f1.getMetadata(); - await f1.delete(); - let [files] = await bucket.getFiles(); - assert.strictEqual(files.length, 0); - const restoredFile = await f1.restore({ - generation: parseInt(metadata.generation?.toString() || '0'), - }); - assert(restoredFile); - [files] = await bucket.getFiles(); - assert.strictEqual(files.length, 1); - }); - - it('should LIST soft-deleted files with restore token', async () => { - const f1 = hnsBucket.file('file5a'); - const f2 = hnsBucket.file('file5b'); - await f1.save('file5a'); - await f2.save('file5b'); - await f1.delete(); - await f2.delete(); - const [notSoftDeletedFiles] = await hnsBucket.getFiles(); - assert.strictEqual(notSoftDeletedFiles.length, 0); - const [softDeletedFiles] = await hnsBucket.getFiles({softDeleted: true}); - assert.strictEqual(softDeletedFiles.length, 2); - assert.notStrictEqual( - softDeletedFiles![0].metadata.restoreToken, - undefined - ); + it('should restore a soft-deleted bucket', async () => { + const restoredBucket = await storage.bucket(bucket.name).restore({ + generation: generation, + }); + assert(restoredBucket); + const [metadata] = await bucket.getMetadata(); + assert(metadata); + }); }); - it('should GET a soft-deleted file with restore token', async () => { - const f1 = hnsBucket.file('file6'); - await f1.save('file6'); - const [metadata] = await f1.getMetadata(); - await f1.delete(); - const [softDeletedFile] = await f1.get({ - softDeleted: true, - generation: parseInt(metadata.generation?.toString() || '0'), + describe('files', () => { + let bucket: Bucket; + let hnsBucket: Bucket; + const SOFT_DELETE_RETENTION_SECONDS = 7 * 24 * 60 * 60; //7 days in seconds; + + beforeEach(async () => { + bucket = storage.bucket(generateName()); + await bucket.create(); + await bucket.setMetadata({ + softDeletePolicy: { + retentionDurationSeconds: SOFT_DELETE_RETENTION_SECONDS, + }, + }); + + hnsBucket = storage.bucket(generateName()); + await storage.createBucket(hnsBucket.name, { + hierarchicalNamespace: {enabled: true}, + iamConfiguration: { + uniformBucketLevelAccess: { + enabled: true, + }, + }, + softDeletePolicy: { + retentionDurationSeconds: SOFT_DELETE_RETENTION_SECONDS, + }, + }); + }); + + afterEach(async () => { + await bucket.deleteFiles({force: true, versions: true}); + await bucket.delete(); + await hnsBucket.deleteFiles({force: true, versions: true}); + await hnsBucket.delete(); + }); + + it('should set softDeletePolicy correctly', async () => { + const metadata = await bucket.getMetadata(); + assert(metadata[0].softDeletePolicy); + assert(metadata[0].softDeletePolicy.effectiveTime); + assert.deepStrictEqual( + metadata[0].softDeletePolicy.retentionDurationSeconds, + SOFT_DELETE_RETENTION_SECONDS.toString() + ); + }); + + it('should LIST soft-deleted files', async () => { + const f1 = bucket.file('file1'); + const f2 = bucket.file('file2'); + await f1.save('file1'); + await f2.save('file2'); + await f1.delete(); + await f2.delete(); + const [notSoftDeletedFiles] = await bucket.getFiles(); + assert.strictEqual(notSoftDeletedFiles.length, 0); + const [softDeletedFiles] = await bucket.getFiles({softDeleted: true}); + assert.strictEqual(softDeletedFiles.length, 2); + }); + + it('should GET a soft-deleted file', async () => { + const f1 = bucket.file('file3'); + await f1.save('file3'); + const [metadata] = await f1.getMetadata(); + await f1.delete(); + const [softDeletedFile] = await f1.get({ + softDeleted: true, + generation: parseInt(metadata.generation?.toString() || '0'), + }); + assert(softDeletedFile); + assert.strictEqual( + softDeletedFile.metadata.generation, + metadata.generation + ); + }); + + it('should restore a soft-deleted file', async () => { + const f1 = bucket.file('file4'); + await f1.save('file4'); + const [metadata] = await f1.getMetadata(); + await f1.delete(); + let [files] = await bucket.getFiles(); + assert.strictEqual(files.length, 0); + const restoredFile = await f1.restore({ + generation: parseInt(metadata.generation?.toString() || '0'), + }); + assert(restoredFile); + [files] = await bucket.getFiles(); + assert.strictEqual(files.length, 1); + }); + + it('should LIST soft-deleted files with restore token', async () => { + const f1 = hnsBucket.file('file5a'); + const f2 = hnsBucket.file('file5b'); + await f1.save('file5a'); + await f2.save('file5b'); + await f1.delete(); + await f2.delete(); + const [notSoftDeletedFiles] = await hnsBucket.getFiles(); + assert.strictEqual(notSoftDeletedFiles.length, 0); + const [softDeletedFiles] = await hnsBucket.getFiles({ + softDeleted: true, + }); + assert.strictEqual(softDeletedFiles.length, 2); + assert.notStrictEqual( + softDeletedFiles![0].metadata.restoreToken, + undefined + ); + }); + + it('should GET a soft-deleted file with restore token', async () => { + const f1 = hnsBucket.file('file6'); + await f1.save('file6'); + const [metadata] = await f1.getMetadata(); + await f1.delete(); + const [softDeletedFile] = await f1.get({ + softDeleted: true, + generation: parseInt(metadata.generation?.toString() || '0'), + }); + assert(softDeletedFile); + assert.strictEqual( + softDeletedFile.metadata.generation, + metadata.generation + ); + assert.notStrictEqual(softDeletedFile.metadata.restoreToken, undefined); + }); + + it('should restore a soft-deleted file using restoreToken', async () => { + const f1 = hnsBucket.file('file7'); + await f1.save('file7'); + const [metadata] = await f1.getMetadata(); + await f1.delete(); + let [files] = await hnsBucket.getFiles(); + assert.strictEqual(files.length, 0); + const [softDeletedFile] = await f1.get({ + softDeleted: true, + generation: parseInt(metadata.generation?.toString() || '0'), + }); + assert(softDeletedFile); + const restoredFile = await f1.restore({ + generation: parseInt( + softDeletedFile.metadata.generation?.toString() || '0' + ), + restoreToken: softDeletedFile.metadata.restoreToken, + }); + assert(restoredFile); + [files] = await hnsBucket.getFiles(); + assert.strictEqual(files.length, 1); }); - assert(softDeletedFile); - assert.strictEqual( - softDeletedFile.metadata.generation, - metadata.generation - ); - assert.notStrictEqual(softDeletedFile.metadata.restoreToken, undefined); - }); - - it('should restore a soft-deleted file using restoreToken', async () => { - const f1 = hnsBucket.file('file7'); - await f1.save('file7'); - const [metadata] = await f1.getMetadata(); - await f1.delete(); - let [files] = await hnsBucket.getFiles(); - assert.strictEqual(files.length, 0); - const [softDeletedFile] = await f1.get({ - softDeleted: true, - generation: parseInt(metadata.generation?.toString() || '0'), - }); - assert(softDeletedFile); - const restoredFile = await f1.restore({ - generation: parseInt( - softDeletedFile.metadata.generation?.toString() || '0' - ), - restoreToken: softDeletedFile.metadata.restoreToken, - }); - assert(restoredFile); - [files] = await hnsBucket.getFiles(); - assert.strictEqual(files.length, 1); }); }); diff --git a/test/bucket.ts b/test/bucket.ts index 3935f9b72..475ef35ed 100644 --- a/test/bucket.ts +++ b/test/bucket.ts @@ -124,6 +124,7 @@ const fakePromisify = { 'request', 'file', 'notification', + 'restore', ]); }, }; @@ -2425,6 +2426,26 @@ describe('Bucket', () => { }); }); + describe('restore', () => { + it('should pass options to underlying request call', async () => { + bucket.request = function ( + reqOpts: DecorateRequestOptions, + callback_: Function + ) { + assert.strictEqual(this, bucket); + assert.deepStrictEqual(reqOpts, { + method: 'POST', + uri: '/restore', + qs: {generation: 123456789}, + }); + assert.strictEqual(callback_, undefined); + return []; + }; + + await bucket.restore({generation: 123456789}); + }); + }); + describe('request', () => { const USER_PROJECT = 'grape-spaceship-123';