@spinnaker/google 2026.3.2 → 2026.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/autoscalingPolicy/GceAutoScalingFieldLayout.d.ts +2 -2
  2. package/dist/domain/loadBalancer.d.ts +6 -3
  3. package/dist/image/ImageSelect.d.ts +1 -1
  4. package/dist/index.js +159 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/loadBalancer/configure/common/gceLoadBalancerModels.d.ts +13 -2
  7. package/dist/loadBalancer/configure/http/GceHttpLoadBalancerEditor.d.ts +3 -0
  8. package/dist/loadBalancer/configure/http/GceHttpLoadBalancerModal.d.ts +1 -1
  9. package/dist/loadBalancer/configure/network/GceRegionalExternalNetworkLoadBalancerEditor.d.ts +30 -0
  10. package/dist/loadBalancer/configure/network/GceRegionalExternalNetworkLoadBalancerModal.d.ts +53 -0
  11. package/dist/loadBalancer/httpLoadBalancerUtils.service.d.ts +3 -1
  12. package/dist/serverGroup/configure/wizard/GceCloneServerGroupModal.d.ts +3 -0
  13. package/dist/serverGroup/configure/wizard/GceServerGroupWizard.types.d.ts +1 -0
  14. package/dist/serverGroup/configure/wizard/customInstance/CustomInstanceConfigurer.d.ts +1 -1
  15. package/dist/serverGroup/configure/wizard/customInstance/GceCustomInstanceBuilder.d.ts +2 -2
  16. package/dist/serverGroup/configure/wizard/pages/AdvancedSettings.d.ts +3 -0
  17. package/dist/serverGroup/configure/wizard/pages/GceServerGroupLoadBalancers.d.ts +1 -0
  18. package/dist/serverGroup/configure/wizard/pages/Policies.d.ts +21 -3
  19. package/dist/serverGroup/configure/wizard/pages/ServerGroupBasicSettings.d.ts +1 -0
  20. package/dist/serverGroup/configure/wizard/pages/ServerGroupCapacity.d.ts +1 -0
  21. package/dist/serverGroup/configure/wizard/pages/ServerGroupImageSettings.d.ts +1 -0
  22. package/dist/serverGroup/configure/wizard/pages/ServerGroupInstanceType.d.ts +1 -0
  23. package/dist/serverGroup/configure/wizard/zones/GceInstanceFlexibilityConfigurer.d.ts +2 -2
  24. package/dist/serverGroup/details/GceInstanceFlexibilityPolicyDetails.d.ts +2 -2
  25. package/dist/serverGroup/details/stateful/MarkDiskStatefulButton.d.ts +2 -2
  26. package/dist/serverGroup/details/stateful/UpdateBootImageButton.d.ts +2 -2
  27. package/package.json +5 -5
  28. package/src/autoHealingPolicy/GceAutoHealingPolicyEditor.spec.tsx +68 -52
  29. package/src/autoscalingPolicy/GceAutoscalingPolicyEditor.spec.tsx +43 -50
  30. package/src/autoscalingPolicy/GceAutoscalingPolicyEditor.tsx +1 -1
  31. package/src/autoscalingPolicy/GceAutoscalingPolicyWriter.spec.ts +1 -1
  32. package/src/domain/loadBalancer.ts +6 -3
  33. package/src/gce.module.spec.ts +132 -153
  34. package/src/instance/details/GceInstanceDetails.spec.tsx +86 -83
  35. package/src/loadBalancer/configure/choice/GceLoadBalancerChoiceModal.spec.tsx +162 -121
  36. package/src/loadBalancer/configure/choice/GceLoadBalancerChoiceModal.tsx +13 -1
  37. package/src/loadBalancer/configure/common/GceProxyLoadBalancerEditor.spec.tsx +47 -30
  38. package/src/loadBalancer/configure/common/GceProxyLoadBalancerEditor.tsx +2 -1
  39. package/src/loadBalancer/configure/common/GceProxyLoadBalancerModal.spec.tsx +85 -57
  40. package/src/loadBalancer/configure/common/gceLoadBalancerData.spec.ts +17 -16
  41. package/src/loadBalancer/configure/common/gceLoadBalancerModels.spec.ts +230 -5
  42. package/src/loadBalancer/configure/common/gceLoadBalancerModels.ts +186 -22
  43. package/src/loadBalancer/configure/common/gceLoadBalancerSubmission.spec.ts +298 -2
  44. package/src/loadBalancer/configure/common/gceLoadBalancerSubmission.ts +35 -5
  45. package/src/loadBalancer/configure/common/useGceLoadBalancerData.spec.tsx +37 -46
  46. package/src/loadBalancer/configure/http/GceHttpLoadBalancerEditor.spec.tsx +399 -58
  47. package/src/loadBalancer/configure/http/GceHttpLoadBalancerEditor.tsx +148 -41
  48. package/src/loadBalancer/configure/http/GceHttpLoadBalancerListenerEditor.spec.tsx +169 -36
  49. package/src/loadBalancer/configure/http/GceHttpLoadBalancerListenerEditor.tsx +80 -12
  50. package/src/loadBalancer/configure/http/GceHttpLoadBalancerModal.spec.tsx +224 -124
  51. package/src/loadBalancer/configure/http/GceHttpLoadBalancerModal.tsx +8 -2
  52. package/src/loadBalancer/configure/http/GceHttpLoadBalancerResourceEditors.spec.tsx +57 -22
  53. package/src/loadBalancer/configure/http/GceHttpLoadBalancerResourceEditors.tsx +21 -0
  54. package/src/loadBalancer/configure/http/GceHttpLoadBalancerRoutingEditor.spec.tsx +24 -20
  55. package/src/loadBalancer/configure/network/GceNetworkLoadBalancerEditor.spec.tsx +41 -32
  56. package/src/loadBalancer/configure/network/GceNetworkLoadBalancerModal.spec.tsx +35 -30
  57. package/src/loadBalancer/configure/network/GceRegionalExternalNetworkLoadBalancerEditor.spec.tsx +443 -0
  58. package/src/loadBalancer/configure/network/GceRegionalExternalNetworkLoadBalancerEditor.tsx +401 -0
  59. package/src/loadBalancer/configure/network/GceRegionalExternalNetworkLoadBalancerModal.spec.tsx +330 -0
  60. package/src/loadBalancer/configure/network/GceRegionalExternalNetworkLoadBalancerModal.tsx +348 -0
  61. package/src/loadBalancer/details/gceLoadBalancerDetails.spec.tsx +439 -35
  62. package/src/loadBalancer/details/gceLoadBalancerDetails.tsx +104 -21
  63. package/src/loadBalancer/httpLoadBalancerUtils.service.spec.ts +126 -0
  64. package/src/loadBalancer/httpLoadBalancerUtils.service.ts +27 -11
  65. package/src/loadBalancer/loadBalancer.setTransformer.spec.ts +165 -0
  66. package/src/loadBalancer/loadBalancer.setTransformer.ts +15 -2
  67. package/src/pipeline/stages/googleStageConfig.spec.js +22 -16
  68. package/src/securityGroup/configure/GceSecurityGroupModal.spec.tsx +177 -193
  69. package/src/securityGroup/configure/GceSecurityGroupModal.tsx +14 -2
  70. package/src/securityGroup/details/GceSecurityGroupDetails.spec.tsx +69 -78
  71. package/src/serverGroup/configure/serverGroupCommandBuilder.service.spec.js +35 -15
  72. package/src/serverGroup/configure/serverGroupConfiguration.service.js +7 -1
  73. package/src/serverGroup/configure/serverGroupConfiguration.service.spec.js +74 -4
  74. package/src/serverGroup/configure/wizard/GceCloneServerGroupModal.spec.tsx +593 -855
  75. package/src/serverGroup/configure/wizard/GceCloneServerGroupModal.tsx +120 -38
  76. package/src/serverGroup/configure/wizard/GceServerGroupWizard.types.ts +1 -0
  77. package/src/serverGroup/configure/wizard/GceServerGroupWizardPage.spec.tsx +53 -50
  78. package/src/serverGroup/configure/wizard/customInstance/GceCustomInstanceBuilder.spec.tsx +65 -49
  79. package/src/serverGroup/configure/wizard/hiddenMetadataKeys.value.js +1 -0
  80. package/src/serverGroup/configure/wizard/pages/AdvancedSettings.spec.tsx +101 -108
  81. package/src/serverGroup/configure/wizard/pages/AdvancedSettings.tsx +32 -28
  82. package/src/serverGroup/configure/wizard/pages/GceServerGroupFirewalls.spec.tsx +60 -59
  83. package/src/serverGroup/configure/wizard/pages/GceServerGroupLoadBalancers.spec.tsx +240 -114
  84. package/src/serverGroup/configure/wizard/pages/GceServerGroupLoadBalancers.tsx +152 -63
  85. package/src/serverGroup/configure/wizard/pages/Policies.spec.tsx +93 -48
  86. package/src/serverGroup/configure/wizard/pages/Policies.tsx +80 -55
  87. package/src/serverGroup/configure/wizard/pages/ServerGroupBasicSettings.spec.tsx +123 -94
  88. package/src/serverGroup/configure/wizard/pages/ServerGroupBasicSettings.tsx +24 -18
  89. package/src/serverGroup/configure/wizard/pages/ServerGroupCapacity.spec.tsx +152 -133
  90. package/src/serverGroup/configure/wizard/pages/ServerGroupCapacity.tsx +40 -37
  91. package/src/serverGroup/configure/wizard/pages/ServerGroupImageSettings.spec.tsx +66 -60
  92. package/src/serverGroup/configure/wizard/pages/ServerGroupImageSettings.tsx +8 -2
  93. package/src/serverGroup/configure/wizard/pages/ServerGroupInstanceType.spec.tsx +131 -101
  94. package/src/serverGroup/configure/wizard/pages/ServerGroupInstanceType.tsx +29 -28
  95. package/src/serverGroup/configure/wizard/zones/GceInstanceFlexibilityConfigurer.spec.tsx +104 -81
  96. package/src/serverGroup/details/GceInstanceFlexibilityPolicyDetails.spec.tsx +10 -11
  97. package/src/serverGroup/details/autoHealingPolicy/GceAutoHealingPolicyDetails.spec.tsx +51 -59
  98. package/src/serverGroup/details/autoHealingPolicy/modal/GceUpsertAutoHealingPolicyModal.spec.tsx +3 -3
  99. package/src/serverGroup/details/autoscalingPolicy/GceAutoscalingPolicyDetails.spec.tsx +49 -58
  100. package/src/serverGroup/details/autoscalingPolicy/modal/GceUpsertAutoscalingPolicyModal.spec.tsx +3 -3
  101. package/src/serverGroup/details/gceServerGroupDetails.spec.tsx +162 -141
  102. package/src/serverGroup/details/resize/GceResizeServerGroupModal.spec.tsx +102 -86
  103. package/src/serverGroup/details/resize/GceResizeServerGroupModal.tsx +4 -0
  104. package/src/serverGroup/details/rollback/GceRollbackServerGroupModal.spec.tsx +40 -31
  105. package/src/serverGroup/serverGroup.transformer.js +1 -0
  106. package/src/serverGroup/serverGroup.transformer.spec.js +48 -0
  107. package/src/validation/ApplicationNameValidator.spec.ts +1 -1
  108. /package/dist/{gce.logo2ae85d8704b1d6b8.png → gce.logoChI3w_A5.png} +0 -0
@@ -26,8 +26,11 @@ export interface IGceHttpLoadBalancerEditorProps {
26
26
 
27
27
  interface IGceHttpLoadBalancerDataItem extends IGceLoadBalancerDataItem {
28
28
  account?: string;
29
+ address?: string;
30
+ addressType?: string;
29
31
  id?: string;
30
32
  network?: string;
33
+ purpose?: string;
31
34
  region?: string;
32
35
  }
33
36
 
@@ -63,11 +66,35 @@ export function buildGceHttpLoadBalancerOptions(
63
66
  data: IGceLoadBalancerData,
64
67
  ): IGceHttpLoadBalancerOptions {
65
68
  const internal = command.loadBalancerType === 'INTERNAL_MANAGED';
69
+ const externalManaged = command.loadBalancerType === 'EXTERNAL_MANAGED';
66
70
  const accountMatches = (item: IGceHttpLoadBalancerDataItem): boolean => item.account === command.credentials;
67
- const locationMatches = (item: IGceHttpLoadBalancerDataItem): boolean =>
68
- accountMatches(item) &&
69
- (internal ? item.region === command.region : item.region === undefined || item.region === 'global');
71
+ const locationMatches = (item: IGceHttpLoadBalancerDataItem): boolean => {
72
+ if (!accountMatches(item)) {
73
+ return false;
74
+ }
75
+ if (internal || externalManaged) {
76
+ return item.region === command.region;
77
+ }
78
+ return item.region === undefined || item.region === 'global';
79
+ };
80
+ const addressMatches = (item: IGceHttpLoadBalancerDataItem): boolean =>
81
+ locationMatches(item) && (!externalManaged || item.addressType === 'EXTERNAL');
70
82
  const networks = (data.networks as IGceHttpLoadBalancerDataItem[]).filter(accountMatches);
83
+ const proxyNetworkIds = new Set(
84
+ (data.subnets as IGceHttpLoadBalancerDataItem[])
85
+ .filter(
86
+ (subnet) =>
87
+ accountMatches(subnet) && subnet.region === command.region && subnet.purpose === 'REGIONAL_MANAGED_PROXY',
88
+ )
89
+ .map((subnet) => subnet.network)
90
+ .filter(Boolean),
91
+ );
92
+ const eligibleNetworks = externalManaged
93
+ ? networks.filter(
94
+ (network) =>
95
+ isAccountLocalNetwork(network) && (proxyNetworkIds.has(network.id) || proxyNetworkIds.has(network.name)),
96
+ )
97
+ : networks;
71
98
  const selectedNetwork = networks.find(
72
99
  (network) => network.name === command.network?.name || network.id === command.network?.name,
73
100
  );
@@ -83,7 +110,7 @@ export function buildGceHttpLoadBalancerOptions(
83
110
  return {
84
111
  accounts: mergeGceResourceOptions(data.accounts, command.credentials ? [{ name: command.credentials }] : []),
85
112
  addresses: mergeGceResourceOptions(
86
- (data.addresses as IGceHttpLoadBalancerDataItem[]).filter(locationMatches),
113
+ (data.addresses as IGceHttpLoadBalancerDataItem[]).filter(addressMatches),
87
114
  uniqueOptions(command.listeners.flatMap((listener) => (listener.address ? [listener.address] : []))),
88
115
  ),
89
116
  backendServices: mergeGceResourceOptions(
@@ -102,7 +129,7 @@ export function buildGceHttpLoadBalancerOptions(
102
129
  (data.healthChecks as IGceHttpLoadBalancerDataItem[]).filter(locationMatches),
103
130
  uniqueOptions([...command.healthChecks, ...backendHealthChecks]),
104
131
  ),
105
- networks: mergeGceResourceOptions(networks, command.network ? [command.network] : []),
132
+ networks: mergeGceResourceOptions(eligibleNetworks, command.network ? [command.network] : []),
106
133
  regions: mergeGceResourceOptions(data.regions, command.region ? [{ name: command.region }] : []),
107
134
  subnets: mergeGceResourceOptions(
108
135
  internal
@@ -119,25 +146,51 @@ export function buildGceHttpLoadBalancerOptions(
119
146
  }
120
147
 
121
148
  export function constrainGceHttpLoadBalancerCommand(command: IGceLoadBalancerCommand): IGceLoadBalancerCommand {
122
- const constrainListener = ({ subnet, ...listener }: IGceLoadBalancerListener, keepSubnet: boolean) => {
149
+ const constrainListener = (
150
+ { subnet, networkTier, ...listener }: IGceLoadBalancerListener,
151
+ keepSubnet: boolean,
152
+ keepNetworkTier: boolean,
153
+ ) => {
154
+ const constrainedNetworkTier =
155
+ keepNetworkTier && (networkTier || (listener.address?.networkTier as string | undefined) || 'PREMIUM');
123
156
  if (listener.protocol === 'HTTPS') {
124
- return { ...listener, portRange: '443', protocol: 'HTTPS' as const, ...(keepSubnet && subnet ? { subnet } : {}) };
157
+ return {
158
+ ...listener,
159
+ portRange: '443',
160
+ protocol: 'HTTPS' as const,
161
+ ...(keepSubnet && subnet ? { subnet } : {}),
162
+ ...(constrainedNetworkTier ? { networkTier: constrainedNetworkTier } : {}),
163
+ };
125
164
  }
126
- const { certificate: _certificate, ...plaintextListener } = listener;
127
- return { ...plaintextListener, protocol: 'HTTP' as const, ...(keepSubnet && subnet ? { subnet } : {}) };
165
+ const { certificate: _certificate, certificateMap: _certificateMap, ...plaintextListener } = listener;
166
+ return {
167
+ ...plaintextListener,
168
+ protocol: 'HTTP' as const,
169
+ ...(keepSubnet && subnet ? { subnet } : {}),
170
+ ...(constrainedNetworkTier ? { networkTier: constrainedNetworkTier } : {}),
171
+ };
128
172
  };
129
173
 
130
174
  if (command.loadBalancerType === 'INTERNAL_MANAGED') {
131
175
  return {
132
176
  ...command,
133
- listeners: command.listeners.map((listener) => constrainListener(listener, true)),
177
+ listeners: command.listeners.map((listener) => constrainListener(listener, true, false)),
134
178
  region: command.region === 'global' ? '' : command.region,
135
179
  };
136
180
  }
137
181
 
182
+ if (command.loadBalancerType === 'EXTERNAL_MANAGED') {
183
+ return {
184
+ ...command,
185
+ listeners: command.listeners.map((listener) => constrainListener(listener, false, true)),
186
+ region: command.region === 'global' ? '' : command.region,
187
+ subnet: undefined,
188
+ };
189
+ }
190
+
138
191
  return {
139
192
  ...command,
140
- listeners: command.listeners.map((listener) => constrainListener(listener, false)),
193
+ listeners: command.listeners.map((listener) => constrainListener(listener, false, false)),
141
194
  loadBalancerType: 'HTTP',
142
195
  network: undefined,
143
196
  region: 'global',
@@ -148,15 +201,21 @@ export function constrainGceHttpLoadBalancerCommand(command: IGceLoadBalancerCom
148
201
  export function validateGceHttpLoadBalancerCommand(command: IGceLoadBalancerCommand): string[] {
149
202
  const errors = new Set<string>();
150
203
  const internal = command.loadBalancerType === 'INTERNAL_MANAGED';
204
+ const externalManaged = command.loadBalancerType === 'EXTERNAL_MANAGED';
151
205
 
152
206
  if (!command.name.trim()) errors.add('Name is required.');
153
207
  if (!command.credentials) errors.add('Account is required.');
154
- if (internal) {
208
+ if (internal || externalManaged) {
155
209
  if (!command.region || command.region === 'global') {
156
- errors.add('Region is required for INTERNAL_MANAGED load balancers.');
210
+ errors.add(`Region is required for ${internal ? 'INTERNAL_MANAGED' : 'EXTERNAL_MANAGED'} load balancers.`);
211
+ }
212
+ if (!command.network?.name) {
213
+ errors.add(`Network is required for ${internal ? 'INTERNAL_MANAGED' : 'EXTERNAL_MANAGED'} load balancers.`);
157
214
  }
158
- if (!command.network?.name) errors.add('Network is required for INTERNAL_MANAGED load balancers.');
159
- if (!command.subnet?.name) errors.add('Subnet is required for INTERNAL_MANAGED load balancers.');
215
+ if (externalManaged && command.network && !isAccountLocalNetwork(command.network)) {
216
+ errors.add('Shared VPC networks are not supported for EXTERNAL_MANAGED load balancers.');
217
+ }
218
+ if (internal && !command.subnet?.name) errors.add('Subnet is required for INTERNAL_MANAGED load balancers.');
160
219
  } else if (command.region !== 'global') {
161
220
  errors.add('HTTP load balancers must use the global location.');
162
221
  }
@@ -176,6 +235,24 @@ export function validateGceHttpLoadBalancerCommand(command: IGceLoadBalancerComm
176
235
  if (listener.protocol === 'HTTPS' && listener.portRange !== '443') {
177
236
  errors.add('HTTPS listeners must use port 443.');
178
237
  }
238
+ if (externalManaged && listener.certificateMap) {
239
+ errors.add('Certificate maps are not supported for EXTERNAL_MANAGED load balancers.');
240
+ }
241
+ if (externalManaged && listener.address?.networkTier && listener.networkTier !== listener.address.networkTier) {
242
+ errors.add('Reserved IP address network tier must match the listener network tier.');
243
+ }
244
+ if (externalManaged && command.mode === 'edit') {
245
+ const original = command.original?.listeners.find(({ name }) => name === listener.name);
246
+ if (
247
+ original &&
248
+ (listener.portRange !== original.portRange ||
249
+ referenceIdentity(listener.address) !== referenceIdentity(original.address) ||
250
+ listener.networkTier !== original.networkTier ||
251
+ listener.protocol !== original.protocol)
252
+ ) {
253
+ errors.add('Rename the listener to change its port, address, network tier, or HTTP/HTTPS protocol.');
254
+ }
255
+ }
179
256
  });
180
257
 
181
258
  if (!command.backendServices.length) errors.add('At least one backend service is required.');
@@ -183,6 +260,9 @@ export function validateGceHttpLoadBalancerCommand(command: IGceLoadBalancerComm
183
260
  if (!backendService.name.trim()) errors.add('Backend service name is required.');
184
261
  if (!backendService.healthCheck?.name) errors.add('Each backend service requires a health check.');
185
262
  if (!backendService.portName) errors.add('Backend service port name is required.');
263
+ if (externalManaged && !['HTTP', 'HTTPS'].includes(String(backendService.protocol || 'HTTP'))) {
264
+ errors.add('Backend service protocol must be HTTP or HTTPS.');
265
+ }
186
266
  if (negative(backendService.connectionDrainingTimeoutSec)) {
187
267
  errors.add('Connection draining timeout must be zero or greater.');
188
268
  }
@@ -240,6 +320,14 @@ function validPort(value: unknown): boolean {
240
320
  return Number.isInteger(port) && port >= 1 && port <= 65535;
241
321
  }
242
322
 
323
+ function isAccountLocalNetwork(reference: { id?: unknown; name?: unknown; selfLink?: unknown }): boolean {
324
+ return !/^[^/]+\/[^/]+$/.test(String(reference.id || reference.selfLink || reference.name || ''));
325
+ }
326
+
327
+ function referenceIdentity(reference: IGceResourceReference | undefined): string {
328
+ return String(reference?.selfLink || reference?.name || '');
329
+ }
330
+
243
331
  function negative(value: unknown): boolean {
244
332
  return value !== undefined && Number(value) < 0;
245
333
  }
@@ -268,6 +356,13 @@ export function GceHttpLoadBalancerEditor({ command, data, onChange }: IGceHttpL
268
356
  const update = (updates: Partial<IGceLoadBalancerCommand>): void => {
269
357
  onChange(constrainGceHttpLoadBalancerCommand({ ...command, ...updates } as IGceLoadBalancerCommand));
270
358
  };
359
+ // EXTERNAL_MANAGED reserved addresses belong to one account and region, so a scope change drops them.
360
+ const updateScope = (updates: Partial<IGceLoadBalancerCommand>): void =>
361
+ update(
362
+ command.loadBalancerType === 'EXTERNAL_MANAGED'
363
+ ? { ...updates, listeners: command.listeners.map((listener) => ({ ...listener, address: undefined })) }
364
+ : updates,
365
+ );
271
366
 
272
367
  const updateBackendService = (
273
368
  index: number,
@@ -342,6 +437,7 @@ export function GceHttpLoadBalancerEditor({ command, data, onChange }: IGceHttpL
342
437
  >
343
438
  <option value="HTTP">HTTP(S)</option>
344
439
  <option value="INTERNAL_MANAGED">Internal managed HTTP</option>
440
+ <option value="EXTERNAL_MANAGED">External managed HTTP(S)</option>
345
441
  </select>
346
442
  </FormRow>
347
443
  <FormRow label="Account">
@@ -351,7 +447,7 @@ export function GceHttpLoadBalancerEditor({ command, data, onChange }: IGceHttpL
351
447
  disabled={editing}
352
448
  required
353
449
  value={command.credentials}
354
- onChange={(event) => update({ credentials: event.target.value })}
450
+ onChange={(event) => updateScope({ credentials: event.target.value })}
355
451
  >
356
452
  <option value="">Select...</option>
357
453
  {options.accounts.map((account) => (
@@ -361,7 +457,7 @@ export function GceHttpLoadBalancerEditor({ command, data, onChange }: IGceHttpL
361
457
  ))}
362
458
  </select>
363
459
  </FormRow>
364
- {command.loadBalancerType === 'INTERNAL_MANAGED' && (
460
+ {(command.loadBalancerType === 'INTERNAL_MANAGED' || command.loadBalancerType === 'EXTERNAL_MANAGED') && (
365
461
  <React.Fragment>
366
462
  <FormRow label="Region">
367
463
  <select
@@ -370,7 +466,7 @@ export function GceHttpLoadBalancerEditor({ command, data, onChange }: IGceHttpL
370
466
  disabled={editing}
371
467
  required
372
468
  value={command.region}
373
- onChange={(event) => update({ region: event.target.value })}
469
+ onChange={(event) => updateScope({ region: event.target.value })}
374
470
  >
375
471
  <option value="">Select...</option>
376
472
  {options.regions.map((region) => (
@@ -384,6 +480,7 @@ export function GceHttpLoadBalancerEditor({ command, data, onChange }: IGceHttpL
384
480
  <select
385
481
  className="form-control input-sm"
386
482
  data-testid="network"
483
+ disabled={editing && command.loadBalancerType === 'EXTERNAL_MANAGED'}
387
484
  required
388
485
  value={command.network?.name || ''}
389
486
  onChange={(event) => update({ network: selectedReference(event.target.value, options.networks) })}
@@ -396,28 +493,30 @@ export function GceHttpLoadBalancerEditor({ command, data, onChange }: IGceHttpL
396
493
  ))}
397
494
  </select>
398
495
  </FormRow>
399
- <FormRow label="Subnet">
400
- <select
401
- className="form-control input-sm"
402
- data-testid="subnet"
403
- required
404
- value={command.subnet?.name || ''}
405
- onChange={(event) => {
406
- const subnet = selectedReference(event.target.value, options.subnets);
407
- update({
408
- subnet,
409
- listeners: command.listeners.map((listener) => ({ ...listener, subnet })),
410
- });
411
- }}
412
- >
413
- <option value="">Select...</option>
414
- {options.subnets.map((subnet) => (
415
- <option key={subnet.name} value={subnet.name}>
416
- {subnet.name}
417
- </option>
418
- ))}
419
- </select>
420
- </FormRow>
496
+ {command.loadBalancerType === 'INTERNAL_MANAGED' && (
497
+ <FormRow label="Subnet">
498
+ <select
499
+ className="form-control input-sm"
500
+ data-testid="subnet"
501
+ required
502
+ value={command.subnet?.name || ''}
503
+ onChange={(event) => {
504
+ const subnet = selectedReference(event.target.value, options.subnets);
505
+ update({
506
+ subnet,
507
+ listeners: command.listeners.map((listener) => ({ ...listener, subnet })),
508
+ });
509
+ }}
510
+ >
511
+ <option value="">Select...</option>
512
+ {options.subnets.map((subnet) => (
513
+ <option key={subnet.name} value={subnet.name}>
514
+ {subnet.name}
515
+ </option>
516
+ ))}
517
+ </select>
518
+ </FormRow>
519
+ )}
421
520
  </React.Fragment>
422
521
  )}
423
522
  </section>
@@ -479,7 +578,15 @@ export function GceHttpLoadBalancerEditor({ command, data, onChange }: IGceHttpL
479
578
  className="add-new btn btn-block"
480
579
  onClick={() =>
481
580
  update({
482
- backendServices: [...command.backendServices, { name: '', portName: 'http', sessionAffinity: 'NONE' }],
581
+ backendServices: [
582
+ ...command.backendServices,
583
+ {
584
+ name: '',
585
+ portName: 'http',
586
+ ...(command.loadBalancerType === 'EXTERNAL_MANAGED' ? { protocol: 'HTTP' as const } : {}),
587
+ sessionAffinity: 'NONE',
588
+ },
589
+ ],
483
590
  })
484
591
  }
485
592
  >
@@ -1,12 +1,13 @@
1
+ import { fireEvent, render, within } from '@testing-library/react';
1
2
  import React from 'react';
2
- import { shallow } from 'enzyme';
3
3
 
4
4
  import { GceHttpLoadBalancerListenerEditor } from './GceHttpLoadBalancerListenerEditor';
5
+ import { buildGceLoadBalancerJobs } from '../common';
5
6
 
6
7
  describe('GceHttpLoadBalancerListenerEditor', () => {
7
8
  it('edits listener addresses and certificates without submitting the parent form', () => {
8
- const onChange = jasmine.createSpy('onChange');
9
- const wrapper = shallow(
9
+ const onChange = vi.fn();
10
+ const { getByTestId, getAllByRole } = render(
10
11
  <GceHttpLoadBalancerListenerEditor
11
12
  addresses={[{ name: 'removed-address', selfLink: 'https://compute/addresses/removed-address' }]}
12
13
  certificates={[{ name: 'removed-cert', selfLink: 'https://compute/certificates/removed-cert' }]}
@@ -19,27 +20,23 @@ describe('GceHttpLoadBalancerListenerEditor', () => {
19
20
  }}
20
21
  loadBalancerType="HTTP"
21
22
  onChange={onChange}
22
- onRemove={jasmine.createSpy('onRemove')}
23
+ onRemove={vi.fn()}
23
24
  subnets={[]}
24
25
  />,
25
26
  );
26
27
 
27
- expect(wrapper.find('[data-testid="listener-address"] option').map((option) => option.prop('value'))).toContain(
28
- 'removed-address',
29
- );
30
- expect(wrapper.find('[data-testid="listener-certificate"] option').map((option) => option.prop('value'))).toContain(
31
- 'removed-cert',
32
- );
33
- expect(wrapper.find('button').everyWhere((button) => button.prop('type') === 'button')).toBe(true);
28
+ expect(optionValues(getByTestId('listener-address'))).toContain('removed-address');
29
+ expect(optionValues(getByTestId('listener-certificate'))).toContain('removed-cert');
30
+ expect(getAllByRole('button').every((button) => button.getAttribute('type') === 'button')).toBe(true);
34
31
 
35
- wrapper.find('[data-testid="listener-address"]').simulate('change', { target: { value: '' } });
32
+ fireEvent.change(getByTestId('listener-address'), { target: { value: '' } });
36
33
  expect(onChange).toHaveBeenCalledWith(
37
- jasmine.objectContaining({ address: undefined, certificate: jasmine.any(Object), name: 'frontend' }),
34
+ expect.objectContaining({ address: undefined, certificate: expect.any(Object), name: 'frontend' }),
38
35
  );
39
36
  });
40
37
 
41
38
  it('supports HTTPS certificates for INTERNAL_MANAGED listeners', () => {
42
- const wrapper = shallow(
39
+ const { getByTestId } = render(
43
40
  <GceHttpLoadBalancerListenerEditor
44
41
  addresses={[]}
45
42
  certificates={[{ name: 'regional-cert' }]}
@@ -51,44 +48,180 @@ describe('GceHttpLoadBalancerListenerEditor', () => {
51
48
  subnet: { name: 'subnet-a' },
52
49
  }}
53
50
  loadBalancerType="INTERNAL_MANAGED"
54
- onChange={jasmine.createSpy('onChange')}
55
- onRemove={jasmine.createSpy('onRemove')}
51
+ onChange={vi.fn()}
52
+ onRemove={vi.fn()}
56
53
  subnets={[{ name: 'subnet-a' }]}
57
54
  />,
58
55
  );
59
56
 
60
- expect(wrapper.find('[data-testid="listener-protocol"] option').map((option) => option.prop('value'))).toEqual([
61
- 'HTTP',
62
- 'HTTPS',
63
- ]);
64
- expect(wrapper.find('[data-testid="listener-certificate"]').prop('value')).toBe('regional-cert');
57
+ expect(optionValues(getByTestId('listener-protocol'))).toEqual(['HTTP', 'HTTPS']);
58
+ expect(getByTestId('listener-certificate')).toHaveValue('regional-cert');
65
59
  });
66
60
 
67
61
  (['HTTP', 'INTERNAL_MANAGED'] as const).forEach((loadBalancerType) => {
68
62
  it(`sets and locks port 443 for ${loadBalancerType} HTTPS listeners`, () => {
69
- const onChange = jasmine.createSpy('onChange');
70
- const wrapper = shallow(
63
+ const onChange = vi.fn();
64
+ const props = {
65
+ addresses: [],
66
+ certificates: [{ name: 'cert-a' }],
67
+ listener: { name: 'frontend', portRange: '80', protocol: 'HTTP' as const },
68
+ loadBalancerType,
69
+ onChange,
70
+ onRemove: vi.fn(),
71
+ subnets: [],
72
+ };
73
+ const { getByTestId, rerender } = render(<GceHttpLoadBalancerListenerEditor {...props} />);
74
+
75
+ fireEvent.change(getByTestId('listener-protocol'), { target: { value: 'HTTPS' } });
76
+
77
+ expect(onChange).toHaveBeenCalledWith(
78
+ expect.objectContaining({ name: 'frontend', portRange: '443', protocol: 'HTTPS' }),
79
+ );
80
+
81
+ rerender(
71
82
  <GceHttpLoadBalancerListenerEditor
72
- addresses={[]}
73
- certificates={[{ name: 'cert-a' }]}
83
+ {...props}
84
+ listener={{ certificate: { name: 'cert-a' }, name: 'frontend', portRange: '443', protocol: 'HTTPS' }}
85
+ />,
86
+ );
87
+ expect(getByTestId('listener-port')).toBeDisabled();
88
+ });
89
+ });
90
+
91
+ it('edits EXTERNAL_MANAGED listener addresses, certificates, and network tier', () => {
92
+ const onChange = vi.fn();
93
+ const { getByLabelText, getByTestId } = render(
94
+ <GceHttpLoadBalancerListenerEditor
95
+ addresses={[{ address: '203.0.113.10', name: 'external-address' }]}
96
+ certificates={[
97
+ {
98
+ name: 'regional-cert',
99
+ selfLink:
100
+ '//certificatemanager.googleapis.com/projects/test/locations/europe-west1/certificates/regional-cert',
101
+ },
102
+ ]}
103
+ listener={{
104
+ address: { name: 'external-address', address: '203.0.113.10' },
105
+ certificate: {
106
+ name: 'regional-cert',
107
+ selfLink:
108
+ '//certificatemanager.googleapis.com/projects/test/locations/europe-west1/certificates/regional-cert',
109
+ },
110
+ name: 'app-https',
111
+ networkTier: 'STANDARD',
112
+ portRange: '443',
113
+ protocol: 'HTTPS',
114
+ }}
115
+ loadBalancerType="EXTERNAL_MANAGED"
116
+ onChange={onChange}
117
+ onRemove={vi.fn()}
118
+ subnets={[]}
119
+ />,
120
+ );
121
+
122
+ expect(optionValues(getByTestId('listener-address'))).toContain('external-address');
123
+ expect(optionValues(getByTestId('listener-certificate'))).toContain('regional-cert');
124
+ const networkTier = getByLabelText('Network tier');
125
+ expect(networkTier).toHaveValue('STANDARD');
126
+
127
+ fireEvent.change(networkTier, { target: { value: 'PREMIUM' } });
128
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ networkTier: 'PREMIUM' }));
129
+ });
130
+
131
+ it('does not expose certificate map controls for EXTERNAL_MANAGED listeners', () => {
132
+ const { queryByLabelText, queryByTestId } = render(
133
+ <GceHttpLoadBalancerListenerEditor
134
+ addresses={[]}
135
+ certificates={[{ name: 'regional-cert' }]}
136
+ listener={{ name: 'frontend', portRange: '443', protocol: 'HTTPS' }}
137
+ loadBalancerType="EXTERNAL_MANAGED"
138
+ onChange={vi.fn()}
139
+ onRemove={vi.fn()}
140
+ subnets={[]}
141
+ />,
142
+ );
143
+
144
+ expect(queryByTestId('listener-certificate-map')).not.toBeInTheDocument();
145
+ expect(queryByLabelText('Certificate map')).not.toBeInTheDocument();
146
+ });
147
+
148
+ it('accepts a direct Certificate Manager URL while preserving selectable Compute certificates', () => {
149
+ const onChange = vi.fn();
150
+ const certificateUrl =
151
+ '//certificatemanager.googleapis.com/projects/test/locations/europe-west1/certificates/manager-cert';
152
+ const props = {
153
+ addresses: [],
154
+ certificates: [{ name: 'compute-cert', selfLink: 'https://compute/sslCertificates/compute-cert' }],
155
+ listener: { name: 'external-https', portRange: '443', protocol: 'HTTPS' as const },
156
+ loadBalancerType: 'EXTERNAL_MANAGED' as const,
157
+ onChange,
158
+ onRemove: vi.fn(),
159
+ subnets: [],
160
+ };
161
+ const { getByLabelText, getByTestId, rerender } = render(<GceHttpLoadBalancerListenerEditor {...props} />);
162
+
163
+ expect(optionValues(getByTestId('listener-certificate'))).toContain('compute-cert');
164
+ fireEvent.change(getByTestId('listener-certificate'), { target: { value: 'compute-cert' } });
165
+ const computeListener = onChange.mock.lastCall[0];
166
+ expect(computeListener.certificate).toEqual({
167
+ name: 'compute-cert',
168
+ selfLink: 'https://compute/sslCertificates/compute-cert',
169
+ });
170
+ rerender(<GceHttpLoadBalancerListenerEditor {...props} listener={computeListener} />);
171
+ expect(getByTestId('listener-certificate-manager-url')).toHaveValue('');
172
+
173
+ const certificateUrlInput = getByLabelText('Certificate Manager resource URL');
174
+ expect(certificateUrlInput).toBe(getByTestId('listener-certificate-manager-url'));
175
+ fireEvent.change(certificateUrlInput, { target: { value: '//certificate' } });
176
+ rerender(<GceHttpLoadBalancerListenerEditor {...props} listener={onChange.mock.lastCall[0]} />);
177
+ expect(getByTestId('listener-certificate-manager-url')).toHaveValue('//certificate');
178
+
179
+ fireEvent.change(getByTestId('listener-certificate-manager-url'), { target: { value: certificateUrl } });
180
+
181
+ const listener = onChange.mock.lastCall[0];
182
+ expect(listener.certificate).toEqual({ name: 'manager-cert', selfLink: certificateUrl });
183
+ expect(listener.certificateMap).toBeUndefined();
184
+
185
+ const operations = buildGceLoadBalancerJobs({
186
+ backendServices: [{ healthCheck: { name: 'check-a' }, name: 'backend-a', portName: 'http' }],
187
+ credentials: 'account-a',
188
+ defaultService: { name: 'backend-a' },
189
+ healthChecks: [{ healthCheckType: 'HTTPS', name: 'check-a', port: 443, requestPath: '/health' }],
190
+ hostRules: [],
191
+ listeners: [listener],
192
+ loadBalancerType: 'EXTERNAL_MANAGED',
193
+ mode: 'pipeline',
194
+ name: 'app-main',
195
+ network: { name: 'default' },
196
+ region: 'europe-west1',
197
+ } as any);
198
+ expect(operations[0].certificate).toBe(certificateUrl);
199
+ expect(operations[0].certificateMap).toBeUndefined();
200
+ });
201
+
202
+ (['HTTP', 'INTERNAL_MANAGED'] as const).forEach((loadBalancerType) => {
203
+ it(`does not propagate address networkTier for ${loadBalancerType} listeners`, () => {
204
+ const onChange = vi.fn();
205
+ const { getByTestId } = render(
206
+ <GceHttpLoadBalancerListenerEditor
207
+ addresses={[{ address: '203.0.113.10', name: 'external-address', networkTier: 'STANDARD' }]}
208
+ certificates={[]}
74
209
  listener={{ name: 'frontend', portRange: '80', protocol: 'HTTP' }}
75
210
  loadBalancerType={loadBalancerType}
76
211
  onChange={onChange}
77
- onRemove={jasmine.createSpy('onRemove')}
212
+ onRemove={vi.fn()}
78
213
  subnets={[]}
79
214
  />,
80
215
  );
81
216
 
82
- wrapper.find('[data-testid="listener-protocol"]').simulate('change', { target: { value: 'HTTPS' } });
83
-
84
- expect(onChange).toHaveBeenCalledWith(
85
- jasmine.objectContaining({ name: 'frontend', portRange: '443', protocol: 'HTTPS' }),
86
- );
87
-
88
- wrapper.setProps({
89
- listener: { certificate: { name: 'cert-a' }, name: 'frontend', portRange: '443', protocol: 'HTTPS' },
90
- });
91
- expect(wrapper.find('[data-testid="listener-port"]').prop('disabled')).toBe(true);
217
+ fireEvent.change(getByTestId('listener-address'), { target: { value: 'external-address' } });
218
+ expect(onChange.mock.lastCall[0].networkTier).toBeUndefined();
92
219
  });
93
220
  });
94
221
  });
222
+
223
+ function optionValues(select: HTMLElement): string[] {
224
+ return within(select)
225
+ .getAllByRole('option')
226
+ .map((option) => (option as HTMLOptionElement).value);
227
+ }