Skip to content

Commit 86359f2

Browse files
author
serverpod_cloud
committed
feat(scloud): a74cd045bb27e8c493af0760e380db34651236b8
1 parent 18e44eb commit 86359f2

7 files changed

Lines changed: 293 additions & 10 deletions

File tree

serverpod_cloud_cli/lib/commands/deploy/deploy.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import 'package:serverpod_cloud_cli/project_zipper/project_zipper_exceptions.dar
1515
import 'package:serverpod_cloud_cli/shared/exceptions/exit_exceptions.dart';
1616
import 'package:serverpod_cloud_cli/util/dart_version_util.dart'
1717
show ProjectDartVersionHint;
18+
import 'package:serverpod_cloud_cli/util/deploy_multi_instance_serverpod_warning.dart';
1819
import 'package:serverpod_cloud_cli/util/pubspec_validator.dart'
1920
show TenantProjectPubspec;
2021
import 'package:serverpod_cloud_cli/util/scloud_config/scloud_config_io.dart';
@@ -83,6 +84,13 @@ abstract class Deploy {
8384
late final String uploadDescription;
8485

8586
if (!dryRun) {
87+
await warnIfLegacyServerpodWithMultipleInstances(
88+
cloudApiClient: cloudApiClient,
89+
projectId: projectId,
90+
logger: logger,
91+
serverpodVersionConstraint: pubspecValidator.serverpodVersion,
92+
);
93+
8694
final serverpodVersion = pubspecValidator.serverpodVersion;
8795

8896
await logger.progress('Retrieving upload description...', () async {

serverpod_cloud_cli/lib/constants.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ abstract final class VersionConstants {
1717
/// The constraint for which Serverpod versions are supported for tenant
1818
/// projects in Serverpod Cloud.
1919
static const supportedServerpodConstraint = '>=$minSupportedServerpodVersion';
20+
21+
/// Minimum Serverpod version recommended when deploying with more than one
22+
/// server instance (scaling / rolling deploy behavior).
23+
static const serverpodMultiInstanceSafeMinVersion = '3.3.0';
2024
}
2125

2226
abstract final class ProjectConfigFileConstants {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import 'package:ground_control_client/ground_control_client.dart'
2+
show Client, ComputeInfo;
3+
import 'package:pub_semver/pub_semver.dart';
4+
import 'package:serverpod_cloud_cli/command_logger/command_logger.dart';
5+
import 'package:serverpod_cloud_cli/constants.dart' show VersionConstants;
6+
7+
/// True when the pubspec's Serverpod constraint does not allow any
8+
/// [VersionConstants.serverpodMultiInstanceSafeMinVersion] or newer release.
9+
bool serverpodConstraintPrecludesMultiInstanceSafeRelease(
10+
final String? serverpodVersionConstraint,
11+
) {
12+
if (serverpodVersionConstraint == null) {
13+
return false;
14+
}
15+
try {
16+
final projectConstraint = VersionConstraint.parse(
17+
serverpodVersionConstraint,
18+
);
19+
final fromSafe = VersionConstraint.parse(
20+
'>=${VersionConstants.serverpodMultiInstanceSafeMinVersion}',
21+
);
22+
return !projectConstraint.allowsAny(fromSafe);
23+
} on FormatException {
24+
return false;
25+
}
26+
}
27+
28+
/// True when compute is configured for more than one running instance
29+
/// at minimum or maximum scale.
30+
bool computeUsesMoreThanOneInstance(final ComputeInfo compute) =>
31+
compute.minInstances > 1 || compute.maxInstances > 1;
32+
33+
/// When the project cannot resolve to Serverpod
34+
/// [VersionConstants.serverpodMultiInstanceSafeMinVersion]+ and the capsule
35+
/// is scaled beyond a single instance, logs a warning. Failures reading
36+
/// compute are ignored (debug log only).
37+
Future<void> warnIfLegacyServerpodWithMultipleInstances({
38+
required final Client cloudApiClient,
39+
required final String projectId,
40+
required final CommandLogger logger,
41+
required final String? serverpodVersionConstraint,
42+
}) async {
43+
if (!serverpodConstraintPrecludesMultiInstanceSafeRelease(
44+
serverpodVersionConstraint,
45+
)) {
46+
return;
47+
}
48+
try {
49+
final compute = await cloudApiClient.compute.readCompute(
50+
cloudCapsuleId: projectId,
51+
);
52+
if (!computeUsesMoreThanOneInstance(compute)) {
53+
return;
54+
}
55+
logger.warning(
56+
'Multiple server instances are enabled, but your Serverpod constraint does not allow it. '
57+
'Upgrade to Serverpod ${VersionConstants.serverpodMultiInstanceSafeMinVersion} '
58+
'or later to reduce the risk of disruption during scaling and deployment.',
59+
newParagraph: true,
60+
);
61+
} on Exception catch (e) {
62+
logger.debug(
63+
'Could not read compute configuration for legacy Serverpod scaling '
64+
'warning: $e',
65+
);
66+
}
67+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import 'package:ground_control_client/ground_control_client.dart';
2+
import 'package:serverpod_cloud_cli/util/deploy_multi_instance_serverpod_warning.dart';
3+
import 'package:test/test.dart';
4+
5+
void main() {
6+
group('serverpodConstraintPrecludesMultiInstanceSafeRelease', () {
7+
test('Given ^3.2.0 when evaluated then returns false', () {
8+
expect(
9+
serverpodConstraintPrecludesMultiInstanceSafeRelease('^3.2.0'),
10+
isFalse,
11+
);
12+
});
13+
14+
test('Given 3.3.0 when evaluated then returns false', () {
15+
expect(
16+
serverpodConstraintPrecludesMultiInstanceSafeRelease('3.3.0'),
17+
isFalse,
18+
);
19+
});
20+
21+
test('Given >=3.2.0 <3.3.0 when evaluated then returns true', () {
22+
expect(
23+
serverpodConstraintPrecludesMultiInstanceSafeRelease('>=3.2.0 <3.3.0'),
24+
isTrue,
25+
);
26+
});
27+
28+
test('Given ^3.3.0 when evaluated then returns false', () {
29+
expect(
30+
serverpodConstraintPrecludesMultiInstanceSafeRelease('^3.3.0'),
31+
isFalse,
32+
);
33+
});
34+
35+
test('Given null when evaluated then returns false', () {
36+
expect(
37+
serverpodConstraintPrecludesMultiInstanceSafeRelease(null),
38+
isFalse,
39+
);
40+
});
41+
});
42+
43+
group('computeUsesMoreThanOneInstance', () {
44+
test('Given min 1 max 1 when evaluated then returns false', () {
45+
final info = ComputeInfo(
46+
cloudCapsuleId: 'c1',
47+
size: ComputeSizeOption.small,
48+
minInstances: 1,
49+
maxInstances: 1,
50+
memoryMb: 512,
51+
);
52+
expect(computeUsesMoreThanOneInstance(info), isFalse);
53+
});
54+
55+
test('Given min 1 max 2 when evaluated then returns true', () {
56+
final info = ComputeInfo(
57+
cloudCapsuleId: 'c1',
58+
size: ComputeSizeOption.small,
59+
minInstances: 1,
60+
maxInstances: 2,
61+
memoryMb: 512,
62+
);
63+
expect(computeUsesMoreThanOneInstance(info), isTrue);
64+
});
65+
66+
test('Given min 2 max 2 when evaluated then returns true', () {
67+
final info = ComputeInfo(
68+
cloudCapsuleId: 'c1',
69+
size: ComputeSizeOption.small,
70+
minInstances: 2,
71+
maxInstances: 2,
72+
memoryMb: 512,
73+
);
74+
expect(computeUsesMoreThanOneInstance(info), isTrue);
75+
});
76+
});
77+
}

serverpod_cloud_cli/test_integration/commands/deploy_command_test.dart

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,21 @@ void main() {
4242
fileUploaderFactory: (final _) => mockFileUploader,
4343
),
4444
);
45+
var readComputeMaxInstancesForTest = 1;
46+
ComputeInfo buildReadComputeAnswer() => ComputeInfoBuilder()
47+
.withMaxInstances(readComputeMaxInstancesForTest)
48+
.build();
4549

4650
setUp(() {
4751
mockFileUploader.init();
4852
logger.clear();
53+
readComputeMaxInstancesForTest = 1;
54+
reset(client.compute);
55+
when(
56+
() => client.compute.readCompute(
57+
cloudCapsuleId: any(named: 'cloudCapsuleId'),
58+
),
59+
).thenAnswer((final _) async => buildReadComputeAnswer());
4960
});
5061

5162
test('Given deploy command when instantiated then requires login', () {
@@ -141,6 +152,111 @@ project:
141152
},
142153
);
143154

155+
group(
156+
'and legacy Serverpod constraint with multi-instance compute when deploying',
157+
() {
158+
late String testProjectDir;
159+
160+
setUp(() async {
161+
await d.dir(ProjectFactory.defaultDirectoryName, [
162+
d.file('pubspec.yaml', '''
163+
name: ${ProjectFactory.defaultPackageName}
164+
environment:
165+
sdk: ${ProjectFactory.validSdkVersion}
166+
dependencies:
167+
serverpod: ">=3.2.0 <3.3.0"
168+
'''),
169+
d.file('scloud.yaml', '''
170+
project:
171+
projectId: "${BucketUploadDescription.projectId}"
172+
dartSdk: "${VersionConstants.minSupportedSdkVersion}"
173+
'''),
174+
]).create();
175+
testProjectDir = p.join(
176+
d.sandbox,
177+
ProjectFactory.defaultDirectoryName,
178+
);
179+
180+
when(
181+
() => client.deploy.createUploadDescription(
182+
any(),
183+
serverpodVersion: any(named: 'serverpodVersion'),
184+
dartVersion: any(named: 'dartVersion'),
185+
),
186+
).thenAnswer(
187+
(final _) async => BucketUploadDescription.uploadDescription,
188+
);
189+
readComputeMaxInstancesForTest = 2;
190+
});
191+
192+
test('then logs multi-instance scaling warning', () async {
193+
await cli.run([
194+
'deploy',
195+
'--project',
196+
BucketUploadDescription.projectId,
197+
'--project-dir',
198+
testProjectDir,
199+
]);
200+
201+
expect(logger.warningCalls, isNotEmpty);
202+
expect(
203+
logger.warningCalls.first.message,
204+
contains(VersionConstants.serverpodMultiInstanceSafeMinVersion),
205+
);
206+
});
207+
},
208+
);
209+
210+
group(
211+
'and Serverpod ^3.3.0 with multi-instance compute when deploying',
212+
() {
213+
late String testProjectDir;
214+
215+
setUp(() async {
216+
await d.dir(ProjectFactory.defaultDirectoryName, [
217+
d.file('pubspec.yaml', '''
218+
name: ${ProjectFactory.defaultPackageName}
219+
environment:
220+
sdk: ${ProjectFactory.validSdkVersion}
221+
dependencies:
222+
serverpod: "^3.3.0"
223+
'''),
224+
d.file('scloud.yaml', '''
225+
project:
226+
projectId: "${BucketUploadDescription.projectId}"
227+
dartSdk: "${VersionConstants.minSupportedSdkVersion}"
228+
'''),
229+
]).create();
230+
testProjectDir = p.join(
231+
d.sandbox,
232+
ProjectFactory.defaultDirectoryName,
233+
);
234+
235+
when(
236+
() => client.deploy.createUploadDescription(
237+
any(),
238+
serverpodVersion: any(named: 'serverpodVersion'),
239+
dartVersion: any(named: 'dartVersion'),
240+
),
241+
).thenAnswer(
242+
(final _) async => BucketUploadDescription.uploadDescription,
243+
);
244+
readComputeMaxInstancesForTest = 2;
245+
});
246+
247+
test('then logs no warning', () async {
248+
await cli.run([
249+
'deploy',
250+
'--project',
251+
BucketUploadDescription.projectId,
252+
'--project-dir',
253+
testProjectDir,
254+
]);
255+
expect(logger.warningCalls, isEmpty);
256+
});
257+
},
258+
);
259+
144260
group('and invalid output option value when running deploy command', () {
145261
late Future cliCommandFuture;
146262
setUp(() async {

serverpod_cloud_cli/test_integration/commands/launch_command_test.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ void main() {
6161
setUp(() {
6262
mockFileUploader.init();
6363
logger.clear();
64+
reset(client.compute);
65+
when(
66+
() => client.compute.readCompute(
67+
cloudCapsuleId: any(named: 'cloudCapsuleId'),
68+
),
69+
).thenAnswer(
70+
(final _) async => ComputeInfoBuilder().withMaxInstances(1).build(),
71+
);
6472
});
6573

6674
tearDown(() {

serverpod_cloud_cli/test_integration/commands/project_command/link_command_test.dart

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -347,17 +347,20 @@ project:
347347
await expectLater(commandResult, completes);
348348
});
349349

350-
test('then scloud.yaml contains dartSdk from .tool-versions', () async {
351-
await commandResult;
350+
test(
351+
'then scloud.yaml omits dartSdk when only .tool-versions is present',
352+
() async {
353+
await commandResult;
352354

353-
final resolved = resolveProjectDartSdkVersion(
354-
Directory(testProjectDir),
355-
);
356-
final expected = d.dir(testProjectDir, [
357-
d.file('scloud.yaml', contains('dartSdk: "$resolved"')),
358-
]);
359-
await expectLater(expected.validate(), completes);
360-
});
355+
final resolved = resolveProjectDartSdkVersion(
356+
Directory(testProjectDir),
357+
);
358+
final expected = d.dir(testProjectDir, [
359+
d.file('scloud.yaml', contains('dartSdk: "$resolved"')),
360+
]);
361+
await expectLater(expected.validate(), completes);
362+
},
363+
);
361364
});
362365

363366
group('and .tool-versions file with dart entry when executing link '

0 commit comments

Comments
 (0)