Skip to content

Commit f4fdf89

Browse files
authored
Container Apps 5/4 Release (#4757)
1 parent aacce90 commit f4fdf89

9 files changed

Lines changed: 192 additions & 34 deletions

File tree

src/containerapp/HISTORY.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
Release History
44
===============
55

6+
0.3.3
7+
++++++
8+
* Improved 'az containerapp up' handling of environment locations
9+
* Added 'az containerapp env storage' to manage Container App environment file shares
610

711
0.3.2
812
++++++

src/containerapp/azext_containerapp/_clients.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414

1515
logger = get_logger(__name__)
1616

17-
API_VERSION = "2021-03-01"
1817
PREVIEW_API_VERSION = "2022-01-01-preview"
1918
STABLE_API_VERSION = "2022-03-01"
2019
POLLING_TIMEOUT = 60 # how many seconds before exiting
@@ -74,7 +73,7 @@ class ContainerAppClient():
7473
@classmethod
7574
def create_or_update(cls, cmd, resource_group_name, name, container_app_envelope, no_wait=False):
7675
management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager
77-
api_version = PREVIEW_API_VERSION
76+
api_version = STABLE_API_VERSION
7877
sub_id = get_subscription_id(cmd.cli_ctx)
7978
url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/containerApps/{}?api-version={}"
8079
request_url = url_fmt.format(

src/containerapp/azext_containerapp/_constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,7 @@
88

99
SHORT_POLLING_INTERVAL_SECS = 3
1010
LONG_POLLING_INTERVAL_SECS = 10
11+
12+
LOG_ANALYTICS_RP = "Microsoft.OperationalInsights"
13+
14+
MAX_ENV_PER_LOCATION = 2

src/containerapp/azext_containerapp/_ssh_utils.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def __init__(self, cmd, resource_group_name, name, revision, replica, container,
5858
self._url = self._get_url(cmd=cmd, resource_group_name=resource_group_name, name=name, revision=revision,
5959
replica=replica, container=container, startup_command=startup_command)
6060
self._socket = websocket.WebSocket(enable_multithread=True)
61-
logger.warning("Attempting to connect to %s", self._url)
61+
logger.info("Attempting to connect to %s", self._url)
6262
self._socket.connect(self._url, header=[f"Authorization: Bearer {self._token}"])
6363

6464
self.is_connected = True
@@ -160,9 +160,12 @@ def _getch_windows():
160160
def ping_container_app(app):
161161
site = safe_get(app, "properties", "configuration", "ingress", "fqdn")
162162
if site:
163-
resp = requests.get(f'https://{site}')
164-
if not resp.ok:
165-
logger.info(f"Got bad status pinging app: {resp.status_code}")
163+
try:
164+
resp = requests.get(f'https://{site}', timeout=30)
165+
if not resp.ok:
166+
logger.info(f"Got bad status pinging app: {resp.status_code}")
167+
except requests.exceptions.ReadTimeout:
168+
logger.info("Timed out while pinging app external URL")
166169
else:
167170
logger.info("Could not fetch site external URL")
168171

src/containerapp/azext_containerapp/_up_utils.py

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,13 @@
4141
create_service_principal_for_rbac,
4242
repo_url_to_name,
4343
get_container_app_if_exists,
44-
trigger_workflow
44+
trigger_workflow,
45+
_ensure_location_allowed,
46+
_is_resource_provider_registered,
47+
_register_resource_provider
4548
)
4649

47-
from ._constants import MAXIMUM_SECRET_LENGTH
50+
from ._constants import MAXIMUM_SECRET_LENGTH, LOG_ANALYTICS_RP
4851

4952
from .custom import (
5053
create_managed_environment,
@@ -62,6 +65,8 @@ def __init__(self, cmd, name: str, location: str, exists: bool = None):
6265
self.cmd = cmd
6366
self.name = name
6467
self.location = _get_default_containerapps_location(cmd, location)
68+
if self.location.lower() == "northcentralusstage":
69+
self.location = "eastus"
6570
self.exists = exists
6671

6772
self.check_exists()
@@ -151,7 +156,7 @@ def __init__(
151156
rg = parse_resource_id(name)["resource_group"]
152157
if resource_group.name != rg:
153158
self.resource_group = ResourceGroup(cmd, rg, location)
154-
self.location = _get_default_containerapps_location(cmd, location)
159+
self.location = location
155160
self.logs_key = logs_key
156161
self.logs_customer_id = logs_customer_id
157162

@@ -164,7 +169,7 @@ def set_name(self, name_or_rid):
164169
self.resource_group = ResourceGroup(
165170
self.cmd,
166171
rg,
167-
_get_default_containerapps_location(self.cmd, self.location),
172+
self.location,
168173
)
169174
else:
170175
self.name = name_or_rid
@@ -188,6 +193,9 @@ def create_if_needed(self, app_name):
188193
) # TODO use .info()
189194

190195
def create(self):
196+
self.location = validate_environment_location(self.cmd, self.location)
197+
if not _is_resource_provider_registered(self.cmd, LOG_ANALYTICS_RP):
198+
_register_resource_provider(self.cmd, LOG_ANALYTICS_RP)
191199
env = create_managed_environment(
192200
self.cmd,
193201
self.name,
@@ -290,8 +298,11 @@ def create_acr(self):
290298
registry_rg = self.resource_group
291299
url = self.registry_server
292300
registry_name = url[: url.rindex(".azurecr.io")]
301+
location = "eastus"
302+
if self.env.location and self.env.location.lower() != "northcentralusstage":
303+
location = self.env.location
293304
registry_def = create_new_acr(
294-
self.cmd, registry_name, registry_rg.name, self.env.location
305+
self.cmd, registry_name, registry_rg.name, location
295306
)
296307
self.registry_server = registry_def.login_server
297308

@@ -435,7 +446,13 @@ def _get_ingress_and_target_port(ingress, target_port, dockerfile_content: "list
435446
return ingress, target_port
436447

437448

438-
def _validate_up_args(source, image, repo, registry_server):
449+
def _validate_up_args(cmd, source, image, repo, registry_server):
450+
disallowed_params = ["--only-show-errors", "--output", "-o"]
451+
command_args = cmd.cli_ctx.data.get("safe_params", [])
452+
for a in disallowed_params:
453+
if a in command_args:
454+
raise ValidationError(f"Argument {a} is not allowed for 'az containerapp up'")
455+
439456
if not source and not image and not repo:
440457
raise RequiredArgumentMissingError(
441458
"You must specify either --source, --repo, or --image"
@@ -782,3 +799,69 @@ def find_existing_acr(cmd, app: "ContainerApp"):
782799
app.should_create_acr = False
783800
return acr.name, parse_resource_id(acr.id)["resource_group"]
784801
return None, None
802+
803+
804+
def validate_environment_location(cmd, location):
805+
from ._constants import MAX_ENV_PER_LOCATION
806+
env_list = list_managed_environments(cmd)
807+
808+
locations = [l["location"] for l in env_list]
809+
locations = list(set(locations)) # remove duplicates
810+
811+
location_count = {}
812+
for loc in locations:
813+
location_count[loc] = len([e for e in env_list if e["location"] == loc])
814+
815+
disallowed_locations = []
816+
for _, value in enumerate(location_count):
817+
if location_count[value] > MAX_ENV_PER_LOCATION - 1:
818+
disallowed_locations.append(value)
819+
820+
res_locations = list_environment_locations(cmd)
821+
res_locations = [l for l in res_locations if l not in disallowed_locations]
822+
823+
allowed_locs = ", ".join(res_locations)
824+
825+
if location:
826+
try:
827+
_ensure_location_allowed(cmd, location, "Microsoft.App", "managedEnvironments")
828+
except Exception: # pylint: disable=broad-except
829+
raise ValidationError("You cannot create a Containerapp environment in location {}. List of eligible locations: {}.".format(location, allowed_locs))
830+
831+
if len(res_locations) > 0:
832+
if not location:
833+
logger.warning("Creating environment on location {}.".format(res_locations[0]))
834+
return res_locations[0]
835+
if location in disallowed_locations:
836+
raise ValidationError("You have more than {} environments in location {}. List of eligible locations: {}.".format(MAX_ENV_PER_LOCATION, location, allowed_locs))
837+
return location
838+
else:
839+
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))
840+
841+
842+
def list_environment_locations(cmd):
843+
from ._utils import providers_client_factory
844+
providers_client = providers_client_factory(cmd.cli_ctx, get_subscription_id(cmd.cli_ctx))
845+
resource_types = getattr(providers_client.get("Microsoft.App"), 'resource_types', [])
846+
res_locations = []
847+
for res in resource_types:
848+
if res and getattr(res, 'resource_type', "") == "managedEnvironments":
849+
res_locations = getattr(res, 'locations', [])
850+
851+
res_locations = [res_loc.lower().replace(" ", "").replace("(", "").replace(")", "") for res_loc in res_locations if res_loc.strip()]
852+
853+
return res_locations
854+
855+
856+
def check_env_name_on_rg(cmd, managed_env, resource_group_name, location):
857+
if location:
858+
_ensure_location_allowed(cmd, location, "Microsoft.App", "managedEnvironments")
859+
if managed_env and resource_group_name and location:
860+
env_def = None
861+
try:
862+
env_def = ManagedEnvironmentClient.show(cmd, resource_group_name, parse_resource_id(managed_env)["name"])
863+
except:
864+
pass
865+
if env_def:
866+
if location != env_def["location"]:
867+
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))

src/containerapp/azext_containerapp/_utils.py

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919

2020
from ._clients import ContainerAppClient
2121
from ._client_factory import handle_raw_exception, providers_client_factory, cf_resource_groups, log_analytics_client_factory, log_analytics_shared_key_client_factory
22-
from ._constants import MAXIMUM_CONTAINER_APP_NAME_LENGTH, SHORT_POLLING_INTERVAL_SECS, LONG_POLLING_INTERVAL_SECS
22+
from ._constants import (MAXIMUM_CONTAINER_APP_NAME_LENGTH, SHORT_POLLING_INTERVAL_SECS, LONG_POLLING_INTERVAL_SECS,
23+
LOG_ANALYTICS_RP)
2324

2425
logger = get_logger(__name__)
2526

@@ -177,8 +178,9 @@ def get_workflow(github_repo, name): # pylint: disable=inconsistent-return-stat
177178

178179

179180
def trigger_workflow(token, repo, name, branch):
180-
logger.warning("Triggering Github Action")
181-
get_workflow(get_github_repo(token, repo), name).create_dispatch(branch)
181+
wf = get_workflow(get_github_repo(token, repo), name)
182+
logger.warning(f"Triggering Github Action: {wf.path}")
183+
wf.create_dispatch(branch)
182184

183185

184186
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):
254256
return group.location
255257

256258

257-
def _validate_subscription_registered(cmd, resource_provider, subscription_id=None):
258-
providers_client = None
259+
def _register_resource_provider(cmd, resource_provider):
260+
from azure.mgmt.resource.resources.models import ProviderRegistrationRequest, ProviderConsentDefinition
261+
262+
logger.warning(f"Registering resource provider {resource_provider} ...")
263+
properties = ProviderRegistrationRequest(third_party_provider_consent=ProviderConsentDefinition(consent_to_authorization=True))
264+
265+
client = providers_client_factory(cmd.cli_ctx)
266+
try:
267+
client.register(resource_provider, properties=properties)
268+
# wait for registration to finish
269+
timeout_secs = 120
270+
registration = _is_resource_provider_registered(cmd, resource_provider)
271+
start = datetime.utcnow()
272+
while not registration:
273+
registration = _is_resource_provider_registered(cmd, resource_provider)
274+
time.sleep(SHORT_POLLING_INTERVAL_SECS)
275+
if (datetime.utcnow() - start).seconds >= timeout_secs:
276+
raise CLIInternalError(f"Timed out while waiting for the {resource_provider} resource provider to be registered.")
277+
278+
except Exception as e:
279+
msg = ("This operation requires requires registering the resource provider {0}. "
280+
"We were unable to perform that registration on your behalf: "
281+
"Server responded with error message -- {1} . "
282+
"Please check with your admin on permissions, "
283+
"or try running registration manually with: az provider register --wait --namespace {0}")
284+
raise ValidationError(resource_provider, msg.format(e.args)) from e
285+
286+
287+
def _is_resource_provider_registered(cmd, resource_provider, subscription_id=None):
288+
registered = None
259289
if not subscription_id:
260290
subscription_id = get_subscription_id(cmd.cli_ctx)
261-
262291
try:
263292
providers_client = providers_client_factory(cmd.cli_ctx, subscription_id)
264293
registration_state = getattr(providers_client.get(resource_provider), 'registration_state', "NotRegistered")
265294

266-
if not (registration_state and registration_state.lower() == 'registered'):
267-
raise ValidationError('Subscription {} is not registered for the {} resource provider. Please run \"az provider register -n {} --wait\" to register your subscription.'.format(
268-
subscription_id, resource_provider, resource_provider))
269-
except ValidationError as ex:
270-
raise ex
295+
registered = (registration_state and registration_state.lower() == 'registered')
271296
except Exception: # pylint: disable=broad-except
272297
pass
298+
return registered
299+
300+
301+
def _validate_subscription_registered(cmd, resource_provider, subscription_id=None):
302+
if not subscription_id:
303+
subscription_id = get_subscription_id(cmd.cli_ctx)
304+
registered = _is_resource_provider_registered(cmd, resource_provider, subscription_id)
305+
if registered is False:
306+
raise ValidationError(f'Subscription {subscription_id} is not registered for the {resource_provider} '
307+
f'resource provider. Please run "az provider register -n {resource_provider} --wait" '
308+
'to register your subscription.')
273309

274310

275311
def _ensure_location_allowed(cmd, location, resource_provider, resource_type):
@@ -421,7 +457,7 @@ def _get_default_log_analytics_location(cmd):
421457
providers_client = None
422458
try:
423459
providers_client = providers_client_factory(cmd.cli_ctx, get_subscription_id(cmd.cli_ctx))
424-
resource_types = getattr(providers_client.get("Microsoft.OperationalInsights"), 'resource_types', [])
460+
resource_types = getattr(providers_client.get(LOG_ANALYTICS_RP), 'resource_types', [])
425461
res_locations = []
426462
for res in resource_types:
427463
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
514550
def _generate_log_analytics_if_not_provided(cmd, logs_customer_id, logs_key, location, resource_group_name):
515551
if logs_customer_id is None and logs_key is None:
516552
logger.warning("No Log Analytics workspace provided.")
553+
_validate_subscription_registered(cmd, LOG_ANALYTICS_RP)
517554
try:
518-
_validate_subscription_registered(cmd, "Microsoft.OperationalInsights")
519555
log_analytics_client = log_analytics_client_factory(cmd.cli_ctx)
520556
log_analytics_shared_key_client = log_analytics_shared_key_client_factory(cmd.cli_ctx)
521557

522558
log_analytics_location = location
523559
try:
524-
_ensure_location_allowed(cmd, log_analytics_location, "Microsoft.OperationalInsights", "workspaces")
560+
_ensure_location_allowed(cmd, log_analytics_location, LOG_ANALYTICS_RP, "workspaces")
525561
except Exception: # pylint: disable=broad-except
526562
log_analytics_location = _get_default_log_analytics_location(cmd)
527563

@@ -819,8 +855,9 @@ def _update_traffic_weights(containerapp_def, list_weights):
819855

820856
if not is_existing:
821857
containerapp_def["properties"]["configuration"]["ingress"]["traffic"].append({
822-
"revisionName": key_val[0],
823-
"weight": int(key_val[1])
858+
"revisionName": key_val[0] if key_val[0].lower() != "latest" else None,
859+
"weight": int(key_val[1]),
860+
"latestRevision": key_val[0].lower() == "latest"
824861
})
825862

826863

src/containerapp/azext_containerapp/custom.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1964,7 +1964,7 @@ def stream_containerapp_logs(cmd, resource_group_name, name, container=None, rev
19641964
url = (f"{base_url}/subscriptions/{sub}/resourceGroups/{resource_group_name}/containerApps/{name}"
19651965
f"/revisions/{revision}/replicas/{replica}/containers/{container}/logstream")
19661966

1967-
logger.warning("connecting to : %s", url)
1967+
logger.info("connecting to : %s", url)
19681968
request_params = {"follow": str(follow).lower(), "output": output_format, "tailLines": tail}
19691969
headers = {"Authorization": f"Bearer {token}"}
19701970
resp = requests.get(url, timeout=None, stream=True, params=request_params, headers=headers)
@@ -2015,12 +2015,14 @@ def containerapp_up(cmd,
20152015
service_principal_tenant_id=None):
20162016
from ._up_utils import (_validate_up_args, _reformat_image, _get_dockerfile_content, _get_ingress_and_target_port,
20172017
ResourceGroup, ContainerAppEnvironment, ContainerApp, _get_registry_from_app,
2018-
_get_registry_details, _create_github_action, _set_up_defaults, up_output, AzureContainerRegistry)
2018+
_get_registry_details, _create_github_action, _set_up_defaults, up_output, AzureContainerRegistry,
2019+
check_env_name_on_rg)
20192020
HELLOWORLD = "mcr.microsoft.com/azuredocs/containerapps-helloworld"
20202021
dockerfile = "Dockerfile" # for now the dockerfile name must be "Dockerfile" (until GH actions API is updated)
20212022

2022-
_validate_up_args(source, image, repo, registry_server)
2023+
_validate_up_args(cmd, source, image, repo, registry_server)
20232024
validate_container_app_name(name)
2025+
check_env_name_on_rg(cmd, managed_env, resource_group_name, location)
20242026

20252027
image = _reformat_image(source, repo, image)
20262028
token = None if not repo else get_github_access_token(cmd, ["admin:repo_hook", "repo", "workflow"], token)

0 commit comments

Comments
 (0)