diff --git a/src/containerapp/HISTORY.rst b/src/containerapp/HISTORY.rst index 2a1a1d2fc27..34788d4681c 100644 --- a/src/containerapp/HISTORY.rst +++ b/src/containerapp/HISTORY.rst @@ -3,6 +3,10 @@ Release History =============== +0.3.3 +++++++ +* Improved 'az containerapp up' handling of environment locations +* Added 'az containerapp env storage' to manage Container App environment file shares 0.3.2 ++++++ diff --git a/src/containerapp/azext_containerapp/_clients.py b/src/containerapp/azext_containerapp/_clients.py index 4362e1d51aa..d217e12be5e 100644 --- a/src/containerapp/azext_containerapp/_clients.py +++ b/src/containerapp/azext_containerapp/_clients.py @@ -14,7 +14,6 @@ logger = get_logger(__name__) -API_VERSION = "2021-03-01" PREVIEW_API_VERSION = "2022-01-01-preview" STABLE_API_VERSION = "2022-03-01" POLLING_TIMEOUT = 60 # how many seconds before exiting @@ -74,7 +73,7 @@ class ContainerAppClient(): @classmethod def create_or_update(cls, cmd, resource_group_name, name, container_app_envelope, no_wait=False): management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager - api_version = PREVIEW_API_VERSION + api_version = STABLE_API_VERSION sub_id = get_subscription_id(cmd.cli_ctx) url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/containerApps/{}?api-version={}" request_url = url_fmt.format( diff --git a/src/containerapp/azext_containerapp/_constants.py b/src/containerapp/azext_containerapp/_constants.py index d6852056fce..62e655754b0 100644 --- a/src/containerapp/azext_containerapp/_constants.py +++ b/src/containerapp/azext_containerapp/_constants.py @@ -8,3 +8,7 @@ SHORT_POLLING_INTERVAL_SECS = 3 LONG_POLLING_INTERVAL_SECS = 10 + +LOG_ANALYTICS_RP = "Microsoft.OperationalInsights" + +MAX_ENV_PER_LOCATION = 2 diff --git a/src/containerapp/azext_containerapp/_ssh_utils.py b/src/containerapp/azext_containerapp/_ssh_utils.py index 057216a321f..c37ad199f75 100644 --- a/src/containerapp/azext_containerapp/_ssh_utils.py +++ b/src/containerapp/azext_containerapp/_ssh_utils.py @@ -58,7 +58,7 @@ def __init__(self, cmd, resource_group_name, name, revision, replica, container, self._url = self._get_url(cmd=cmd, resource_group_name=resource_group_name, name=name, revision=revision, replica=replica, container=container, startup_command=startup_command) self._socket = websocket.WebSocket(enable_multithread=True) - logger.warning("Attempting to connect to %s", self._url) + logger.info("Attempting to connect to %s", self._url) self._socket.connect(self._url, header=[f"Authorization: Bearer {self._token}"]) self.is_connected = True @@ -160,9 +160,12 @@ def _getch_windows(): def ping_container_app(app): site = safe_get(app, "properties", "configuration", "ingress", "fqdn") if site: - resp = requests.get(f'https://{site}') - if not resp.ok: - logger.info(f"Got bad status pinging app: {resp.status_code}") + try: + resp = requests.get(f'https://{site}', timeout=30) + if not resp.ok: + logger.info(f"Got bad status pinging app: {resp.status_code}") + except requests.exceptions.ReadTimeout: + logger.info("Timed out while pinging app external URL") else: logger.info("Could not fetch site external URL") diff --git a/src/containerapp/azext_containerapp/_up_utils.py b/src/containerapp/azext_containerapp/_up_utils.py index 013baabc681..fae718932c0 100644 --- a/src/containerapp/azext_containerapp/_up_utils.py +++ b/src/containerapp/azext_containerapp/_up_utils.py @@ -41,10 +41,13 @@ create_service_principal_for_rbac, repo_url_to_name, get_container_app_if_exists, - trigger_workflow + trigger_workflow, + _ensure_location_allowed, + _is_resource_provider_registered, + _register_resource_provider ) -from ._constants import MAXIMUM_SECRET_LENGTH +from ._constants import MAXIMUM_SECRET_LENGTH, LOG_ANALYTICS_RP from .custom import ( create_managed_environment, @@ -62,6 +65,8 @@ def __init__(self, cmd, name: str, location: str, exists: bool = None): self.cmd = cmd self.name = name self.location = _get_default_containerapps_location(cmd, location) + if self.location.lower() == "northcentralusstage": + self.location = "eastus" self.exists = exists self.check_exists() @@ -151,7 +156,7 @@ def __init__( rg = parse_resource_id(name)["resource_group"] if resource_group.name != rg: self.resource_group = ResourceGroup(cmd, rg, location) - self.location = _get_default_containerapps_location(cmd, location) + self.location = location self.logs_key = logs_key self.logs_customer_id = logs_customer_id @@ -164,7 +169,7 @@ def set_name(self, name_or_rid): self.resource_group = ResourceGroup( self.cmd, rg, - _get_default_containerapps_location(self.cmd, self.location), + self.location, ) else: self.name = name_or_rid @@ -188,6 +193,9 @@ def create_if_needed(self, app_name): ) # TODO use .info() def create(self): + self.location = validate_environment_location(self.cmd, self.location) + if not _is_resource_provider_registered(self.cmd, LOG_ANALYTICS_RP): + _register_resource_provider(self.cmd, LOG_ANALYTICS_RP) env = create_managed_environment( self.cmd, self.name, @@ -290,8 +298,11 @@ def create_acr(self): registry_rg = self.resource_group url = self.registry_server registry_name = url[: url.rindex(".azurecr.io")] + location = "eastus" + if self.env.location and self.env.location.lower() != "northcentralusstage": + location = self.env.location registry_def = create_new_acr( - self.cmd, registry_name, registry_rg.name, self.env.location + self.cmd, registry_name, registry_rg.name, location ) self.registry_server = registry_def.login_server @@ -435,7 +446,13 @@ def _get_ingress_and_target_port(ingress, target_port, dockerfile_content: "list return ingress, target_port -def _validate_up_args(source, image, repo, registry_server): +def _validate_up_args(cmd, source, image, repo, registry_server): + disallowed_params = ["--only-show-errors", "--output", "-o"] + command_args = cmd.cli_ctx.data.get("safe_params", []) + for a in disallowed_params: + if a in command_args: + raise ValidationError(f"Argument {a} is not allowed for 'az containerapp up'") + if not source and not image and not repo: raise RequiredArgumentMissingError( "You must specify either --source, --repo, or --image" @@ -782,3 +799,69 @@ def find_existing_acr(cmd, app: "ContainerApp"): app.should_create_acr = False return acr.name, parse_resource_id(acr.id)["resource_group"] return None, None + + +def validate_environment_location(cmd, location): + from ._constants import MAX_ENV_PER_LOCATION + env_list = list_managed_environments(cmd) + + locations = [l["location"] for l in env_list] + locations = list(set(locations)) # remove duplicates + + location_count = {} + for loc in locations: + location_count[loc] = len([e for e in env_list if e["location"] == loc]) + + disallowed_locations = [] + for _, value in enumerate(location_count): + if location_count[value] > MAX_ENV_PER_LOCATION - 1: + disallowed_locations.append(value) + + res_locations = list_environment_locations(cmd) + res_locations = [l for l in res_locations if l not in disallowed_locations] + + allowed_locs = ", ".join(res_locations) + + if location: + try: + _ensure_location_allowed(cmd, location, "Microsoft.App", "managedEnvironments") + except Exception: # pylint: disable=broad-except + raise ValidationError("You cannot create a Containerapp environment in location {}. List of eligible locations: {}.".format(location, allowed_locs)) + + if len(res_locations) > 0: + if not location: + logger.warning("Creating environment on location {}.".format(res_locations[0])) + return res_locations[0] + if location in disallowed_locations: + raise ValidationError("You have more than {} environments in location {}. List of eligible locations: {}.".format(MAX_ENV_PER_LOCATION, location, allowed_locs)) + return location + else: + raise ValidationError("You cannot create any more environments. Environments are limited to {} per location in a subscription. Please specify an existing environment using --environment.".format(MAX_ENV_PER_LOCATION)) + + +def list_environment_locations(cmd): + from ._utils import providers_client_factory + providers_client = providers_client_factory(cmd.cli_ctx, get_subscription_id(cmd.cli_ctx)) + resource_types = getattr(providers_client.get("Microsoft.App"), 'resource_types', []) + res_locations = [] + for res in resource_types: + if res and getattr(res, 'resource_type', "") == "managedEnvironments": + res_locations = getattr(res, 'locations', []) + + res_locations = [res_loc.lower().replace(" ", "").replace("(", "").replace(")", "") for res_loc in res_locations if res_loc.strip()] + + return res_locations + + +def check_env_name_on_rg(cmd, managed_env, resource_group_name, location): + if location: + _ensure_location_allowed(cmd, location, "Microsoft.App", "managedEnvironments") + if managed_env and resource_group_name and location: + env_def = None + try: + env_def = ManagedEnvironmentClient.show(cmd, resource_group_name, parse_resource_id(managed_env)["name"]) + except: + pass + if env_def: + if location != env_def["location"]: + raise ValidationError("Environment {} already exists in resource group {} on location {}, cannot change location of existing environment to {}.".format(parse_resource_id(managed_env)["name"], resource_group_name, env_def["location"], location)) diff --git a/src/containerapp/azext_containerapp/_utils.py b/src/containerapp/azext_containerapp/_utils.py index 06bb2054276..eb44906c87e 100644 --- a/src/containerapp/azext_containerapp/_utils.py +++ b/src/containerapp/azext_containerapp/_utils.py @@ -19,7 +19,8 @@ from ._clients import ContainerAppClient from ._client_factory import handle_raw_exception, providers_client_factory, cf_resource_groups, log_analytics_client_factory, log_analytics_shared_key_client_factory -from ._constants import MAXIMUM_CONTAINER_APP_NAME_LENGTH, SHORT_POLLING_INTERVAL_SECS, LONG_POLLING_INTERVAL_SECS +from ._constants import (MAXIMUM_CONTAINER_APP_NAME_LENGTH, SHORT_POLLING_INTERVAL_SECS, LONG_POLLING_INTERVAL_SECS, + LOG_ANALYTICS_RP) logger = get_logger(__name__) @@ -177,8 +178,9 @@ def get_workflow(github_repo, name): # pylint: disable=inconsistent-return-stat def trigger_workflow(token, repo, name, branch): - logger.warning("Triggering Github Action") - get_workflow(get_github_repo(token, repo), name).create_dispatch(branch) + wf = get_workflow(get_github_repo(token, repo), name) + logger.warning(f"Triggering Github Action: {wf.path}") + wf.create_dispatch(branch) def await_github_action(cmd, token, repo, branch, name, resource_group_name, timeout_secs=1200): @@ -254,22 +256,56 @@ def _get_location_from_resource_group(cli_ctx, resource_group_name): return group.location -def _validate_subscription_registered(cmd, resource_provider, subscription_id=None): - providers_client = None +def _register_resource_provider(cmd, resource_provider): + from azure.mgmt.resource.resources.models import ProviderRegistrationRequest, ProviderConsentDefinition + + logger.warning(f"Registering resource provider {resource_provider} ...") + properties = ProviderRegistrationRequest(third_party_provider_consent=ProviderConsentDefinition(consent_to_authorization=True)) + + client = providers_client_factory(cmd.cli_ctx) + try: + client.register(resource_provider, properties=properties) + # wait for registration to finish + timeout_secs = 120 + registration = _is_resource_provider_registered(cmd, resource_provider) + start = datetime.utcnow() + while not registration: + registration = _is_resource_provider_registered(cmd, resource_provider) + time.sleep(SHORT_POLLING_INTERVAL_SECS) + if (datetime.utcnow() - start).seconds >= timeout_secs: + raise CLIInternalError(f"Timed out while waiting for the {resource_provider} resource provider to be registered.") + + except Exception as e: + msg = ("This operation requires requires registering the resource provider {0}. " + "We were unable to perform that registration on your behalf: " + "Server responded with error message -- {1} . " + "Please check with your admin on permissions, " + "or try running registration manually with: az provider register --wait --namespace {0}") + raise ValidationError(resource_provider, msg.format(e.args)) from e + + +def _is_resource_provider_registered(cmd, resource_provider, subscription_id=None): + registered = None if not subscription_id: subscription_id = get_subscription_id(cmd.cli_ctx) - try: providers_client = providers_client_factory(cmd.cli_ctx, subscription_id) registration_state = getattr(providers_client.get(resource_provider), 'registration_state', "NotRegistered") - if not (registration_state and registration_state.lower() == 'registered'): - raise ValidationError('Subscription {} is not registered for the {} resource provider. Please run \"az provider register -n {} --wait\" to register your subscription.'.format( - subscription_id, resource_provider, resource_provider)) - except ValidationError as ex: - raise ex + registered = (registration_state and registration_state.lower() == 'registered') except Exception: # pylint: disable=broad-except pass + return registered + + +def _validate_subscription_registered(cmd, resource_provider, subscription_id=None): + if not subscription_id: + subscription_id = get_subscription_id(cmd.cli_ctx) + registered = _is_resource_provider_registered(cmd, resource_provider, subscription_id) + if registered is False: + raise ValidationError(f'Subscription {subscription_id} is not registered for the {resource_provider} ' + f'resource provider. Please run "az provider register -n {resource_provider} --wait" ' + 'to register your subscription.') def _ensure_location_allowed(cmd, location, resource_provider, resource_type): @@ -421,7 +457,7 @@ def _get_default_log_analytics_location(cmd): providers_client = None try: providers_client = providers_client_factory(cmd.cli_ctx, get_subscription_id(cmd.cli_ctx)) - resource_types = getattr(providers_client.get("Microsoft.OperationalInsights"), 'resource_types', []) + resource_types = getattr(providers_client.get(LOG_ANALYTICS_RP), 'resource_types', []) res_locations = [] for res in resource_types: if res and getattr(res, 'resource_type', "") == "workspaces": @@ -514,14 +550,14 @@ def _get_log_analytics_workspace_name(cmd, logs_customer_id, resource_group_name def _generate_log_analytics_if_not_provided(cmd, logs_customer_id, logs_key, location, resource_group_name): if logs_customer_id is None and logs_key is None: logger.warning("No Log Analytics workspace provided.") + _validate_subscription_registered(cmd, LOG_ANALYTICS_RP) try: - _validate_subscription_registered(cmd, "Microsoft.OperationalInsights") log_analytics_client = log_analytics_client_factory(cmd.cli_ctx) log_analytics_shared_key_client = log_analytics_shared_key_client_factory(cmd.cli_ctx) log_analytics_location = location try: - _ensure_location_allowed(cmd, log_analytics_location, "Microsoft.OperationalInsights", "workspaces") + _ensure_location_allowed(cmd, log_analytics_location, LOG_ANALYTICS_RP, "workspaces") except Exception: # pylint: disable=broad-except log_analytics_location = _get_default_log_analytics_location(cmd) @@ -819,8 +855,9 @@ def _update_traffic_weights(containerapp_def, list_weights): if not is_existing: containerapp_def["properties"]["configuration"]["ingress"]["traffic"].append({ - "revisionName": key_val[0], - "weight": int(key_val[1]) + "revisionName": key_val[0] if key_val[0].lower() != "latest" else None, + "weight": int(key_val[1]), + "latestRevision": key_val[0].lower() == "latest" }) diff --git a/src/containerapp/azext_containerapp/custom.py b/src/containerapp/azext_containerapp/custom.py index ce52bd8e5d8..4010086bbe8 100644 --- a/src/containerapp/azext_containerapp/custom.py +++ b/src/containerapp/azext_containerapp/custom.py @@ -1964,7 +1964,7 @@ def stream_containerapp_logs(cmd, resource_group_name, name, container=None, rev url = (f"{base_url}/subscriptions/{sub}/resourceGroups/{resource_group_name}/containerApps/{name}" f"/revisions/{revision}/replicas/{replica}/containers/{container}/logstream") - logger.warning("connecting to : %s", url) + logger.info("connecting to : %s", url) request_params = {"follow": str(follow).lower(), "output": output_format, "tailLines": tail} headers = {"Authorization": f"Bearer {token}"} resp = requests.get(url, timeout=None, stream=True, params=request_params, headers=headers) @@ -2015,12 +2015,14 @@ def containerapp_up(cmd, service_principal_tenant_id=None): from ._up_utils import (_validate_up_args, _reformat_image, _get_dockerfile_content, _get_ingress_and_target_port, ResourceGroup, ContainerAppEnvironment, ContainerApp, _get_registry_from_app, - _get_registry_details, _create_github_action, _set_up_defaults, up_output, AzureContainerRegistry) + _get_registry_details, _create_github_action, _set_up_defaults, up_output, AzureContainerRegistry, + check_env_name_on_rg) HELLOWORLD = "mcr.microsoft.com/azuredocs/containerapps-helloworld" dockerfile = "Dockerfile" # for now the dockerfile name must be "Dockerfile" (until GH actions API is updated) - _validate_up_args(source, image, repo, registry_server) + _validate_up_args(cmd, source, image, repo, registry_server) validate_container_app_name(name) + check_env_name_on_rg(cmd, managed_env, resource_group_name, location) image = _reformat_image(source, repo, image) token = None if not repo else get_github_access_token(cmd, ["admin:repo_hook", "repo", "workflow"], token) diff --git a/src/containerapp/azext_containerapp/tests/latest/test_containerapp_scenario.py b/src/containerapp/azext_containerapp/tests/latest/test_containerapp_scenario.py index f8b81030eeb..c438c4c53ba 100644 --- a/src/containerapp/azext_containerapp/tests/latest/test_containerapp_scenario.py +++ b/src/containerapp/azext_containerapp/tests/latest/test_containerapp_scenario.py @@ -7,6 +7,7 @@ import platform from unittest import mock import time +import unittest from azext_containerapp.custom import containerapp_ssh from azure.cli.testsdk.reverse_dependency import get_dummy_cli @@ -18,7 +19,7 @@ TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..')) -@live_only() +@live_only() # Containerapp tests can only be run live due to log analytics name being randomly generated every time class ContainerappScenarioTest(ScenarioTest): @AllowLargeResponse(8192) @ResourceGroupPreparer(location="eastus2") @@ -218,6 +219,30 @@ def test_container_acr(self, resource_group): JMESPathCheck('length(properties.configuration.secrets)', 1), ]) + # Update Container App with ACR + update_string = 'containerapp update -g {} -n {} --min-replicas 0 --max-replicas 1 --set-env-vars testenv=testing'.format( + resource_group, containerapp_name) + self.cmd(update_string, checks=[ + JMESPathCheck('name', containerapp_name), + JMESPathCheck('properties.configuration.registries[0].server', registry_server), + JMESPathCheck('properties.configuration.registries[0].username', registry_username), + JMESPathCheck('length(properties.configuration.secrets)', 1), + JMESPathCheck('properties.template.scale.minReplicas', '0'), + JMESPathCheck('properties.template.scale.maxReplicas', '1'), + JMESPathCheck('length(properties.template.containers[0].env)', 1), + ]) + + # Add secrets to Container App with ACR + containerapp_secret = self.cmd('containerapp secret list -g {} -n {}'.format(resource_group, containerapp_name)).get_output_in_json() + secret_name = containerapp_secret[0]["name"] + secret_string = 'containerapp secret set -g {} -n {} --secrets newsecret=test'.format(resource_group, containerapp_name) + self.cmd(secret_string, checks=[ + JMESPathCheck('length(@)', 2), + ]) + + with self.assertRaises(CLIError): + # Removing ACR password should fail since it is needed for ACR + self.cmd('containerapp secret remove -g {} -n {} --secret-names {}'.format(resource_group, containerapp_name, secret_name)) @AllowLargeResponse(8192) @ResourceGroupPreparer(location="eastus") @@ -286,6 +311,7 @@ def test_containerapp_update(self, resource_group): JMESPathCheck('properties.template.containers[1].resources.memory', '1.5Gi'), ]) + @unittest.skip("API only on stage currently") @live_only() # VCR.py can't seem to handle websockets (only --live works) # @ResourceGroupPreparer(location="centraluseuap") @mock.patch("azext_containerapp._ssh_utils._resize_terminal") @@ -358,4 +384,4 @@ def test_containerapp_logstream(self, resource_group): self.cmd(f'containerapp env create -g {resource_group} -n {env_name}') self.cmd(f'containerapp create -g {resource_group} -n {containerapp_name} --environment {env_name} --min-replicas 1 --ingress external --target-port 80') - self.cmd(f'containerapp log tail -n {containerapp_name} -g {resource_group}') + self.cmd(f'containerapp logs show -n {containerapp_name} -g {resource_group}') diff --git a/src/containerapp/setup.py b/src/containerapp/setup.py index d0f615849f3..ca2dd65ff30 100644 --- a/src/containerapp/setup.py +++ b/src/containerapp/setup.py @@ -17,7 +17,7 @@ # TODO: Confirm this is the right version number you want and it matches your # HISTORY.rst entry. -VERSION = '0.3.2' +VERSION = '0.3.3' # The full list of classifiers is available at