Skip to content

Commit 9b0a71d

Browse files
authored
feat: Add Firestore backup/schedule and restore support (#6778)
1 parent 2ef6af8 commit 9b0a71d

25 files changed

+1126
-201
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Command } from "../command";
2+
import { Backup, deleteBackup, getBackup } from "../gcp/firestore";
3+
import { promptOnce } from "../prompt";
4+
import * as clc from "colorette";
5+
import { logger } from "../logger";
6+
import { requirePermissions } from "../requirePermissions";
7+
import { Emulators } from "../emulator/types";
8+
import { warnEmulatorNotSupported } from "../emulator/commandUtils";
9+
import { FirestoreOptions } from "../firestore/options";
10+
import { FirebaseError } from "../error";
11+
12+
export const command = new Command("firestore:backups:delete <backup>")
13+
.description("Delete a backup under your Cloud Firestore database.")
14+
.option("--force", "Attempt to delete backup without prompting for confirmation.")
15+
.before(requirePermissions, ["datastore.backups.delete"])
16+
.before(warnEmulatorNotSupported, Emulators.FIRESTORE)
17+
.action(async (backupName: string, options: FirestoreOptions) => {
18+
const backup: Backup = await getBackup(backupName);
19+
20+
if (!options.force) {
21+
const confirmMessage = `You are about to delete ${backupName}. Do you wish to continue?`;
22+
const consent = await promptOnce({
23+
type: "confirm",
24+
message: confirmMessage,
25+
default: false,
26+
});
27+
if (!consent) {
28+
throw new FirebaseError("Delete backup canceled.");
29+
}
30+
}
31+
32+
try {
33+
await deleteBackup(backupName);
34+
} catch (err: any) {
35+
throw new FirebaseError(`Failed to delete the backup ${backupName}`, { original: err });
36+
}
37+
38+
if (options.json) {
39+
logger.info(JSON.stringify(backup, undefined, 2));
40+
} else {
41+
logger.info(clc.bold(`Successfully deleted ${clc.yellow(backupName)}`));
42+
}
43+
44+
return backup;
45+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Command } from "../command";
2+
import { logger } from "../logger";
3+
import { requirePermissions } from "../requirePermissions";
4+
import { Emulators } from "../emulator/types";
5+
import { warnEmulatorNotSupported } from "../emulator/commandUtils";
6+
import { FirestoreOptions } from "../firestore/options";
7+
import { Backup, getBackup } from "../gcp/firestore";
8+
import { PrettyPrint } from "../firestore/pretty-print";
9+
10+
export const command = new Command("firestore:backups:get <backup>")
11+
.description("Get a Cloud Firestore database backup.")
12+
.before(requirePermissions, ["datastore.backups.get"])
13+
.before(warnEmulatorNotSupported, Emulators.FIRESTORE)
14+
.action(async (backupName: string, options: FirestoreOptions) => {
15+
const backup: Backup = await getBackup(backupName);
16+
const printer = new PrettyPrint();
17+
18+
if (options.json) {
19+
logger.info(JSON.stringify(backup, undefined, 2));
20+
} else {
21+
printer.prettyPrintBackup(backup);
22+
}
23+
24+
return backup;
25+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Command } from "../command";
2+
import { logger } from "../logger";
3+
import { requirePermissions } from "../requirePermissions";
4+
import { Emulators } from "../emulator/types";
5+
import { warnEmulatorNotSupported } from "../emulator/commandUtils";
6+
import { FirestoreOptions } from "../firestore/options";
7+
import { Backup, listBackups, ListBackupsResponse } from "../gcp/firestore";
8+
import { logWarning } from "../utils";
9+
import { PrettyPrint } from "../firestore/pretty-print";
10+
11+
export const command = new Command("firestore:backups:list")
12+
.description("List all Cloud Firestore backups in a given location")
13+
.option(
14+
"-l, --location <locationId>",
15+
"Location to search for backups, for example 'nam5'. Run 'firebase firestore:locations' to get a list of eligible locations. Defaults to all locations.",
16+
)
17+
.before(requirePermissions, ["datastore.backups.list"])
18+
.before(warnEmulatorNotSupported, Emulators.FIRESTORE)
19+
.action(async (options: FirestoreOptions) => {
20+
const printer = new PrettyPrint();
21+
22+
const location = options.location ?? "-";
23+
const listBackupsResponse: ListBackupsResponse = await listBackups(options.project, location);
24+
const backups: Backup[] = listBackupsResponse.backups || [];
25+
26+
if (options.json) {
27+
logger.info(JSON.stringify(listBackupsResponse, undefined, 2));
28+
} else {
29+
printer.prettyPrintBackups(backups);
30+
if (listBackupsResponse.unreachable && listBackupsResponse.unreachable.length > 0) {
31+
logWarning(
32+
"We were not able to reach the following locations: " +
33+
listBackupsResponse.unreachable.join(", "),
34+
);
35+
}
36+
}
37+
38+
return backups;
39+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import * as clc from "colorette";
2+
3+
import { Command } from "../command";
4+
import { calculateRetention } from "../firestore/backupUtils";
5+
import {
6+
BackupSchedule,
7+
DayOfWeek,
8+
WeeklyRecurrence,
9+
createBackupSchedule,
10+
} from "../gcp/firestore";
11+
import * as types from "../firestore/api-types";
12+
import { logger } from "../logger";
13+
import { requirePermissions } from "../requirePermissions";
14+
import { Emulators } from "../emulator/types";
15+
import { warnEmulatorNotSupported } from "../emulator/commandUtils";
16+
import { FirestoreOptions } from "../firestore/options";
17+
import { PrettyPrint } from "../firestore/pretty-print";
18+
19+
export const command = new Command("firestore:backups:schedules:create")
20+
.description("Create a backup schedule under your Cloud Firestore database.")
21+
.option(
22+
"-db, --database <databaseId>",
23+
"Database under which you want to create a schedule. Defaults to the (default) database",
24+
)
25+
.option("-rt, --retention <duration>", "duration string (e.g. 12h or 30d) for backup retention")
26+
.option("-rc, --recurrence <recurrence>", "Recurrence settings; either DAILY or WEEKLY")
27+
.option(
28+
"-dw, --day-of-week <dayOfWeek>",
29+
"On which day of the week to perform backups; one of MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, or SUNDAY",
30+
)
31+
.before(requirePermissions, ["datastore.backupSchedules.create"])
32+
.before(warnEmulatorNotSupported, Emulators.FIRESTORE)
33+
.action(async (options: FirestoreOptions) => {
34+
const printer = new PrettyPrint();
35+
36+
const databaseId = options.database || "(default)";
37+
38+
if (!options.retention) {
39+
logger.error(
40+
"Missing required flag --retention. See firebase firestore:backups:schedules:create --help for more info",
41+
);
42+
return;
43+
}
44+
const retention = calculateRetention(options.retention);
45+
46+
if (!options.recurrence) {
47+
logger.error(
48+
"Missing required flag --recurrence. See firebase firestore:backups:schedules:create --help for more info",
49+
);
50+
return;
51+
}
52+
const recurrenceType: types.RecurrenceType = options.recurrence;
53+
if (
54+
recurrenceType !== types.RecurrenceType.DAILY &&
55+
recurrenceType !== types.RecurrenceType.WEEKLY
56+
) {
57+
logger.error(
58+
"Invalid value for flag --recurrence. See firebase firestore:backups:schedules:create --help for more info",
59+
);
60+
return;
61+
}
62+
let dailyRecurrence: Record<string, never> | undefined;
63+
let weeklyRecurrence: WeeklyRecurrence | undefined;
64+
if (options.recurrence === types.RecurrenceType.DAILY) {
65+
dailyRecurrence = {};
66+
if (options.dayOfWeek) {
67+
logger.error("--day-of-week should not be provided if --recurrence is DAILY");
68+
return;
69+
}
70+
} else if (options.recurrence === types.RecurrenceType.WEEKLY) {
71+
if (!options.dayOfWeek) {
72+
logger.error(
73+
"If --recurrence is WEEKLY, --day-of-week must be provided. See firebase firestore:backups:schedules:create --help for more info",
74+
);
75+
return;
76+
}
77+
const day: DayOfWeek = options.dayOfWeek;
78+
weeklyRecurrence = {
79+
day,
80+
};
81+
}
82+
83+
const backupSchedule: BackupSchedule = await createBackupSchedule(
84+
options.project,
85+
databaseId,
86+
retention,
87+
dailyRecurrence,
88+
weeklyRecurrence,
89+
);
90+
91+
if (options.json) {
92+
logger.info(JSON.stringify(backupSchedule, undefined, 2));
93+
} else {
94+
logger.info(
95+
clc.bold(`Successfully created ${printer.prettyBackupScheduleString(backupSchedule)}`),
96+
);
97+
}
98+
99+
return backupSchedule;
100+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Command } from "../command";
2+
import { BackupSchedule, deleteBackupSchedule, getBackupSchedule } from "../gcp/firestore";
3+
import { promptOnce } from "../prompt";
4+
import * as clc from "colorette";
5+
import { logger } from "../logger";
6+
import { requirePermissions } from "../requirePermissions";
7+
import { Emulators } from "../emulator/types";
8+
import { warnEmulatorNotSupported } from "../emulator/commandUtils";
9+
import { FirestoreOptions } from "../firestore/options";
10+
import { FirebaseError } from "../error";
11+
12+
export const command = new Command("firestore:backups:schedules:delete <backupSchedule>")
13+
.description("Delete a backup schedule under your Cloud Firestore database.")
14+
.option("--force", "Attempt to delete backup schedule without prompting for confirmation.")
15+
.before(requirePermissions, ["datastore.backupSchedules.delete"])
16+
.before(warnEmulatorNotSupported, Emulators.FIRESTORE)
17+
.action(async (backupScheduleName: string, options: FirestoreOptions) => {
18+
const backupSchedule: BackupSchedule = await getBackupSchedule(backupScheduleName);
19+
20+
if (!options.force) {
21+
const confirmMessage = `You are about to delete ${backupScheduleName}. Do you wish to continue?`;
22+
const consent = await promptOnce({
23+
type: "confirm",
24+
message: confirmMessage,
25+
default: false,
26+
});
27+
if (!consent) {
28+
throw new FirebaseError("Delete backup schedule canceled.");
29+
}
30+
}
31+
32+
try {
33+
await deleteBackupSchedule(backupScheduleName);
34+
} catch (err: any) {
35+
throw new FirebaseError(`Failed to delete the backup schedule ${backupScheduleName}`, {
36+
original: err,
37+
});
38+
}
39+
40+
if (options.json) {
41+
logger.info(JSON.stringify(backupSchedule, undefined, 2));
42+
} else {
43+
logger.info(clc.bold(`Successfully deleted ${clc.yellow(backupScheduleName)}`));
44+
}
45+
46+
return backupSchedule;
47+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Command } from "../command";
2+
import { logger } from "../logger";
3+
import { requirePermissions } from "../requirePermissions";
4+
import { Emulators } from "../emulator/types";
5+
import { warnEmulatorNotSupported } from "../emulator/commandUtils";
6+
import { FirestoreOptions } from "../firestore/options";
7+
import { BackupSchedule, listBackupSchedules } from "../gcp/firestore";
8+
import { PrettyPrint } from "../firestore/pretty-print";
9+
10+
export const command = new Command("firestore:backups:schedules:list")
11+
.description("List backup schedules under your Cloud Firestore database.")
12+
.option(
13+
"-db, --database <databaseId>",
14+
"Database whose schedules you wish to list. Defaults to the (default) database.",
15+
)
16+
.before(requirePermissions, ["datastore.backupSchedules.list"])
17+
.before(warnEmulatorNotSupported, Emulators.FIRESTORE)
18+
.action(async (options: FirestoreOptions) => {
19+
const printer = new PrettyPrint();
20+
21+
const databaseId = options.database ?? "(default)";
22+
const backupSchedules: BackupSchedule[] = await listBackupSchedules(
23+
options.project,
24+
databaseId,
25+
);
26+
27+
if (options.json) {
28+
logger.info(JSON.stringify(backupSchedules, undefined, 2));
29+
} else {
30+
printer.prettyPrintBackupSchedules(backupSchedules, databaseId);
31+
}
32+
33+
return backupSchedules;
34+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import * as clc from "colorette";
2+
3+
import { Command } from "../command";
4+
import { calculateRetention } from "../firestore/backupUtils";
5+
import { BackupSchedule, updateBackupSchedule } from "../gcp/firestore";
6+
import { logger } from "../logger";
7+
import { requirePermissions } from "../requirePermissions";
8+
import { Emulators } from "../emulator/types";
9+
import { warnEmulatorNotSupported } from "../emulator/commandUtils";
10+
import { FirestoreOptions } from "../firestore/options";
11+
import { PrettyPrint } from "../firestore/pretty-print";
12+
13+
export const command = new Command("firestore:backups:schedules:update <backupSchedule>")
14+
.description("Update a backup schedule under your Cloud Firestore database.")
15+
.option("-rt, --retention <duration>", "duration string (e.g. 12h or 30d) for backup retention")
16+
.before(requirePermissions, ["datastore.backupSchedules.update"])
17+
.before(warnEmulatorNotSupported, Emulators.FIRESTORE)
18+
.action(async (backupScheduleName: string, options: FirestoreOptions) => {
19+
const printer = new PrettyPrint();
20+
21+
if (!options.retention) {
22+
logger.error(
23+
"Missing required flag --retention. See firebase firestore:backups:schedules:update --help for more info",
24+
);
25+
return;
26+
}
27+
const retention = calculateRetention(options.retention);
28+
29+
const backupSchedule: BackupSchedule = await updateBackupSchedule(
30+
backupScheduleName,
31+
retention,
32+
);
33+
34+
if (options.json) {
35+
logger.info(JSON.stringify(backupSchedule, undefined, 2));
36+
} else {
37+
logger.info(
38+
clc.bold(`Successfully updated ${printer.prettyBackupScheduleString(backupSchedule)}`),
39+
);
40+
}
41+
42+
return backupSchedule;
43+
});

src/commands/firestore-databases-create.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
import { Command } from "../command";
21
import * as clc from "colorette";
2+
3+
import { Command } from "../command";
34
import * as fsi from "../firestore/api";
45
import * as types from "../firestore/api-types";
56
import { logger } from "../logger";
67
import { requirePermissions } from "../requirePermissions";
78
import { Emulators } from "../emulator/types";
89
import { warnEmulatorNotSupported } from "../emulator/commandUtils";
910
import { FirestoreOptions } from "../firestore/options";
11+
import { PrettyPrint } from "../firestore/pretty-print";
1012

1113
export const command = new Command("firestore:databases:create <database>")
1214
.description("Create a database in your Firebase project.")
@@ -26,6 +28,7 @@ export const command = new Command("firestore:databases:create <database>")
2628
.before(warnEmulatorNotSupported, Emulators.FIRESTORE)
2729
.action(async (database: string, options: FirestoreOptions) => {
2830
const api = new fsi.FirestoreApi();
31+
const printer = new PrettyPrint();
2932
if (!options.location) {
3033
logger.error(
3134
"Missing required flag --location. See firebase firestore:databases:create --help for more info.",
@@ -76,7 +79,7 @@ export const command = new Command("firestore:databases:create <database>")
7679
if (options.json) {
7780
logger.info(JSON.stringify(databaseResp, undefined, 2));
7881
} else {
79-
logger.info(clc.bold(`Successfully created ${api.prettyDatabaseString(databaseResp)}`));
82+
logger.info(clc.bold(`Successfully created ${printer.prettyDatabaseString(databaseResp)}`));
8083
logger.info(
8184
"Please be sure to configure Firebase rules in your Firebase config file for\n" +
8285
"the new database. By default, created databases will have closed rules that\n" +

0 commit comments

Comments
 (0)