Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 88 additions & 14 deletions lib/datastore-backup.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,63 @@ require('colors');
// @see https://googleapis.dev/nodejs/datastore/latest/
const {Datastore} = require('@google-cloud/datastore');

/**
* Execute a gcloud command safely without invoking a shell.
*
* @param {string[]} args
* @returns {string} stdout as UTF-8 string
*/
const execGcloud = (args) => {
const result = child_process.spawnSync('gcloud', args, { encoding: 'utf8' });
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
const stderr = result.stderr || '';
const stdout = result.stdout || '';
throw new Error(`gcloud ${args.join(' ')} failed with code ${result.status}: ${stderr || stdout}`);
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spawnSync can return status === null when the process is terminated by a signal; the current error message will report “code null” and lose the actual reason. Consider including result.signal (and formatting the message differently when status is null) so failures are diagnosable.

Suggested change
throw new Error(`gcloud ${args.join(' ')} failed with code ${result.status}: ${stderr || stdout}`);
const status = result.status;
const signal = result.signal;
const reason = (status === null && signal)
? `terminated by signal ${signal}`
: `failed with code ${status}`;
throw new Error(`gcloud ${args.join(' ')} ${reason}: ${stderr || stdout}`);

Copilot uses AI. Check for mistakes.
}
return result.stdout || '';
};

/**
* Build argument list for gcloud datastore import.
*
* @param {string[]} kinds
* @param {string} project
* @param {string} bucket
* @param {string} timestamp
* @param {object} options
* @returns {string[]}
*/
const buildDatastoreRestoreArgs = (kinds, project, bucket, timestamp, options) => {
const args = ['datastore', 'import', '--project', project];
if (options && !_.isUndefined(options.account)) {
args.push('--account', options.account);
}
Comment on lines +41 to +43
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Account handling is inconsistent between the two arg builders: restore uses !_.isUndefined(options.account) while status uses a truthy check (if (options && options.account)). This can lead to different behavior for empty-string accounts or other falsy-but-present values. Consider using the same predicate in both builders to keep behavior predictable.

Copilot uses AI. Check for mistakes.
const kindsValue = kinds.join(',');
args.push('--kinds', kindsValue);
args.push('--async');
const backupMetadataFile = 'gs://' + bucket + '/' + timestamp + '/' + timestamp + '.overall_export_metadata';
args.push(backupMetadataFile);
return args;
};

/**
* Build argument list for gcloud datastore operations list.
*
* @param {string} project
* @param {object} options
* @returns {string[]}
*/
const buildDatastoreStatusArgs = (project, options) => {
const args = ['datastore', 'operations', 'list', '--project', project];
if (options && options.account) {
args.push('--account', options.account);
}
return args;
};

/**
* create a backup of kinds from project into bucket; depends only on Datastore API client, rather
* than calling anything on cmd line - so could be run in NodeJS env that can't call out to OS
Expand Down Expand Up @@ -45,27 +102,44 @@ const backup = async (kinds, project, bucket, options) => {
* @returns {string} output from the commands
*/
const testRestoreFromBackup = (kind, project, bucket, timestamp, options) => {
let restore = child_process.execSync(datastoreRestoreCommand([kind], project, bucket, timestamp, options)).toString('utf8');
let status = child_process.execSync(datastoreStatusCommand(project, options));
const restoreArgs = buildDatastoreRestoreArgs([kind], project, bucket, timestamp, options || {});
const restore = execGcloud(restoreArgs);
const statusArgs = buildDatastoreStatusArgs(project, options || {});
const status = execGcloud(statusArgs);
return restore + "\n" + status;
};

/**
* Build a printable gcloud datastore import command string.
*
* This is intended for display to the user (for example, in index.js),
* not for execution via a shell.
*
* @param {string[]} kinds
* @param {string} project
* @param {string} bucket
* @param {string} timestamp
* @param {object} options
* @returns {string}
*/
const datastoreRestoreCommand = (kinds, project, bucket, timestamp, options) => {
let authOptions = ' --project ' + project;
if (!_.isUndefined(options.account)) {
authOptions += '--account ' + options.account;
}
let backupMetadataFile = 'gs://' + bucket + '/' + timestamp + '/' + timestamp + '.overall_export_metadata'
return 'gcloud datastore import ' + authOptions + ' --kinds="' + kinds.join(',') + '" --async ' + backupMetadataFile ;
const args = buildDatastoreRestoreArgs(kinds, project, bucket, timestamp, options || {});
return 'gcloud ' + args.map(String).join(' ');
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Building a copy-pasteable command by joining args with spaces can produce an unsafe/misleading shell command when any arg contains whitespace or shell metacharacters (project IDs, account emails, or GCS paths with unusual characters). Since these strings are intended for users to paste into a shell, consider shell-escaping/quoting each arg (e.g., via a small escape helper or a vetted “shell-escape” utility) before joining.

Copilot uses AI. Check for mistakes.
};

/**
* Build a printable gcloud datastore operations list command string.
*
* This is intended for display to the user (for example, in index.js),
* not for execution via a shell.
*
* @param {string} project
* @param {object} options
* @returns {string}
*/
const datastoreStatusCommand = (project, options) => {
let authOptions = '';
if (options.account) {
authOptions += ' --account \' + options.account';
}
authOptions += ' --project ' + project;
return 'gcloud datastore operations' + authOptions + ' list';
const args = buildDatastoreStatusArgs(project, options || {});
return 'gcloud ' + args.map(String).join(' ');
};

module.exports.backup = backup;
Expand Down
Loading