Skip to content

Commit d3a6cc3

Browse files
author
serverpod_cloud
committed
feat: 11d5c59e9ea779188fe4204a03895e6b2fce0fff
1 parent 2583e6c commit d3a6cc3

5 files changed

Lines changed: 235 additions & 0 deletions

File tree

ground_control_client/lib/src/protocol/client.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,15 @@ class EndpointAdminProjects extends _i1.EndpointRef {
123123
'listProjects',
124124
{'includeArchived': includeArchived},
125125
);
126+
127+
/// Redeploys a capsule using its current image.
128+
/// Triggers a deploymentUpdated event to redeploy the infrastructure.
129+
_i2.Future<void> redeployCapsule(String cloudProjectId) =>
130+
caller.callServerEndpoint<void>(
131+
'adminProjects',
132+
'redeployCapsule',
133+
{'cloudProjectId': cloudProjectId},
134+
);
126135
}
127136

128137
/// Endpoint for global administrator users access.

serverpod_cloud_cli/lib/command_runner/commands/admin/admin_command.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:serverpod_cloud_cli/command_runner/cloud_cli_command.dart';
22

33
import 'admin_product_commands.dart';
44
import 'admin_projects_commands.dart';
5+
import 'admin_redeploy_command.dart';
56
import 'admin_users_commands.dart';
67

78
/// The admin command is used internally for Serverpod Cloud administration.
@@ -24,6 +25,7 @@ class CloudAdminCommand extends CloudCliCommand {
2425
addSubcommand(AdminListUsersCommand(logger: logger));
2526
addSubcommand(AdminInviteUserCommand(logger: logger));
2627
addSubcommand(AdminListProjectsCommand(logger: logger));
28+
addSubcommand(AdminRedeployCommand(logger: logger));
2729
addSubcommand(AdminProductCommand(logger: logger));
2830
}
2931
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import 'package:config/config.dart';
2+
import 'package:serverpod_cloud_cli/command_runner/cloud_cli_command.dart';
3+
import 'package:serverpod_cloud_cli/commands/admin/project_admin.dart';
4+
5+
enum AdminRedeployOption<V> implements OptionDefinition<V> {
6+
projectId(
7+
StringOption(
8+
argPos: 0,
9+
mandatory: true,
10+
argName: 'project',
11+
helpText: 'The project ID to redeploy.',
12+
),
13+
);
14+
15+
const AdminRedeployOption(this.option);
16+
17+
@override
18+
final ConfigOptionBase<V> option;
19+
}
20+
21+
class AdminRedeployCommand extends CloudCliCommand<AdminRedeployOption> {
22+
@override
23+
final name = 'redeploy';
24+
25+
@override
26+
final description =
27+
'Trigger redeployment of a project using its current image.';
28+
29+
AdminRedeployCommand({required super.logger})
30+
: super(options: AdminRedeployOption.values);
31+
32+
@override
33+
Future<void> runWithConfig(
34+
final Configuration<AdminRedeployOption> commandConfig,
35+
) async {
36+
final projectId = commandConfig.value(AdminRedeployOption.projectId);
37+
38+
await ProjectAdminCommands.redeployProject(
39+
runner.serviceProvider.cloudApiClient,
40+
logger: logger,
41+
projectId: projectId,
42+
);
43+
}
44+
}

serverpod_cloud_cli/lib/commands/admin/project_admin.dart

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:ground_control_client/ground_control_client.dart';
22
import 'package:serverpod_cloud_cli/command_logger/command_logger.dart';
3+
import 'package:serverpod_cloud_cli/shared/exceptions/exit_exceptions.dart';
34
import 'package:serverpod_cloud_cli/util/common.dart';
45
import 'package:serverpod_cloud_cli/util/printers/table_printer.dart';
56

@@ -35,6 +36,23 @@ abstract class ProjectAdminCommands {
3536
table.writeLines(logger.line);
3637
}
3738

39+
static Future<void> redeployProject(
40+
final Client cloudApiClient, {
41+
required final CommandLogger logger,
42+
required final String projectId,
43+
}) async {
44+
try {
45+
await cloudApiClient.adminProjects.redeployCapsule(projectId);
46+
} on Exception catch (e, s) {
47+
throw FailureException.nested(e, s, 'Failed to redeploy project');
48+
}
49+
50+
logger.success(
51+
'Redeployment triggered for project: $projectId',
52+
newParagraph: true,
53+
);
54+
}
55+
3856
static String _formatProjectUsers(final Project project) {
3957
return project.roles?.map(
4058
(final r) {
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import 'dart:async';
2+
3+
import 'package:ground_control_client/ground_control_client_test_tools.dart';
4+
import 'package:mocktail/mocktail.dart';
5+
import 'package:test/test.dart';
6+
7+
import 'package:serverpod_cloud_cli/command_runner/cloud_cli_command_runner.dart';
8+
import 'package:serverpod_cloud_cli/command_runner/commands/admin/admin_redeploy_command.dart';
9+
import 'package:serverpod_cloud_cli/command_runner/helpers/cloud_cli_service_provider.dart';
10+
import 'package:serverpod_cloud_cli/shared/exceptions/exit_exceptions.dart';
11+
12+
import '../../../test_utils/command_logger_matchers.dart';
13+
import '../../../test_utils/test_command_logger.dart';
14+
15+
void main() {
16+
final logger = TestCommandLogger();
17+
final keyManager = InMemoryKeyManager();
18+
final client = ClientMock(authenticationKeyManager: keyManager);
19+
final cli = CloudCliCommandRunner.create(
20+
logger: logger,
21+
serviceProvider: CloudCliServiceProvider(
22+
apiClientFactory: (final globalCfg) => client,
23+
),
24+
adminUserMode: true,
25+
);
26+
27+
tearDown(() async {
28+
await keyManager.remove();
29+
30+
logger.clear();
31+
});
32+
33+
test('Given admin redeploy command when instantiated then requires login',
34+
() {
35+
expect(AdminRedeployCommand(logger: logger).requireLogin, isTrue);
36+
});
37+
38+
group('Given unauthenticated', () {
39+
group('when executing admin redeploy', () {
40+
late Future commandResult;
41+
setUp(() async {
42+
commandResult = cli.run([
43+
'admin',
44+
'redeploy',
45+
'test-project',
46+
]);
47+
});
48+
49+
test('then throws exception', () async {
50+
await expectLater(commandResult, throwsA(isA<ErrorExitException>()));
51+
});
52+
53+
test('then logs error', () async {
54+
await commandResult.catchError((final _) {});
55+
56+
expect(logger.errorCalls, isNotEmpty);
57+
expect(
58+
logger.errorCalls.first,
59+
equalsErrorCall(
60+
message: 'This command requires you to be logged in.',
61+
));
62+
});
63+
});
64+
});
65+
66+
group('Given authenticated', () {
67+
setUp(() async {
68+
await keyManager.put('mock-token');
69+
});
70+
71+
group('when executing admin redeploy', () {
72+
late Future commandResult;
73+
setUp(() async {
74+
when(() => client.adminProjects.redeployCapsule('test-project'))
75+
.thenAnswer((final invocation) async => Future.value());
76+
77+
commandResult = cli.run([
78+
'admin',
79+
'redeploy',
80+
'test-project',
81+
]);
82+
});
83+
84+
test('then command completes successfully', () async {
85+
await expectLater(commandResult, completes);
86+
});
87+
88+
test('then logs success message', () async {
89+
await commandResult.catchError((final _) {});
90+
91+
expect(
92+
logger.successCalls,
93+
contains(
94+
equalsSuccessCall(
95+
message: 'Redeployment triggered for project: test-project',
96+
newParagraph: true,
97+
),
98+
),
99+
);
100+
});
101+
});
102+
103+
group('when executing admin redeploy with different project ID', () {
104+
late Future commandResult;
105+
setUp(() async {
106+
when(() => client.adminProjects.redeployCapsule('another-project'))
107+
.thenAnswer((final invocation) async => Future.value());
108+
109+
commandResult = cli.run([
110+
'admin',
111+
'redeploy',
112+
'another-project',
113+
]);
114+
});
115+
116+
test('then command completes successfully', () async {
117+
await expectLater(commandResult, completes);
118+
});
119+
120+
test('then logs success message with correct project ID', () async {
121+
await commandResult.catchError((final _) {});
122+
123+
expect(
124+
logger.successCalls,
125+
contains(
126+
equalsSuccessCall(
127+
message: 'Redeployment triggered for project: another-project',
128+
newParagraph: true,
129+
),
130+
),
131+
);
132+
});
133+
});
134+
135+
group('when redeployCapsule throws exception', () {
136+
late Future commandResult;
137+
setUp(() async {
138+
when(() => client.adminProjects.redeployCapsule('test-project'))
139+
.thenThrow(Exception('API Error'));
140+
141+
commandResult = cli.run([
142+
'admin',
143+
'redeploy',
144+
'test-project',
145+
]);
146+
});
147+
148+
test('then throws ErrorExitException', () async {
149+
await expectLater(commandResult, throwsA(isA<ErrorExitException>()));
150+
});
151+
152+
test('then logs error message', () async {
153+
await commandResult.catchError((final _) {});
154+
155+
expect(logger.errorCalls, isNotEmpty);
156+
expect(logger.errorCalls.first.message,
157+
equals('Failed to redeploy project'));
158+
expect(logger.errorCalls.first.exception, isNotNull);
159+
});
160+
});
161+
});
162+
}

0 commit comments

Comments
 (0)