Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/spring/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
Release History
===============
1.27.1
---
* Support scenario of bringing your own container image for command `az spring export`.

1.27.0
---
* 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.
Expand Down
45 changes: 41 additions & 4 deletions src/spring/azext_spring/migration/converter/app_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ def transform_data():
def transform_data_item(self, app):
blueDeployment = self.wrapper_data.get_blue_deployment_by_app(app)
blueDeployment = self._transform_deployment(blueDeployment)
maxReplicas = blueDeployment.get("capacity", 5) if blueDeployment is not None else 5
greenDeployment = self.wrapper_data.get_green_deployment_by_app(app)
greenDeployment = self._transform_deployment(greenDeployment)
if self.wrapper_data.is_support_blue_green_deployment(app):
logger.warning(f"Action Needed: you should manually deploy the deployment '{greenDeployment.get('name')}' of app '{app.get('name')}' in Azure Container Apps.")
tier = blueDeployment.get('sku', {}).get('tier')
tier = blueDeployment.get('sku', {}).get('tier') if blueDeployment is not None else 'Standard'
serviceBinds = self._get_service_bind(app)
ingress = self._get_ingress(app, tier)
isPublic = app['properties'].get('public')
Expand All @@ -46,14 +47,20 @@ def transform_data_item(self, app):
"mountOptions": self._get_mount_options(disk_props),
})
return {
"isByoc": self.wrapper_data.is_support_custom_container_image_for_app(app),
"isPrivateImage": self.wrapper_data.is_private_custom_container_image(app),
"paramContainerAppImagePassword": self._get_param_name_of_container_image_password(app),
"containerRegistry": self._get_container_registry(app),
"args": self._get_args(app),
"commands": self._get_commands(app),
"containerAppName": self._get_resource_name(app),
"paramContainerAppImageName": self._get_param_name_of_container_image(app),
"paramTargetPort": self._get_param_name_of_target_port(app),
"moduleName": self._get_app_module_name(app),
"ingress": ingress,
"isPublic": isPublic,
"minReplicas": 1,
"maxReplicas": blueDeployment.get("capacity", 5),
"maxReplicas": maxReplicas,
"serviceBinds": serviceBinds,
"blue": blueDeployment,
"green": greenDeployment,
Expand Down Expand Up @@ -99,7 +106,7 @@ def _get_service_bind(self, app):
return service_bind

def _transform_deployment(self, deployment):
if deployment is None or deployment == {}:
if deployment is None:
return
env = deployment.get('properties', {}).get('deploymentSettings', {}).get('environmentVariables', {})
liveness_probe = deployment.get('properties', {}).get('deploymentSettings', {}).get('livenessProbe', {})
Expand Down Expand Up @@ -233,9 +240,15 @@ def _get_ingress(self, app, tier):
ingress = app['properties'].get('ingressSettings')
if ingress is None:
return None
transport = ingress.get('backendProtocol')
if transport == "Default":
transport = "auto"
else:
transport = "auto"
logger.warning(f"Mismatch: The backendProtocol '{transport}' of app '{app.get('name')}' is not supported in Azure Container Apps. Converting it to 'auto'.")
return {
"targetPort": 8080 if tier == "Enterprise" else 1025,
"transport": ingress.get('backendProtocol').replace("Default", "auto"),
"transport": transport,
"sessionAffinity": ingress.get('sessionAffinity').replace("Cookie", "sticky").replace("None", "none")
}

Expand All @@ -245,3 +258,27 @@ def _convert_scale(self, scale):
"maxReplicas": scale.get("maxReplicas", 5),
"rules": scale.get("rules", [])
}

def _get_container_registry(self, app):
blueDeployment = self.wrapper_data.get_blue_deployment_by_app(app)
if blueDeployment is not None and self.wrapper_data.is_support_custom_container_image_for_deployment(blueDeployment):
server = blueDeployment['properties']['source'].get('customContainer').get('server', None)
username = blueDeployment['properties']['source'].get('customContainer').get('imageRegistryCredential', {}).get('username', None)
passwordSecretRefPerfix = server.replace(".", "")
passwordSecretRef = f"{passwordSecretRefPerfix}-{username}"
return {
"server": server,
"username": username,
"passwordSecretRef": passwordSecretRef,
"image": self._get_container_image(app),
}

def _get_args(self, app):
blueDeployment = self.wrapper_data.get_blue_deployment_by_app(app)
if blueDeployment is not None and self.wrapper_data.is_support_custom_container_image_for_deployment(blueDeployment):
return blueDeployment['properties']['source'].get('customContainer').get('args', [])

def _get_commands(self, app):
blueDeployment = self.wrapper_data.get_blue_deployment_by_app(app)
if blueDeployment is not None and self.wrapper_data.is_support_custom_container_image_for_deployment(blueDeployment):
return blueDeployment['properties']['source'].get('customContainer').get('command', [])
67 changes: 63 additions & 4 deletions src/spring/azext_spring/migration/converter/base_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ def _get_storage_unique_name(self, disk_props):
access_mode = self._get_storage_access_mode(disk_props)
storage_unique_name = f"{storage_name}|{account_name}|{share_name}|{mount_path}|{access_mode}"
hash_value = hashlib.md5(storage_unique_name.encode()).hexdigest()[:16] # Take first 16 chars of hash
result = f"{storage_name}{hash_value}"
result = f"{storage_name}{hash_value}".replace("-", "").replace("_", "")
return result[:32] # Ensure total length is no more than 32

def _get_mount_options(self, disk_props):
Expand All @@ -134,6 +134,39 @@ def _get_storage_enable_subpath(self, disk_props):
enableSubPath = disk_props.get('customPersistentDiskProperties', False).get('enableSubPath', False)
return enableSubPath

def _get_app_storage_configs(self):
storage_configs = []
apps = self.wrapper_data.get_apps()
for app in apps:
# Check if app has properties and customPersistentDiskProperties
if 'properties' in app and 'customPersistentDisks' in app['properties']:
disks = app['properties'].get('customPersistentDisks', [])
for disk_props in disks:
if self._get_storage_enable_subpath(disk_props) is True:
logger.warning("Mismatch: enableSubPath of custom persistent disks is not supported in Azure Container Apps.")
# print("storage_name + account_name + share_name + mount_path + access_mode:", storage_name + account_name + share_name + mountPath + access_mode)
storage_config = {
'paramContainerAppEnvStorageAccountKey': self._get_param_name_of_storage_account_key(disk_props),
'storageName': self._get_storage_unique_name(disk_props),
'shareName': self._get_storage_share_name(disk_props),
'accessMode': self._get_storage_access_mode(disk_props),
'accountName': self._get_storage_account_name(disk_props),
}
if storage_config not in storage_configs:
storage_configs.append(storage_config)
return storage_configs

# app
def _get_container_image(self, app):
blueDeployment = self.wrapper_data.get_blue_deployment_by_app(app)
if blueDeployment is not None:
if self.wrapper_data.is_support_custom_container_image_for_app(app):
server = blueDeployment['properties']['source'].get('customContainer').get('server', '')
containerImage = blueDeployment['properties']['source'].get('customContainer').get('containerImage', '')
return f"{server}/{containerImage}"
else:
return None

# module name
def _get_app_module_name(self, app):
appName = self._get_resource_name(app)
Expand All @@ -159,6 +192,11 @@ def _get_param_name_of_storage_account_key(self, disk_props):
storage_unique_name = self._get_storage_unique_name(disk_props)
return "containerAppEnvStorageAccountKeyOf_" + storage_unique_name

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


class SourceDataWrapper:
def __init__(self, source):
Expand Down Expand Up @@ -210,7 +248,7 @@ def is_support_sba(self):
return self.is_support_feature('Microsoft.AppPlatform/Spring/applicationLiveViews')

def is_support_gateway(self):
return self.is_support_feature('Microsoft.AppPlatform/Spring/gateways/routeConfigs')
return self.is_support_feature('Microsoft.AppPlatform/Spring/gateways')

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

def get_green_deployment_by_app(self, app):
deployments = self.get_deployments_by_app(app)
deployments = [deployment for deployment in deployments if deployment['properties']['active'] is False]
return deployments[0] if deployments else {}
return deployments[0] if deployments else None

def get_green_deployments(self):
deployments = self.get_deployments()
Expand Down Expand Up @@ -295,3 +333,24 @@ def is_enabled_system_assigned_identity_for_app(self, app):
if identity is None:
return False
return identity.get('type') == 'SystemAssigned'

def is_support_custom_container_image_for_deployment(self, deployment):
if deployment is None:
return False
return deployment['properties'].get('source') is not None and \
deployment['properties']['source'].get('customContainer') is not None and \
deployment['properties']['source'].get('type') == 'Container' and \
deployment['properties']['source']['customContainer'].get('containerImage') is not None

def is_support_custom_container_image_for_app(self, app):
blueDeployment = self.get_blue_deployment_by_app(app)
if blueDeployment is None:
return False
return self.is_support_custom_container_image_for_deployment(blueDeployment)

def is_private_custom_container_image(self, app):
blueDeployment = self.get_blue_deployment_by_app(app)
if blueDeployment is None:
return False
if self.is_support_custom_container_image_for_app(app):
return blueDeployment['properties']['source'].get('customContainer').get('imageRegistryCredential', {}).get('username', None) is not None
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def transform_data():
asa_content_certs = self.wrapper_data.get_content_certificates()
for cert in asa_content_certs:
logger.warning(f"Action Needed: The content certificate '{cert['name']}' cannot be exported automatically. Please export it manually.")
return self.wrapper_data.get_certificates()
return self.wrapper_data.get_keyvault_certificates()
super().__init__(source, transform_data)

def transform_data_item(self, cert):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,11 @@ def __init__(self, source):
def transform_data():
asa_service = self.wrapper_data.get_asa_service()
name = self._get_resource_name(asa_service)
apps = self.wrapper_data.get_apps()
certs = self.wrapper_data.get_certificates()
certs = self.wrapper_data.get_keyvault_certificates()
data = {
"containerAppEnvName": name,
"containerAppLogAnalyticsName": f"log-{name}",
"storages": self._get_app_storage_configs(apps),
"storages": self._get_app_storage_configs(),
}
if self._need_identity(certs):
data["identity"] = {
Expand Down Expand Up @@ -57,31 +56,3 @@ def _need_identity(self, certs):
if certs is not None and len(certs) > 0:
return True
return False

def _get_app_storage_configs(self, apps):
storage_configs = []
for app in apps:
# Check if app has properties and customPersistentDiskProperties
if 'properties' in app and 'customPersistentDisks' in app['properties']:
disks = app['properties'].get('customPersistentDisks', [])
for disk_props in disks:
if self._get_storage_enable_subpath(disk_props) is True:
logger.warning("Mismatch: enableSubPath of custom persistent disks is not supported in Azure Container Apps.")
# print("storage_name + account_name + share_name + mount_path + access_mode:", storage_name + account_name + share_name + mountPath + access_mode)
storage_config = {
'containerAppEnvStorageName': self._get_resource_name_of_storage(app, disk_props),
'paramContainerAppEnvStorageAccountKey': self._get_param_name_of_storage_account_key(disk_props),
'storageName': self._get_storage_unique_name(disk_props),
'shareName': self._get_storage_share_name(disk_props),
'accessMode': self._get_storage_access_mode(disk_props),
'accountName': self._get_storage_account_name(disk_props),
}
storage_configs.append(storage_config)
# print("storage_configs:", storage_configs)
return storage_configs

# get resource name of containerAppEnvStorageName
def _get_resource_name_of_storage(self, app, disk_props):
storage_name = self._get_storage_name(disk_props)
app_name = self._get_resource_name(app)
return (app_name + "_" + storage_name).replace("-", "_")
16 changes: 5 additions & 11 deletions src/spring/azext_spring/migration/converter/main_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class MainConverter(BaseConverter):

def __init__(self, source):
def transform_data():
asa_certs = self.wrapper_data.get_certificates()
asa_certs = self.wrapper_data.get_keyvault_certificates()
certs = []
for cert in asa_certs:
certName = self._get_resource_name(cert)
Expand All @@ -21,7 +21,6 @@ def transform_data():
"templateName": templateName,
}
certs.append(certData)
storage_configs = []
apps_data = []
apps = self.wrapper_data.get_apps()
for app in apps:
Expand All @@ -34,22 +33,17 @@ def transform_data():
"paramContainerAppImageName": self._get_param_name_of_container_image(app),
"paramTargetPort": self._get_param_name_of_target_port(app),
"dependsOns": self._get_depends_on_list(app),
"isByoc": self.wrapper_data.is_support_custom_container_image_for_app(app),
"isPrivateImage": self.wrapper_data.is_private_custom_container_image(app),
"paramContainerAppImagePassword": self._get_param_name_of_container_image_password(app),
}
if 'properties' in app and 'customPersistentDisks' in app['properties']:
disks = app['properties']['customPersistentDisks']
for disk_props in disks:
storage_config = {
'paramContainerAppEnvStorageAccountKey': self._get_param_name_of_storage_account_key(disk_props),
}
storage_configs.append(storage_config)

apps_data.append(appData)

return {
"isVnet": self.wrapper_data.is_vnet(),
"certs": certs,
"apps": apps_data,
"storages": storage_configs,
"storages": self._get_app_storage_configs(),
"gateway": self.wrapper_data.is_support_gateway(),
"config": self.wrapper_data.is_support_configserver(),
"eureka": self.wrapper_data.is_support_eureka(),
Expand Down
16 changes: 6 additions & 10 deletions src/spring/azext_spring/migration/converter/param_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,21 @@ class ParamConverter(BaseConverter):
def __init__(self, source):
def transform_data():
apps = self.wrapper_data.get_apps()
storage_configs = []
apps_data = []
for app in apps:
apps_data.append({
"appName": self._get_resource_name(app),
"paramContainerAppImageName": self._get_param_name_of_container_image(app),
"paramTargetPort": self._get_param_name_of_target_port(app),
"isByoc": self.wrapper_data.is_support_custom_container_image_for_app(app),
"isPrivateImage": self.wrapper_data.is_private_custom_container_image(app),
"paramContainerAppImagePassword": self._get_param_name_of_container_image_password(app),
"image": self._get_container_image(app),
})
if 'properties' in app and 'customPersistentDisks' in app['properties']:
disks = app['properties']['customPersistentDisks']
for disk_props in disks:
storage_config = {
'paramContainerAppEnvStorageAccountKey': self._get_param_name_of_storage_account_key(disk_props),
'accountName': self._get_storage_account_name(disk_props),
}
storage_configs.append(storage_config)

return {
"apps": apps_data,
"storages": storage_configs,
"storages": self._get_app_storage_configs(),
"isVnet": self.wrapper_data.is_vnet()
}
super().__init__(source, transform_data)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ def transform_data():

data = {
"isVnet": self.wrapper_data.is_vnet(),
"containerApps": self.wrapper_data.get_container_deployments(),
"buildResultsApps": self.wrapper_data.get_build_results_deployments(),
"containerDeployments": self.wrapper_data.get_container_deployments(),
"buildResultsDeployments": self.wrapper_data.get_build_results_deployments(),
"hasApps": len(apps) > 0,
"isSupportGateway": self.wrapper_data.is_support_gateway(),
"isSupportConfigServer": self.wrapper_data.is_support_configserver(),
"customDomains": self._transform_domains(custom_domains),
"hasCerts": len(keyvault_certs) > 0 or len(content_certs) > 0,
Expand Down
Loading
Loading