diff --git a/README.md b/README.md index 2c4a6e520..4b0cdb25f 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ Samples are in the [`samples/`](https://github.com/googleapis/nodejs-spanner/tre | Sample | Source Code | Try it | | --------------------------- | --------------------------------- | ------ | | Backups-cancel | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/backups-cancel.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/backups-cancel.js,samples/README.md) | +| Copies a source backup | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/backups-copy.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/backups-copy.js,samples/README.md) | | Backups-create-with-encryption-key | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/backups-create-with-encryption-key.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/backups-create-with-encryption-key.js,samples/README.md) | | Backups-create | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/backups-create.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/backups-create.js,samples/README.md) | | Backups-delete | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/backups-delete.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/backups-delete.js,samples/README.md) | diff --git a/samples/README.md b/samples/README.md index 5383994e3..8e7095045 100644 --- a/samples/README.md +++ b/samples/README.md @@ -15,6 +15,7 @@ and automatic, synchronous replication for high availability. * [Before you begin](#before-you-begin) * [Samples](#samples) * [Backups-cancel](#backups-cancel) + * [Copies a source backup](#copies-a-source-backup) * [Backups-create-with-encryption-key](#backups-create-with-encryption-key) * [Backups-create](#backups-create) * [Backups-delete](#backups-delete) @@ -95,6 +96,23 @@ __Usage:__ +### Copies a source backup + +View the [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/backups-copy.js). + +[![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/backups-copy.js,samples/README.md) + +__Usage:__ + + +`node spannerCopyBackup ` + + +----- + + + + ### Backups-create-with-encryption-key View the [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/backups-create-with-encryption-key.js). diff --git a/samples/backups-copy.js b/samples/backups-copy.js new file mode 100644 index 000000000..9636be972 --- /dev/null +++ b/samples/backups-copy.js @@ -0,0 +1,97 @@ +// Copyright 2022 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. + +// sample-metadata: +// title: Copies a source backup +// usage: node spannerCopyBackup + +'use strict'; + +function main( + instanceId = 'my-instance', + backupId = 'my-backup', + sourceBackupPath = 'projects/my-project-id/instances/my-source-instance/backups/my-source-backup', + projectId = 'my-project-id' +) { + // [START spanner_copy_backup] + /** + * TODO(developer): Uncomment these variables before running the sample. + */ + // const instanceId = 'my-instance'; + // const backupId = 'my-backup', + // const sourceBackupPath = 'projects/my-project-id/instances/my-source-instance/backups/my-source-backup', + // const projectId = 'my-project-id'; + + // Imports the Google Cloud Spanner client library + const {Spanner} = require('@google-cloud/spanner'); + const {PreciseDate} = require('@google-cloud/precise-date'); + + // Instantiates a client + const spanner = new Spanner({ + projectId: projectId, + }); + + async function spannerCopyBackup() { + // Gets a reference to a Cloud Spanner instance and backup + const instance = spanner.instance(instanceId); + + // Expire copy backup 14 days in the future + const expireTime = Spanner.timestamp( + Date.now() + 1000 * 60 * 60 * 24 * 14 + ).toStruct(); + + // Copy the source backup + try { + console.log(`Creating copy of the source backup ${sourceBackupPath}.`); + const [, operation] = await instance.copyBackup( + sourceBackupPath, + backupId, + { + expireTime: expireTime, + } + ); + + console.log( + `Waiting for backup copy ${ + instance.backup(backupId).formattedName_ + } to complete...` + ); + await operation.promise(); + + // Verify the copy backup is ready + const copyBackup = instance.backup(backupId); + const [copyBackupInfo] = await copyBackup.getMetadata(); + if (copyBackupInfo.state === 'READY') { + console.log( + `Backup copy ${copyBackupInfo.name} of size ` + + `${copyBackupInfo.sizeBytes} bytes was created at ` + + `${new PreciseDate(copyBackupInfo.createTime).toISOString()} ` + + 'with version time ' + + `${new PreciseDate(copyBackupInfo.versionTime).toISOString()}` + ); + } else { + console.error('ERROR: Copy of backup is not ready.'); + } + } catch (err) { + console.error('ERROR:', err); + } + } + spannerCopyBackup(); + // [END spanner_copy_backup] +} +process.on('unhandledRejection', err => { + console.error(err.message); + process.exitCode = 1; +}); +main(...process.argv.slice(2)); diff --git a/samples/backups-get-operations.js b/samples/backups-get-operations.js index cd8a4dedf..9369b3e74 100644 --- a/samples/backups-get-operations.js +++ b/samples/backups-get-operations.js @@ -15,7 +15,12 @@ 'use strict'; -async function getBackupOperations(instanceId, databaseId, projectId) { +async function getBackupOperations( + instanceId, + databaseId, + backupId, + projectId +) { // [START spanner_list_backup_operations] // Imports the Google Cloud client library const {Spanner, protos} = require('@google-cloud/spanner'); @@ -25,6 +30,7 @@ async function getBackupOperations(instanceId, databaseId, projectId) { */ // const projectId = 'my-project-id'; // const databaseId = 'my-database'; + // const backupId = 'my-backup'; // const instanceId = 'my-instance'; // Creates a client @@ -35,7 +41,7 @@ async function getBackupOperations(instanceId, databaseId, projectId) { // Gets a reference to a Cloud Spanner instance const instance = spanner.instance(instanceId); - // List backup operations + // List create backup operations try { const [backupOperations] = await instance.getBackupOperations({ filter: @@ -56,6 +62,32 @@ async function getBackupOperations(instanceId, databaseId, projectId) { } catch (err) { console.error('ERROR:', err); } + + // List copy backup operations + try { + console.log( + '(metadata.@type:type.googleapis.com/google.spanner.admin.database.v1.CopyBackupMetadata) ' + + `AND (metadata.source_backup:${backupId})` + ); + const [backupOperations] = await instance.getBackupOperations({ + filter: + '(metadata.@type:type.googleapis.com/google.spanner.admin.database.v1.CopyBackupMetadata) ' + + `AND (metadata.source_backup:${backupId})`, + }); + console.log('Copy Backup Operations:'); + backupOperations.forEach(backupOperation => { + const metadata = + protos.google.spanner.admin.database.v1.CopyBackupMetadata.decode( + backupOperation.metadata.value + ); + console.log( + `Backup ${metadata.name} copied from source backup ${metadata.sourceBackup} is ` + + `${metadata.progress.progressPercent}% complete.` + ); + }); + } catch (err) { + console.error('ERROR:', err); + } // [END spanner_list_backup_operations] } diff --git a/samples/backups-update.js b/samples/backups-update.js index 55c044693..639513821 100644 --- a/samples/backups-update.js +++ b/samples/backups-update.js @@ -40,8 +40,13 @@ async function updateBackup(instanceId, backupId, projectId) { // Read backup metadata and update expiry time try { const currentExpireTime = await backup.getExpireTime(); - const newExpireTime = new PreciseDate(currentExpireTime); - newExpireTime.setDate(newExpireTime.getDate() + 30); + const maxExpireTime = backup.metadata.maxExpireTime; + const wantExpireTime = new PreciseDate(currentExpireTime); + wantExpireTime.setDate(wantExpireTime.getDate() + 1); + // New expire time should be less than the max expire time + const min = (currentExpireTime, maxExpireTime) => + currentExpireTime < maxExpireTime ? currentExpireTime : maxExpireTime; + const newExpireTime = new PreciseDate(min(wantExpireTime, maxExpireTime)); console.log( `Backup ${backupId} current expire time: ${currentExpireTime.toISOString()}` ); diff --git a/samples/backups.js b/samples/backups.js index 1ef05454e..3cedeef18 100644 --- a/samples/backups.js +++ b/samples/backups.js @@ -83,11 +83,16 @@ require('yargs') ) ) .command( - 'getBackupOperations ', + 'getBackupOperations ', 'Lists all backup operations in the instance.', {}, opts => - getBackupOperations(opts.instanceName, opts.databaseName, opts.projectId) + getBackupOperations( + opts.instanceName, + opts.databaseName, + opts.backupName, + opts.projectId + ) ) .command( 'getDatabaseOperations ', diff --git a/samples/system-test/spanner.test.js b/samples/system-test/spanner.test.js index bf7215dcf..adeee8f38 100644 --- a/samples/system-test/spanner.test.js +++ b/samples/system-test/spanner.test.js @@ -52,6 +52,7 @@ const VERSION_RETENTION_DATABASE_ID = `test-database-${CURRENT_TIME}-v`; const ENCRYPTED_DATABASE_ID = `test-database-${CURRENT_TIME}-enc`; const DEFAULT_LEADER_DATABASE_ID = `test-database-${CURRENT_TIME}-dl`; const BACKUP_ID = `test-backup-${CURRENT_TIME}`; +const COPY_BACKUP_ID = `test-copy-backup-${CURRENT_TIME}`; const ENCRYPTED_BACKUP_ID = `test-backup-${CURRENT_TIME}-enc`; const CANCELLED_BACKUP_ID = `test-backup-${CURRENT_TIME}-c`; const LOCATION_ID = 'regional-us-west1'; @@ -214,6 +215,7 @@ describe('Spanner', () => { await Promise.all([ instance.backup(BACKUP_ID).delete(GAX_OPTIONS), instance.backup(ENCRYPTED_BACKUP_ID).delete(GAX_OPTIONS), + instance.backup(COPY_BACKUP_ID).delete(GAX_OPTIONS), instance.backup(CANCELLED_BACKUP_ID).delete(GAX_OPTIONS), ]); await instance.delete(GAX_OPTIONS); @@ -223,6 +225,7 @@ describe('Spanner', () => { instance.database(RESTORE_DATABASE_ID).delete(), instance.database(ENCRYPTED_RESTORE_DATABASE_ID).delete(), instance.backup(BACKUP_ID).delete(GAX_OPTIONS), + instance.backup(COPY_BACKUP_ID).delete(GAX_OPTIONS), instance.backup(ENCRYPTED_BACKUP_ID).delete(GAX_OPTIONS), instance.backup(CANCELLED_BACKUP_ID).delete(GAX_OPTIONS), ]); @@ -1021,6 +1024,18 @@ describe('Spanner', () => { assert.include(output, `using encryption key ${key.name}`); }); + // copy_backup + it('should create a copy of a backup', async () => { + const sourceBackupPath = `projects/${PROJECT_ID}/instances/${INSTANCE_ID}/backups/${BACKUP_ID}`; + const output = execSync( + `node backups-copy.js ${INSTANCE_ID} ${COPY_BACKUP_ID} ${sourceBackupPath} ${PROJECT_ID}` + ); + assert.match( + output, + new RegExp(`(.*)Backup copy(.*)${COPY_BACKUP_ID} of size(.*)`) + ); + }); + // cancel_backup it('should cancel a backup of the database', async () => { const output = execSync( @@ -1048,13 +1063,18 @@ describe('Spanner', () => { // list_backup_operations it('should list backup operations in the instance', async () => { const output = execSync( - `${backupsCmd} getBackupOperations ${INSTANCE_ID} ${DATABASE_ID} ${PROJECT_ID}` + `${backupsCmd} getBackupOperations ${INSTANCE_ID} ${DATABASE_ID} ${BACKUP_ID} ${PROJECT_ID}` ); assert.match(output, /Create Backup Operations:/); assert.match( output, new RegExp(`Backup (.+)${BACKUP_ID} (.+) is 100% complete`) ); + assert.match(output, /Copy Backup Operations:/); + assert.match( + output, + new RegExp(`Backup (.+)${COPY_BACKUP_ID} (.+) is 100% complete`) + ); }); // update_backup_expire_time diff --git a/src/backup.ts b/src/backup.ts index 1f374c274..34cb71dfb 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -35,6 +35,7 @@ import {google as databaseAdmin} from '../protos/protos'; import {common as p} from 'protobufjs'; export type CreateBackupCallback = LongRunningCallback; +export type CopyBackupCallback = LongRunningCallback; export interface CreateBackupGaxOperation extends GaxOperation { // Overridden with more specific type for CreateBackup operation @@ -56,6 +57,19 @@ export interface CreateBackupOptions { gaxOptions?: CallOptions; } +export interface CopyBackupGaxOperation extends GaxOperation { + // Overridden with more specific type for CopyBackup operation + metadata: Metadata & + databaseAdmin.spanner.admin.database.v1.ICopyBackupMetadata; +} + +export type CopyBackupResponse = [Backup, CopyBackupGaxOperation, IOperation]; + +export interface CopyBackupOptions + extends databaseAdmin.spanner.admin.database.v1.ICopyBackupRequest { + gaxOptions?: CallOptions; +} + /** * IBackup structure with backup state enum translated to string form. */ @@ -88,7 +102,7 @@ export type ExistsCallback = NormalCallback; /** * The {@link Backup} class represents a Cloud Spanner backup. * - * Create a `Backup` object to interact with or create a Cloud Spanner backup. + * Create a `Backup` object to interact with or create a Cloud Spanner backup or copy a backup. * * @class * @@ -99,6 +113,15 @@ export type ExistsCallback = NormalCallback; * const instance = spanner.instance('my-instance'); * const backup = instance.backup('my-backup'); * ``` + * + * ``` + * * @example + * const {Spanner} = require('@google-cloud/spanner'); + * const spanner = new Spanner(); + * const instance = spanner.instance('my-instance'); + * const sourceBackup = instance.backup('my-source-backup'); + * const copyBackup = instance.copyBackup('my-copy-backup', 'my-source-backup'); + * ``` */ class Backup { id: string; @@ -107,11 +130,13 @@ class Backup { resourceHeader_: {[k: string]: string}; request: BackupRequest; metadata?: databaseAdmin.spanner.admin.database.v1.IBackup; - constructor(instance: Instance, name: string) { + sourceName: string | undefined; + constructor(instance: Instance, name: string, sourceName?: string) { this.request = instance.request; this.instanceFormattedName_ = instance.formattedName_; this.formattedName_ = Backup.formatName_(instance.formattedName_, name); this.id = this.formattedName_.split('/').pop() || ''; + this.sourceName = sourceName; this.resourceHeader_ = { [CLOUD_RESOURCE_HEADER]: this.instanceFormattedName_, }; @@ -181,52 +206,81 @@ class Backup { * await backupOperation.promise(); * ``` */ - create(options: CreateBackupOptions): Promise; - create(options: CreateBackupOptions, callback: CreateBackupCallback): void; create( - options: CreateBackupOptions, - callback?: CreateBackupCallback - ): Promise | void { + options: CreateBackupOptions | CopyBackupOptions + ): Promise | Promise; + create( + options: CreateBackupOptions | CopyBackupOptions, + callback: CreateBackupCallback | CopyBackupCallback + ): void; + create( + options: CreateBackupOptions | CopyBackupOptions, + callback?: CreateBackupCallback | CopyBackupCallback + ): Promise | Promise | void { const gaxOpts = options.gaxOptions; - const reqOpts: databaseAdmin.spanner.admin.database.v1.ICreateBackupRequest = - { - parent: this.instanceFormattedName_, - backupId: this.id, - backup: { - database: options.databasePath, - expireTime: Spanner.timestamp(options.expireTime).toStruct(), - name: this.formattedName_, + if ('databasePath' in options) { + const reqOpts: databaseAdmin.spanner.admin.database.v1.ICreateBackupRequest = + { + parent: this.instanceFormattedName_, + backupId: this.id, + backup: { + database: options.databasePath, + expireTime: Spanner.timestamp(options.expireTime).toStruct(), + name: this.formattedName_, + }, + }; + if ('versionTime' in options) { + reqOpts.backup!.versionTime = Spanner.timestamp( + options.versionTime + ).toStruct(); + } + if ( + 'encryptionConfig' in options && + (options as CreateBackupOptions).encryptionConfig + ) { + reqOpts.encryptionConfig = ( + options as CreateBackupOptions + ).encryptionConfig; + } + this.request( + { + client: 'DatabaseAdminClient', + method: 'createBackup', + reqOpts, + gaxOpts, + headers: this.resourceHeader_, }, - }; - if ('versionTime' in options) { - reqOpts.backup!.versionTime = Spanner.timestamp( - options.versionTime - ).toStruct(); - } - if ( - 'encryptionConfig' in options && - (options as CreateBackupOptions).encryptionConfig - ) { - reqOpts.encryptionConfig = ( - options as CreateBackupOptions - ).encryptionConfig; - } - this.request( - { - client: 'DatabaseAdminClient', - method: 'createBackup', - reqOpts, - gaxOpts, - headers: this.resourceHeader_, - }, - (err, operation, resp) => { - if (err) { - callback!(err, null, null, resp); - return; + (err, operation, resp) => { + if (err) { + callback!(err, null, null, resp); + return; + } + callback!(null, this, operation, resp); } - callback!(null, this, operation, resp); - } - ); + ); + } else if (this.sourceName) { + delete options.gaxOptions; + options.backupId = this.id; + options.parent = this.instanceFormattedName_; + options.sourceBackup = this.sourceName; + this.request( + { + client: 'DatabaseAdminClient', + method: 'copyBackup', + reqOpts: + options as databaseAdmin.spanner.admin.database.v1.ICopyBackupRequest, + gaxOpts, + headers: this.resourceHeader_, + }, + (err, operation, resp) => { + if (err) { + callback!(err, null, null, resp); + return; + } + callback!(null, this, operation, resp); + } + ); + } } /** diff --git a/src/instance.ts b/src/instance.ts index e01373f69..f34e39f5e 100644 --- a/src/instance.ts +++ b/src/instance.ts @@ -41,7 +41,12 @@ import { CallOptions, GoogleError, } from 'google-gax'; -import {Backup} from './backup'; +import { + Backup, + CopyBackupCallback, + CopyBackupResponse, + CopyBackupOptions, +} from './backup'; import {google as instanceAdmin} from '../protos/protos'; import {google as databaseAdmin} from '../protos/protos'; import {google as spannerClient} from '../protos/protos'; @@ -260,6 +265,62 @@ class Instance extends common.GrpcServiceObject { return new Backup(this, backupId); } + /** + * Get a reference to a Backup object. + * + * @throws {GoogleError} If any parameter is not provided. + * + * @typedef {object} CopyBackupOptions + * * @property {string|null} + * * sourceBackup The full path of the backup to be copied + * * @property {string|number|google.protobuf.Timestamp|external:PreciseDate} + * * expireTime The expire time of the backup. + * * @property {google.spanner.admin.database.v1.ICopyBackupEncryptionConfig} + * * encryptionConfig An encryption configuration describing the + * * encryption type and key resources in Cloud KMS to be used to encrypt + * * the copy backup. + * * @property {object} [gaxOptions] The request configuration options, + * * See {@link https://googleapis.dev/nodejs/google-gax/latest/interfaces/CallOptions.html|CallOptions} + * * for more details. + * */ + /** + * @callback CopyBackupCallback + * @param {string} sourceBackupId Full path of the source backup to be copied. + * @param {string} backupId The name of the backup. + * @param {CopyBackupOptions} + * @return {Backup} A Backup object. + * + * @example + * ``` + * const {Spanner} = require('@google-cloud/spanner'); + * const spanner = new Spanner(); + * const instance = spanner.instance('my-instance'); + * const backup = instance.backup('my-source-backup','my-backup',{ + * expireTime: expireTime, + * encryptionConfig: { + * encryptionType: 'CUSTOMER_MANAGED_ENCRYPTION', + * kmsKeyName: 'projects/my-project-id/my-region/keyRings/my-key-ring/cryptoKeys/my-key', + * },); + * ``` + */ + copyBackup( + sourceBackupId: string, + backupId: string, + options: CopyBackupOptions, + callback?: CopyBackupCallback + ): Promise | void { + if (!backupId || !sourceBackupId) { + throw new GoogleError( + 'A backup ID and source backup ID is required to create a copy of the source backup.' + ); + } + const copyOfBackup = new Backup(this, backupId, sourceBackupId); + if (callback) { + return copyOfBackup.create(options, callback); + } + return copyOfBackup.create(options); + } + /** * Query object for listing backups. * diff --git a/system-test/spanner.ts b/system-test/spanner.ts index 023f96dc6..eab5f4592 100644 --- a/system-test/spanner.ts +++ b/system-test/spanner.ts @@ -1263,19 +1263,23 @@ describe('Spanner', () => { backup1Operation.metadata!.name, `${instance.formattedName_}/backups/${backup1Name}` ); - assert.strictEqual( - backup1Operation.metadata!.database, - database1.formattedName_ - ); + if ('database' in backup1Operation.metadata) { + assert.strictEqual( + backup1Operation.metadata!.database, + database1.formattedName_ + ); + } assert.strictEqual( backup2Operation.metadata!.name, `${instance.formattedName_}/backups/${backup2Name}` ); - assert.strictEqual( - backup2Operation.metadata!.database, - database2.formattedName_ - ); + if ('database' in backup2Operation.metadata) { + assert.strictEqual( + backup2Operation.metadata!.database, + database2.formattedName_ + ); + } // Wait for backups to finish. await backup1Operation.promise();