Skip to content

Commit fd9516a

Browse files
author
serverpod_cloud
committed
feat(cli): d4fdbbfad4a53b5b711696b7f30034c3c478e7c6
1 parent 91dd1af commit fd9516a

5 files changed

Lines changed: 289 additions & 0 deletions

File tree

ground_control_client/lib/src/protocol/client.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,14 @@ class EndpointDatabase extends _i1.EndpointRef {
683683
'cloudCapsuleId': cloudCapsuleId,
684684
'username': username,
685685
});
686+
687+
/// Wipes the database by deleting and recreating it.
688+
/// This will drop all tables and data in the database.
689+
/// The deployment will error until a redeploy is performed.
690+
_i2.Future<void> wipeDatabase({required String cloudCapsuleId}) =>
691+
caller.callServerEndpoint<void>('database', 'wipeDatabase', {
692+
'cloudCapsuleId': cloudCapsuleId,
693+
});
686694
}
687695

688696
/// Endpoint for infrastructure resource provisioning.

serverpod_cloud_cli/lib/command_runner/commands/categories.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ abstract final class CommandCategories {
22
static const String gettingStarted = 'Getting started';
33
static const String manage = 'Management';
44
static const String control = 'Mission Control';
5+
static const String dangerZone = 'Danger Zone';
56
}

serverpod_cloud_cli/lib/command_runner/commands/db_command.dart

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:config/config.dart';
22
import 'package:serverpod_cloud_cli/command_runner/cloud_cli_command.dart';
3+
import 'package:serverpod_cloud_cli/commands/db/db.dart';
34
import 'package:serverpod_cloud_cli/shared/exceptions/exit_exceptions.dart';
45
import 'package:serverpod_cloud_cli/command_runner/helpers/command_options.dart';
56

@@ -18,6 +19,7 @@ class CloudDbCommand extends CloudCliCommand {
1819
CloudDbCommand({required super.logger}) {
1920
addSubcommand(CloudDbConnectionDetailsCommand(logger: logger));
2021
addSubcommand(CloudDbUserCommand(logger: logger));
22+
addSubcommand(CloudDbWipeCommand(logger: logger));
2123
}
2224
}
2325

@@ -192,3 +194,42 @@ $password''');
192194
}
193195
}
194196
}
197+
198+
enum DbWipeOption<V> implements OptionDefinition<V> {
199+
projectId(ProjectIdOption());
200+
201+
const DbWipeOption(this.option);
202+
203+
@override
204+
final ConfigOptionBase<V> option;
205+
}
206+
207+
class CloudDbWipeCommand extends CloudCliCommand<DbWipeOption> {
208+
@override
209+
final name = 'wipe';
210+
211+
@override
212+
final description =
213+
'Irreversibly wipe and recreate the database, deleting all data and schema changes.';
214+
215+
@override
216+
String get category => CommandCategories.dangerZone;
217+
218+
CloudDbWipeCommand({required super.logger})
219+
: super(options: DbWipeOption.values);
220+
221+
@override
222+
Future<void> runWithConfig(
223+
final Configuration<DbWipeOption> commandConfig,
224+
) async {
225+
final projectId = commandConfig.value(DbWipeOption.projectId);
226+
final skipConfirmation = globalConfiguration.skipConfirmation;
227+
228+
await DbCommands.wipeDatabase(
229+
runner.serviceProvider.cloudApiClient,
230+
logger: logger,
231+
projectId: projectId,
232+
skipConfirmation: skipConfirmation,
233+
);
234+
}
235+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import 'package:ground_control_client/ground_control_client.dart';
2+
import 'package:serverpod_cloud_cli/command_logger/command_logger.dart';
3+
import 'package:serverpod_cloud_cli/shared/exceptions/exit_exceptions.dart';
4+
5+
abstract class DbCommands {
6+
static Future<void> wipeDatabase(
7+
final Client cloudApiClient, {
8+
required final CommandLogger logger,
9+
required final String projectId,
10+
required final bool skipConfirmation,
11+
}) async {
12+
if (!skipConfirmation) {
13+
final confirmed = await logger.confirm('''
14+
WARNING: Deletes all tables and data in the database for project "$projectId".
15+
This is a NON-REVERSIBLE action.
16+
The server will error until a redeploy is performed.
17+
18+
Do you want to proceed?''', defaultValue: false);
19+
20+
if (!confirmed) {
21+
logger.info('Database wipe cancelled.');
22+
return;
23+
}
24+
}
25+
26+
final apiCloudClient = cloudApiClient;
27+
28+
try {
29+
await logger.progress(
30+
'Wiping database for project "$projectId"...',
31+
newParagraph: true,
32+
() async {
33+
await apiCloudClient.database.wipeDatabase(cloudCapsuleId: projectId);
34+
return true;
35+
},
36+
);
37+
38+
logger.success('Database wiped successfully.');
39+
logger.info('Redeploy is needed, run: scloud deploy');
40+
} on Exception catch (e, stackTrace) {
41+
throw FailureException.nested(e, stackTrace, 'Failed to wipe database');
42+
}
43+
}
44+
}

serverpod_cloud_cli/test_integration/commands/db_command/db_command_test.dart

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ void main() {
5353
},
5454
);
5555

56+
test('Given db wipe command when instantiated then requires login', () {
57+
expect(CloudDbWipeCommand(logger: logger).requireLogin, isTrue);
58+
});
59+
5660
group('Given unauthenticated', () {
5761
group('when executing db connection', () {
5862
setUpAll(() {
@@ -183,6 +187,50 @@ void main() {
183187
);
184188
});
185189
});
190+
191+
group('when executing db wipe', () {
192+
setUpAll(() {
193+
when(
194+
() => client.database.wipeDatabase(
195+
cloudCapsuleId: any(named: 'cloudCapsuleId'),
196+
),
197+
).thenThrow(ServerpodClientUnauthorized());
198+
});
199+
200+
tearDownAll(() {
201+
reset(client.database);
202+
});
203+
204+
late Future commandResult;
205+
setUp(() {
206+
commandResult = cli.run([
207+
'db',
208+
'wipe',
209+
'--project',
210+
projectId,
211+
'--yes',
212+
]);
213+
});
214+
215+
test('then throws exception', () async {
216+
await expectLater(commandResult, throwsA(isA<ExitException>()));
217+
});
218+
219+
test('then logs error', () async {
220+
try {
221+
await commandResult;
222+
} catch (_) {}
223+
224+
expect(logger.errorCalls, isNotEmpty);
225+
expect(
226+
logger.errorCalls.first,
227+
equalsErrorCall(
228+
message:
229+
'The credentials for this session seem to no longer be valid.',
230+
),
231+
);
232+
});
233+
});
186234
});
187235

188236
group('Given authenticated', () {
@@ -330,5 +378,152 @@ DB password is reset. The new password is only shown this once:
330378
$password''');
331379
});
332380
});
381+
382+
group('when executing db wipe with --yes', () {
383+
setUpAll(() {
384+
when(
385+
() => client.database.wipeDatabase(
386+
cloudCapsuleId: any(named: 'cloudCapsuleId'),
387+
),
388+
).thenAnswer((final _) async => Future.value());
389+
});
390+
391+
tearDownAll(() {
392+
reset(client.database);
393+
});
394+
395+
late Future commandResult;
396+
setUp(() {
397+
commandResult = cli.run([
398+
'db',
399+
'wipe',
400+
'--project',
401+
projectId,
402+
'--yes',
403+
]);
404+
});
405+
406+
tearDown(() {
407+
// Reset mock call count between tests in this group
408+
clearInteractions(client.database);
409+
});
410+
411+
test('then succeeds', () async {
412+
await expectLater(commandResult, completes);
413+
});
414+
415+
test('then outputs success message', () async {
416+
await commandResult;
417+
418+
expect(logger.successCalls, isNotEmpty);
419+
expect(
420+
logger.successCalls.single.message,
421+
contains('Database wiped successfully.'),
422+
);
423+
expect(
424+
logger.infoCalls.single.message,
425+
contains('Redeploy is needed, run: scloud deploy'),
426+
);
427+
});
428+
429+
test('then calls wipeDatabase on client', () async {
430+
await commandResult;
431+
432+
verify(
433+
() => client.database.wipeDatabase(cloudCapsuleId: projectId),
434+
).called(1);
435+
});
436+
});
437+
438+
group('when executing db wipe without --yes', () {
439+
setUpAll(() {
440+
when(
441+
() => client.database.wipeDatabase(
442+
cloudCapsuleId: any(named: 'cloudCapsuleId'),
443+
),
444+
).thenAnswer((final _) async => Future.value());
445+
});
446+
447+
tearDownAll(() {
448+
reset(client.database);
449+
});
450+
451+
group('and user confirms', () {
452+
late Future commandResult;
453+
setUp(() {
454+
logger.answerNextConfirmWith(true);
455+
commandResult = cli.run(['db', 'wipe', '--project', projectId]);
456+
});
457+
458+
tearDown(() {
459+
// Reset mock call count between tests in this group
460+
clearInteractions(client.database);
461+
});
462+
463+
test('then succeeds', () async {
464+
await expectLater(commandResult, completes);
465+
});
466+
467+
test('then prompts for confirmation', () async {
468+
await commandResult;
469+
470+
expect(logger.confirmCalls, isNotEmpty);
471+
expect(
472+
logger.confirmCalls.first.message,
473+
contains('Do you want to proceed?'),
474+
);
475+
});
476+
477+
test('then calls wipeDatabase on client', () async {
478+
await commandResult;
479+
480+
verify(
481+
() => client.database.wipeDatabase(cloudCapsuleId: projectId),
482+
).called(1);
483+
});
484+
});
485+
486+
group('and user declines', () {
487+
late Future commandResult;
488+
setUp(() {
489+
logger.answerNextConfirmWith(false);
490+
commandResult = cli.run(['db', 'wipe', '--project', projectId]);
491+
});
492+
493+
test('then succeeds without wiping', () async {
494+
await expectLater(commandResult, completes);
495+
});
496+
497+
test('then prompts for confirmation', () async {
498+
await commandResult;
499+
500+
expect(logger.confirmCalls, isNotEmpty);
501+
expect(
502+
logger.confirmCalls.first.message,
503+
contains('Do you want to proceed?'),
504+
);
505+
});
506+
507+
test('then does not call wipeDatabase on client', () async {
508+
await commandResult;
509+
510+
verifyNever(
511+
() => client.database.wipeDatabase(cloudCapsuleId: projectId),
512+
);
513+
});
514+
515+
test('then logs cancellation message', () async {
516+
await commandResult;
517+
518+
expect(logger.infoCalls, isNotEmpty);
519+
expect(
520+
logger.infoCalls.any(
521+
(final call) => call.message.contains('Database wipe cancelled.'),
522+
),
523+
isTrue,
524+
);
525+
});
526+
});
527+
});
333528
});
334529
}

0 commit comments

Comments
 (0)