Skip to content

Commit 736f925

Browse files
authored
[Spring] Support BYOC in migration command (#8609)
1 parent 8742659 commit 736f925

15 files changed

+255
-98
lines changed

src/spring/HISTORY.md

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
Release History
22
===============
3+
1.27.1
4+
---
5+
* Support scenario of bringing your own container image for command `az spring export`.
6+
37
1.27.0
48
---
59
* Add command `az spring export` which is used to generate target resources definitions to help customer migrating from Azure Spring Apps to other Azure services, such as Azure Container Apps.

src/spring/azext_spring/migration/converter/app_converter.py

+41-4
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ def transform_data():
2121
def transform_data_item(self, app):
2222
blueDeployment = self.wrapper_data.get_blue_deployment_by_app(app)
2323
blueDeployment = self._transform_deployment(blueDeployment)
24+
maxReplicas = blueDeployment.get("capacity", 5) if blueDeployment is not None else 5
2425
greenDeployment = self.wrapper_data.get_green_deployment_by_app(app)
2526
greenDeployment = self._transform_deployment(greenDeployment)
2627
if self.wrapper_data.is_support_blue_green_deployment(app):
2728
logger.warning(f"Action Needed: you should manually deploy the deployment '{greenDeployment.get('name')}' of app '{app.get('name')}' in Azure Container Apps.")
28-
tier = blueDeployment.get('sku', {}).get('tier')
29+
tier = blueDeployment.get('sku', {}).get('tier') if blueDeployment is not None else 'Standard'
2930
serviceBinds = self._get_service_bind(app)
3031
ingress = self._get_ingress(app, tier)
3132
isPublic = app['properties'].get('public')
@@ -46,14 +47,20 @@ def transform_data_item(self, app):
4647
"mountOptions": self._get_mount_options(disk_props),
4748
})
4849
return {
50+
"isByoc": self.wrapper_data.is_support_custom_container_image_for_app(app),
51+
"isPrivateImage": self.wrapper_data.is_private_custom_container_image(app),
52+
"paramContainerAppImagePassword": self._get_param_name_of_container_image_password(app),
53+
"containerRegistry": self._get_container_registry(app),
54+
"args": self._get_args(app),
55+
"commands": self._get_commands(app),
4956
"containerAppName": self._get_resource_name(app),
5057
"paramContainerAppImageName": self._get_param_name_of_container_image(app),
5158
"paramTargetPort": self._get_param_name_of_target_port(app),
5259
"moduleName": self._get_app_module_name(app),
5360
"ingress": ingress,
5461
"isPublic": isPublic,
5562
"minReplicas": 1,
56-
"maxReplicas": blueDeployment.get("capacity", 5),
63+
"maxReplicas": maxReplicas,
5764
"serviceBinds": serviceBinds,
5865
"blue": blueDeployment,
5966
"green": greenDeployment,
@@ -99,7 +106,7 @@ def _get_service_bind(self, app):
99106
return service_bind
100107

101108
def _transform_deployment(self, deployment):
102-
if deployment is None or deployment == {}:
109+
if deployment is None:
103110
return
104111
env = deployment.get('properties', {}).get('deploymentSettings', {}).get('environmentVariables', {})
105112
liveness_probe = deployment.get('properties', {}).get('deploymentSettings', {}).get('livenessProbe', {})
@@ -233,9 +240,15 @@ def _get_ingress(self, app, tier):
233240
ingress = app['properties'].get('ingressSettings')
234241
if ingress is None:
235242
return None
243+
transport = ingress.get('backendProtocol')
244+
if transport == "Default":
245+
transport = "auto"
246+
else:
247+
transport = "auto"
248+
logger.warning(f"Mismatch: The backendProtocol '{transport}' of app '{app.get('name')}' is not supported in Azure Container Apps. Converting it to 'auto'.")
236249
return {
237250
"targetPort": 8080 if tier == "Enterprise" else 1025,
238-
"transport": ingress.get('backendProtocol').replace("Default", "auto"),
251+
"transport": transport,
239252
"sessionAffinity": ingress.get('sessionAffinity').replace("Cookie", "sticky").replace("None", "none")
240253
}
241254

@@ -245,3 +258,27 @@ def _convert_scale(self, scale):
245258
"maxReplicas": scale.get("maxReplicas", 5),
246259
"rules": scale.get("rules", [])
247260
}
261+
262+
def _get_container_registry(self, app):
263+
blueDeployment = self.wrapper_data.get_blue_deployment_by_app(app)
264+
if blueDeployment is not None and self.wrapper_data.is_support_custom_container_image_for_deployment(blueDeployment):
265+
server = blueDeployment['properties']['source'].get('customContainer').get('server', None)
266+
username = blueDeployment['properties']['source'].get('customContainer').get('imageRegistryCredential', {}).get('username', None)
267+
passwordSecretRefPerfix = server.replace(".", "")
268+
passwordSecretRef = f"{passwordSecretRefPerfix}-{username}"
269+
return {
270+
"server": server,
271+
"username": username,
272+
"passwordSecretRef": passwordSecretRef,
273+
"image": self._get_container_image(app),
274+
}
275+
276+
def _get_args(self, app):
277+
blueDeployment = self.wrapper_data.get_blue_deployment_by_app(app)
278+
if blueDeployment is not None and self.wrapper_data.is_support_custom_container_image_for_deployment(blueDeployment):
279+
return blueDeployment['properties']['source'].get('customContainer').get('args', [])
280+
281+
def _get_commands(self, app):
282+
blueDeployment = self.wrapper_data.get_blue_deployment_by_app(app)
283+
if blueDeployment is not None and self.wrapper_data.is_support_custom_container_image_for_deployment(blueDeployment):
284+
return blueDeployment['properties']['source'].get('customContainer').get('command', [])

src/spring/azext_spring/migration/converter/base_converter.py

+63-4
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ def _get_storage_unique_name(self, disk_props):
117117
access_mode = self._get_storage_access_mode(disk_props)
118118
storage_unique_name = f"{storage_name}|{account_name}|{share_name}|{mount_path}|{access_mode}"
119119
hash_value = hashlib.md5(storage_unique_name.encode()).hexdigest()[:16] # Take first 16 chars of hash
120-
result = f"{storage_name}{hash_value}"
120+
result = f"{storage_name}{hash_value}".replace("-", "").replace("_", "")
121121
return result[:32] # Ensure total length is no more than 32
122122

123123
def _get_mount_options(self, disk_props):
@@ -134,6 +134,39 @@ def _get_storage_enable_subpath(self, disk_props):
134134
enableSubPath = disk_props.get('customPersistentDiskProperties', False).get('enableSubPath', False)
135135
return enableSubPath
136136

137+
def _get_app_storage_configs(self):
138+
storage_configs = []
139+
apps = self.wrapper_data.get_apps()
140+
for app in apps:
141+
# Check if app has properties and customPersistentDiskProperties
142+
if 'properties' in app and 'customPersistentDisks' in app['properties']:
143+
disks = app['properties'].get('customPersistentDisks', [])
144+
for disk_props in disks:
145+
if self._get_storage_enable_subpath(disk_props) is True:
146+
logger.warning("Mismatch: enableSubPath of custom persistent disks is not supported in Azure Container Apps.")
147+
# print("storage_name + account_name + share_name + mount_path + access_mode:", storage_name + account_name + share_name + mountPath + access_mode)
148+
storage_config = {
149+
'paramContainerAppEnvStorageAccountKey': self._get_param_name_of_storage_account_key(disk_props),
150+
'storageName': self._get_storage_unique_name(disk_props),
151+
'shareName': self._get_storage_share_name(disk_props),
152+
'accessMode': self._get_storage_access_mode(disk_props),
153+
'accountName': self._get_storage_account_name(disk_props),
154+
}
155+
if storage_config not in storage_configs:
156+
storage_configs.append(storage_config)
157+
return storage_configs
158+
159+
# app
160+
def _get_container_image(self, app):
161+
blueDeployment = self.wrapper_data.get_blue_deployment_by_app(app)
162+
if blueDeployment is not None:
163+
if self.wrapper_data.is_support_custom_container_image_for_app(app):
164+
server = blueDeployment['properties']['source'].get('customContainer').get('server', '')
165+
containerImage = blueDeployment['properties']['source'].get('customContainer').get('containerImage', '')
166+
return f"{server}/{containerImage}"
167+
else:
168+
return None
169+
137170
# module name
138171
def _get_app_module_name(self, app):
139172
appName = self._get_resource_name(app)
@@ -159,6 +192,11 @@ def _get_param_name_of_storage_account_key(self, disk_props):
159192
storage_unique_name = self._get_storage_unique_name(disk_props)
160193
return "containerAppEnvStorageAccountKeyOf_" + storage_unique_name
161194

195+
# get param name of paramContainerAppImagePassword
196+
def _get_param_name_of_container_image_password(self, app):
197+
appName = self._get_resource_name(app)
198+
return "containerImagePasswordOf_" + appName.replace("-", "_")
199+
162200

163201
class SourceDataWrapper:
164202
def __init__(self, source):
@@ -210,7 +248,7 @@ def is_support_sba(self):
210248
return self.is_support_feature('Microsoft.AppPlatform/Spring/applicationLiveViews')
211249

212250
def is_support_gateway(self):
213-
return self.is_support_feature('Microsoft.AppPlatform/Spring/gateways/routeConfigs')
251+
return self.is_support_feature('Microsoft.AppPlatform/Spring/gateways')
214252

215253
def get_asa_service(self):
216254
return self.get_resources_by_type('Microsoft.AppPlatform/Spring')[0]
@@ -228,12 +266,12 @@ def get_deployments_by_app(self, app):
228266
def get_blue_deployment_by_app(self, app):
229267
deployments = self.get_deployments_by_app(app)
230268
deployments = [deployment for deployment in deployments if deployment['properties']['active'] is True]
231-
return deployments[0] if deployments else {}
269+
return deployments[0] if deployments else None
232270

233271
def get_green_deployment_by_app(self, app):
234272
deployments = self.get_deployments_by_app(app)
235273
deployments = [deployment for deployment in deployments if deployment['properties']['active'] is False]
236-
return deployments[0] if deployments else {}
274+
return deployments[0] if deployments else None
237275

238276
def get_green_deployments(self):
239277
deployments = self.get_deployments()
@@ -295,3 +333,24 @@ def is_enabled_system_assigned_identity_for_app(self, app):
295333
if identity is None:
296334
return False
297335
return identity.get('type') == 'SystemAssigned'
336+
337+
def is_support_custom_container_image_for_deployment(self, deployment):
338+
if deployment is None:
339+
return False
340+
return deployment['properties'].get('source') is not None and \
341+
deployment['properties']['source'].get('customContainer') is not None and \
342+
deployment['properties']['source'].get('type') == 'Container' and \
343+
deployment['properties']['source']['customContainer'].get('containerImage') is not None
344+
345+
def is_support_custom_container_image_for_app(self, app):
346+
blueDeployment = self.get_blue_deployment_by_app(app)
347+
if blueDeployment is None:
348+
return False
349+
return self.is_support_custom_container_image_for_deployment(blueDeployment)
350+
351+
def is_private_custom_container_image(self, app):
352+
blueDeployment = self.get_blue_deployment_by_app(app)
353+
if blueDeployment is None:
354+
return False
355+
if self.is_support_custom_container_image_for_app(app):
356+
return blueDeployment['properties']['source'].get('customContainer').get('imageRegistryCredential', {}).get('username', None) is not None

src/spring/azext_spring/migration/converter/cert_converter.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def transform_data():
1616
asa_content_certs = self.wrapper_data.get_content_certificates()
1717
for cert in asa_content_certs:
1818
logger.warning(f"Action Needed: The content certificate '{cert['name']}' cannot be exported automatically. Please export it manually.")
19-
return self.wrapper_data.get_certificates()
19+
return self.wrapper_data.get_keyvault_certificates()
2020
super().__init__(source, transform_data)
2121

2222
def transform_data_item(self, cert):

src/spring/azext_spring/migration/converter/environment_converter.py

+2-31
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,11 @@ def __init__(self, source):
1515
def transform_data():
1616
asa_service = self.wrapper_data.get_asa_service()
1717
name = self._get_resource_name(asa_service)
18-
apps = self.wrapper_data.get_apps()
19-
certs = self.wrapper_data.get_certificates()
18+
certs = self.wrapper_data.get_keyvault_certificates()
2019
data = {
2120
"containerAppEnvName": name,
2221
"containerAppLogAnalyticsName": f"log-{name}",
23-
"storages": self._get_app_storage_configs(apps),
22+
"storages": self._get_app_storage_configs(),
2423
}
2524
if self._need_identity(certs):
2625
data["identity"] = {
@@ -57,31 +56,3 @@ def _need_identity(self, certs):
5756
if certs is not None and len(certs) > 0:
5857
return True
5958
return False
60-
61-
def _get_app_storage_configs(self, apps):
62-
storage_configs = []
63-
for app in apps:
64-
# Check if app has properties and customPersistentDiskProperties
65-
if 'properties' in app and 'customPersistentDisks' in app['properties']:
66-
disks = app['properties'].get('customPersistentDisks', [])
67-
for disk_props in disks:
68-
if self._get_storage_enable_subpath(disk_props) is True:
69-
logger.warning("Mismatch: enableSubPath of custom persistent disks is not supported in Azure Container Apps.")
70-
# print("storage_name + account_name + share_name + mount_path + access_mode:", storage_name + account_name + share_name + mountPath + access_mode)
71-
storage_config = {
72-
'containerAppEnvStorageName': self._get_resource_name_of_storage(app, disk_props),
73-
'paramContainerAppEnvStorageAccountKey': self._get_param_name_of_storage_account_key(disk_props),
74-
'storageName': self._get_storage_unique_name(disk_props),
75-
'shareName': self._get_storage_share_name(disk_props),
76-
'accessMode': self._get_storage_access_mode(disk_props),
77-
'accountName': self._get_storage_account_name(disk_props),
78-
}
79-
storage_configs.append(storage_config)
80-
# print("storage_configs:", storage_configs)
81-
return storage_configs
82-
83-
# get resource name of containerAppEnvStorageName
84-
def _get_resource_name_of_storage(self, app, disk_props):
85-
storage_name = self._get_storage_name(disk_props)
86-
app_name = self._get_resource_name(app)
87-
return (app_name + "_" + storage_name).replace("-", "_")

src/spring/azext_spring/migration/converter/main_converter.py

+5-11
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ class MainConverter(BaseConverter):
1010

1111
def __init__(self, source):
1212
def transform_data():
13-
asa_certs = self.wrapper_data.get_certificates()
13+
asa_certs = self.wrapper_data.get_keyvault_certificates()
1414
certs = []
1515
for cert in asa_certs:
1616
certName = self._get_resource_name(cert)
@@ -21,7 +21,6 @@ def transform_data():
2121
"templateName": templateName,
2222
}
2323
certs.append(certData)
24-
storage_configs = []
2524
apps_data = []
2625
apps = self.wrapper_data.get_apps()
2726
for app in apps:
@@ -34,22 +33,17 @@ def transform_data():
3433
"paramContainerAppImageName": self._get_param_name_of_container_image(app),
3534
"paramTargetPort": self._get_param_name_of_target_port(app),
3635
"dependsOns": self._get_depends_on_list(app),
36+
"isByoc": self.wrapper_data.is_support_custom_container_image_for_app(app),
37+
"isPrivateImage": self.wrapper_data.is_private_custom_container_image(app),
38+
"paramContainerAppImagePassword": self._get_param_name_of_container_image_password(app),
3739
}
38-
if 'properties' in app and 'customPersistentDisks' in app['properties']:
39-
disks = app['properties']['customPersistentDisks']
40-
for disk_props in disks:
41-
storage_config = {
42-
'paramContainerAppEnvStorageAccountKey': self._get_param_name_of_storage_account_key(disk_props),
43-
}
44-
storage_configs.append(storage_config)
45-
4640
apps_data.append(appData)
4741

4842
return {
4943
"isVnet": self.wrapper_data.is_vnet(),
5044
"certs": certs,
5145
"apps": apps_data,
52-
"storages": storage_configs,
46+
"storages": self._get_app_storage_configs(),
5347
"gateway": self.wrapper_data.is_support_gateway(),
5448
"config": self.wrapper_data.is_support_configserver(),
5549
"eureka": self.wrapper_data.is_support_eureka(),

src/spring/azext_spring/migration/converter/param_converter.py

+6-10
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,21 @@ class ParamConverter(BaseConverter):
1111
def __init__(self, source):
1212
def transform_data():
1313
apps = self.wrapper_data.get_apps()
14-
storage_configs = []
1514
apps_data = []
1615
for app in apps:
1716
apps_data.append({
1817
"appName": self._get_resource_name(app),
1918
"paramContainerAppImageName": self._get_param_name_of_container_image(app),
2019
"paramTargetPort": self._get_param_name_of_target_port(app),
20+
"isByoc": self.wrapper_data.is_support_custom_container_image_for_app(app),
21+
"isPrivateImage": self.wrapper_data.is_private_custom_container_image(app),
22+
"paramContainerAppImagePassword": self._get_param_name_of_container_image_password(app),
23+
"image": self._get_container_image(app),
2124
})
22-
if 'properties' in app and 'customPersistentDisks' in app['properties']:
23-
disks = app['properties']['customPersistentDisks']
24-
for disk_props in disks:
25-
storage_config = {
26-
'paramContainerAppEnvStorageAccountKey': self._get_param_name_of_storage_account_key(disk_props),
27-
'accountName': self._get_storage_account_name(disk_props),
28-
}
29-
storage_configs.append(storage_config)
25+
3026
return {
3127
"apps": apps_data,
32-
"storages": storage_configs,
28+
"storages": self._get_app_storage_configs(),
3329
"isVnet": self.wrapper_data.is_vnet()
3430
}
3531
super().__init__(source, transform_data)

src/spring/azext_spring/migration/converter/readme_converter.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ def transform_data():
1919

2020
data = {
2121
"isVnet": self.wrapper_data.is_vnet(),
22-
"containerApps": self.wrapper_data.get_container_deployments(),
23-
"buildResultsApps": self.wrapper_data.get_build_results_deployments(),
22+
"containerDeployments": self.wrapper_data.get_container_deployments(),
23+
"buildResultsDeployments": self.wrapper_data.get_build_results_deployments(),
2424
"hasApps": len(apps) > 0,
25+
"isSupportGateway": self.wrapper_data.is_support_gateway(),
2526
"isSupportConfigServer": self.wrapper_data.is_support_configserver(),
2627
"customDomains": self._transform_domains(custom_domains),
2728
"hasCerts": len(keyvault_certs) > 0 or len(content_certs) > 0,

0 commit comments

Comments
 (0)