Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ContainerApp] Added support for Cloud Patching #7571

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions src/containerapp/HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Release History
===============
upcoming
++++++
* 'az containerapp patch': Support Cloud Patch scenario in North Central US Staging region

0.3.51
++++++
Expand Down
105 changes: 105 additions & 0 deletions src/containerapp/azext_containerapp/_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,111 @@ def list_auth_token(cls, cmd, builder_name, build_name, resource_group_name, loc
return r.json()


class PatchClient():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be PatchPreviewClient?

api_version = PREVIEW_API_VERSION

@classmethod
def list(cls, cmd, resource_group_name, container_app_name):
management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager
sub_id = get_subscription_id(cmd.cli_ctx)
url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/containerApps/{}/patches?api-version={}"
request_url = url_fmt.format(
management_hostname.strip('/'),
sub_id,
resource_group_name,
container_app_name,
cls.api_version)
r = send_raw_request(cmd.cli_ctx, "GET", request_url)
return r.json()

@classmethod
def show(cls, cmd, resource_group_name, container_app_name, patch_name):
management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager
sub_id = get_subscription_id(cmd.cli_ctx)
url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/containerApps/{}/patches/{}?api-version={}"
request_url = url_fmt.format(
management_hostname.strip('/'),
sub_id,
resource_group_name,
container_app_name,
patch_name,
cls.api_version)
r = send_raw_request(cmd.cli_ctx, "GET", request_url)
return r.json()

@classmethod
def apply(cls, cmd, resource_group_name, container_app_name, patch_name):
management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager
sub_id = get_subscription_id(cmd.cli_ctx)
url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/containerApps/{}/patches/{}/apply?api-version={}"
request_url = url_fmt.format(
management_hostname.strip('/'),
sub_id,
resource_group_name,
container_app_name,
patch_name,
cls.api_version)
r = send_raw_request(cmd.cli_ctx, "POST", request_url)
if r.status_code == 202 or r.status_code == 201 or r.status_code == 200:
return True
return False

@classmethod
def delete(cls, cmd, resource_group_name, container_app_name, patch_name):
management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager
sub_id = get_subscription_id(cmd.cli_ctx)
url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/containerApps/{}/patches/{}?api-version={}"
request_url = url_fmt.format(
management_hostname.strip('/'),
sub_id,
resource_group_name,
container_app_name,
patch_name,
cls.api_version)
r = send_raw_request(cmd.cli_ctx, "DELETE", request_url)
if r.status_code == 202 or r.status_code == 204 or r.status_code == 200 or r.status_code == 201:
return True
return False

@classmethod
def skip(cls, cmd, resource_group_name, container_app_name, patch_name, skip_config: bool):
management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager
sub_id = get_subscription_id(cmd.cli_ctx)
skip_config = "true" if skip_config else "false"
url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/containerApps/{}/patches/{}/skip?api-version={}?patchSkipConfig=skip:{}"
request_url = url_fmt.format(
management_hostname.strip('/'),
sub_id,
resource_group_name,
container_app_name,
patch_name,
cls.api_version,
skip_config)
r = send_raw_request(cmd.cli_ctx, "POST", request_url)
return r.json()

@classmethod
def patch_mode_configure(cls, cmd, resource_group_name, container_app_name, patch_mode):
management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager
sub_id = get_subscription_id(cmd.cli_ctx)
url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/containerApps/{}?api-version={}"
request_url = url_fmt.format(
management_hostname.strip('/'),
sub_id,
resource_group_name,
container_app_name,
cls.api_version)
body_data = {
"properties": {
"patchingMode": patch_mode
}
}
r = send_raw_request(cmd.cli_ctx, "PATCH", request_url, body=json.dumps(body_data))
if r.status_code == 202 or r.status_code == 204 or r.status_code == 200 or r.status_code == 201:
return True
return False


class JavaComponentPreviewClient():
api_version = PREVIEW_API_VERSION

Expand Down
38 changes: 38 additions & 0 deletions src/containerapp/azext_containerapp/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,9 @@
- name: List patchable and unpatchable container apps by managed environment with the show-all option.
text: |
az containerapp patch list -g MyResourceGroup --environment MyContainerAppEnv --show-all
- name: List available patches for a container app.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Container App

text: |
az containerapp patch list -g MyResourceGroup --container-app-name MyContainerApp
"""

helps['containerapp patch apply'] = """
Expand All @@ -827,6 +830,27 @@
- name: List patchable and unpatchable container apps by managed environment with the show-all option and apply patch for patchable container apps.
text: |
az containerapp patch apply -g MyResourceGroup --environment MyContainerAppEnv --show-all
- name: Apply a patch for a container app by patch name using Cloud Patching.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Container App

text: |
az containerapp patch apply -g MyResourceGroup --container-app-name MyContainerApp --patch-name patch-12345
"""

helps['containerapp patch delete'] = """
type: command
short-summary: Delete a Container App Cloud Patch from a Container App.
examples:
- name: Delete a Cloud Patch.
text: |
az containerapp patch delete -g MyResourceGroup --container-app-name MyContainerApp --patch-name patch-12345
"""

helps['containerapp patch show'] = """
type: command
short-summary: Show the details of a Container App Cloud Patch.
examples:
- name: Show the details of a Cloud Patch.
text: |
az containerapp patch show -g MyResourceGroup --container-app-name MyContainerApp --patch-name patch-12345
"""

helps['containerapp patch interactive'] = """
Expand All @@ -847,6 +871,20 @@
az containerapp patch interactive -g MyResourceGroup --environment MyContainerAppEnv --show-all
"""

helps["containerapp patch configure"] = """
type: group
short-summary: Commands to configure the patching settings for a container app.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace container app by Container App

"""

helps["containerapp patch configure mode"] = """
type: command
short-summary: Configure the patching mode for a container app.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Container App instead of container app

examples:
- name: Configure the patching mode for a container app to be manually patched.
text: |
az containerapp patch configure mode -g MyResourceGroup --container-app-name MyContainerApp --patch-mode Manual
"""

# containerapp create for preview
helps['containerapp create'] = """
type: command
Expand Down
4 changes: 4 additions & 0 deletions src/containerapp/azext_containerapp/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,9 +265,13 @@ def load_arguments(self, _):

# Patch
with self.argument_context('containerapp patch') as c:
c.argument('patch_name', help="Name of the cloud patch for a container app.")
c.argument('container_app_name', help='Name of the container app.')
c.argument('resource_group_name', arg_type=resource_group_name_type)
c.argument('managed_env', options_list=['--environment', '-e'], help='Name or resource id of the Container App environment.')
c.argument('show_all', action='store_true', help='Show all patchable and unpatchable container apps')
c.argument('configure', help='Configure the patch or the patching mode for the container app')
c.argument('patch_mode', help='The patching mode for the container app. Supported modes are: Automatic, Manual, Disabled')

# Container App job
with self.argument_context('containerapp job') as c:
Expand Down
5 changes: 5 additions & 0 deletions src/containerapp/azext_containerapp/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ def load_command_table(self, args):
g.custom_command('list', 'patch_list')
g.custom_command('apply', 'patch_apply')
g.custom_command('interactive', 'patch_interactive')
g.custom_command('delete', 'patch_delete')
g.custom_command('show', 'patch_show')

with self.command_group('containerapp patch configure', is_preview=True) as g:
g.custom_command('mode', 'patch_mode_configure')

if is_cloud_supported_by_connected_env(self.cli_ctx):
with self.command_group('containerapp connected-env', is_preview=True) as g:
Expand Down
123 changes: 121 additions & 2 deletions src/containerapp/azext_containerapp/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@

logger = get_logger(__name__)


def list_all_services(cmd, environment_name, resource_group_name):
services = list_containerapp(cmd, resource_group_name=resource_group_name, managed_env=environment_name)
dev_service_list = []
Expand Down Expand Up @@ -1496,7 +1497,25 @@ def set_workload_profile(cmd, resource_group_name, env_name, workload_profile_na
return update_managed_environment(cmd, env_name, resource_group_name, workload_profile_type=workload_profile_type, workload_profile_name=workload_profile_name, min_nodes=min_nodes, max_nodes=max_nodes)


def patch_list(cmd, resource_group_name=None, managed_env=None, show_all=False):
def patch_list(cmd, resource_group_name=None, managed_env=None, container_app_name=None, show_all=False):
from azure.cli.command_modules.containerapp._utils import format_location
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this import statement to the top of this file

if container_app_name:
# Use cloud patching if container app name is provided
logger.warning("Container app name is provided. List cloud patches available for the container app.")
logger.warning("Cloud patching is only supported in North Central US (Stage) now.")
app = show_containerapp(cmd, container_app_name, resource_group_name)
if app is None:
logger.error("Container app {0} not found in resource group {1}.".format(container_app_name, resource_group_name))
return
if format_location(app["location"]) != format_location("North Central US (Stage)"):
logger.warning("Cloud patching is not supported in the location of the container app. Defaulted back to use local patching.")
logger.warning("Container App name will not be used in local patching.")
else:
from ._clients import PatchClient
patches = PatchClient.list(cmd, resource_group_name, container_app_name)
if patches:
return patches["value"]

# Ensure that Docker is running locally before attempting to use the pack CLI
if is_docker_running() is False:
logger.error("Please install or start Docker and try again.")
Expand Down Expand Up @@ -1552,6 +1571,81 @@ def patch_list(cmd, resource_group_name=None, managed_env=None, show_all=False):
return results


def patch_show(cmd, resource_group_name=None, container_app_name=None, patch_name=None):
from azure.cli.command_modules.containerapp._utils import format_location
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this import statement to the top and re-use across methods.

logger.warning("This command is currently only available for container app cloud patches in North Central US (Stage).")
if patch_name is None:
logger.error("Please provide the name of the patch to show.")
return
try:
app = show_containerapp(cmd, container_app_name, resource_group_name)
except Exception as e:
logger.error("Failed to fetch container app {0} in resource group {1}. Error: {2}".format(container_app_name, resource_group_name, str(e)))
return
if app is None:
logger.error("Container app {0} not found in resource group {1}.".format(container_app_name, resource_group_name))
return
if format_location(app["location"]) != format_location("North Central US (Stage)"):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use safe_get() to get app's location

logger.warning("Cloud patching is not supported in the location of the container app.")
return
from ._clients import PatchClient
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment - Move all imports to the top.

patch_client = PatchClient()
return patch_client.show(cmd, resource_group_name, container_app_name, patch_name)


def patch_delete(cmd, resource_group_name=None, container_app_name=None, patch_name=None):
from azure.cli.command_modules.containerapp._utils import format_location
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment about moving this import statement to the top

logger.warning("This command is currently only available for container app cloud patches in North Central US (Stage).")
if patch_name is None:
logger.error("Please provide the name of the patch to delete.")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should throw a validation error instead of just logging as an error?

return
try:
app = show_containerapp(cmd, container_app_name, resource_group_name)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could throw a not found exception if container app does not exist.

except Exception as e:
logger.error("Failed to fetch container app {0} in resource group {1}. Error: {2}".format(container_app_name, resource_group_name, str(e)))
return
if app is None:
logger.error("Container app {0} not found in resource group {1}.".format(container_app_name, resource_group_name))
return
if format_location(app["location"]) != format_location("North Central US (Stage)"):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use safe_get()

logger.warning("Cloud patching is not supported in the location of the container app.")
return
from ._clients import PatchClient
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comment about import statement

patch_client = PatchClient()
is_patch_deleted = patch_client.delete(cmd, resource_group_name, container_app_name, patch_name)
if is_patch_deleted:
print("Patch {0} for container app {1} is deleted successfully.".format(patch_name, container_app_name))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logger.warning instead of print

else:
logger.error("Failed to delete patch {0} for container app {1}.".format(patch_name, container_app_name))


def patch_mode_configure(cmd, resource_group_name=None, container_app_name=None, patch_mode=None):
from azure.cli.command_modules.containerapp._utils import format_location
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move import

logger.warning("This command is currently only available for container app Cloud Patches in North Central US (Stage).")
try:
app = show_containerapp(cmd, container_app_name, resource_group_name)
except Exception as e:
logger.error("Failed to fetch container app {0} in resource group {1}. Error: {2}".format(container_app_name, resource_group_name, str(e)))
return
if app is None:
logger.error("Container app {0} not found in resource group {1}.".format(container_app_name, resource_group_name))
Copy link
Contributor

@snehapar9 snehapar9 May 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change to Container App

return
if format_location(app["location"]) != format_location("North Central US (Stage)"):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use safe_get instead of just app["location"]

logger.warning("Cloud patching is not supported in the location of the container app.")
return
if patch_mode not in ["Automatic", "Manual", "Disabled"]:
logger.error("Invalid patch mode provided. Please provide 'Automatic', 'Manual', or 'Disabled'.")
return
from ._clients import PatchClient
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move imort

patch_client = PatchClient()
is_patch_mode_configured = patch_client.patch_mode_configure(cmd, resource_group_name, container_app_name, patch_mode)
if is_patch_mode_configured:
print("Patch mode for container app {0} is set to {1}.".format(container_app_name, patch_mode))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logger.warning if we want this to show up on the console.

else:
logger.error("Failed to set patch mode for container app {}.".format(container_app_name))
return


def _get_patchable_check_result(inspect_result, oryx_run_images):
# Define reasons for patchable check failure
failed_reason = "Failed to inspect the image. Please make sure that you are authenticated to the container registry and that the image exists."
Expand Down Expand Up @@ -1642,7 +1736,10 @@ def patch_interactive(cmd, resource_group_name=None, managed_env=None, show_all=
patch_apply_handle_input(cmd, patchable_check_results, user_input, pack_exec_path)


def patch_apply(cmd, resource_group_name=None, managed_env=None, show_all=False):
def patch_apply(cmd, resource_group_name=None, managed_env=None, container_app_name=None, patch_name=None, show_all=False):
if container_app_name:
# Use Cloud Patching if container app name is provided
return use_cloud_patch(cmd, container_app_name, resource_group_name, patch_name)
if is_docker_running() is False:
logger.error("Please install or start Docker and try again.")
return
Expand All @@ -1663,6 +1760,28 @@ def patch_apply(cmd, resource_group_name=None, managed_env=None, show_all=False)
patch_apply_handle_input(cmd, patchable_check_results, "y", pack_exec_path)


def use_cloud_patch(cmd, container_app_name, resource_group_name, patch_name):
try:
app = show_containerapp(cmd, container_app_name, resource_group_name)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this throw a not found exception if container app does not exist? Probably need to handle it separately? by check if not found exception is thrown in a separate method and return None if it is

except Exception as e:
logger.error("Failed to fetch container app {0} in resource group {1}. Error: {2}".format(container_app_name, resource_group_name, str(e)))
return
if app is None:
logger.error("Container app {0} not found in resource group {1}.".format(container_app_name, resource_group_name))
return
if app["location"] == "North Central US (Stage)":
Copy link
Contributor

@snehapar9 snehapar9 May 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use safe_get() here for getting the app's location and let's use STAGE_LOCATION from the constants file instead of hardcoding it here.

logger.warning("Using cloud patching...")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's combine this into a single line.

logger.warning("Cloud patching is only supported in North Central US (Stage) now.")
from ._clients import PatchClient
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move import.

patch_client = PatchClient()
is_patch_success = patch_client.apply(cmd, resource_group_name, container_app_name, patch_name)
if is_patch_success:
print("Patch {0} for container app {1} is queued successfully to be applied.".format(patch_name, container_app_name))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logger.warning if we want this to show up on cx console?

else:
logger.error("Failed to apply patch {0} for container app {1}.".format(patch_name, container_app_name))
return


def patch_apply_handle_input(cmd, patch_check_list, method, pack_exec_path):
input_method = method.strip().lower()
# Track number of times patches were applied successfully.
Expand Down
Loading