From c8c9e8befd69c0188f90b81266da5b29bc2aca34 Mon Sep 17 00:00:00 2001
From: JarodAertsMsft <189775254+JarodAertsMsft@users.noreply.github.com>
Date: Mon, 19 Oct 2026 09:27:27 -0500
Subject: [PATCH 1/6] [App Service] Add Active Backup silo management preview
Add dedicated plan/site silo lifecycle, managed regional traffic, Last Known Good, accepted refresh, networking, and operational pass-through commands. Preserve service eligibility and API operation semantics. Include isolated ARM transport, offline coverage, opt-in live scenarios, and usage documentation.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 224abf0a-0923-4347-a5ea-5c4a1b4e4824
---
doc/active_backup_preview.md | 208 ++++++
.../appservice/_active_backup_client.py | 337 +++++++++
.../appservice/_active_backup_commands.py | 256 +++++++
.../cli/command_modules/appservice/_help.py | 2 +
.../cli/command_modules/appservice/_params.py | 2 +
.../appservice/active_backup.py | 677 ++++++++++++++++++
.../command_modules/appservice/commands.py | 2 +
.../tests/latest/test_active_backup_client.py | 353 +++++++++
.../latest/test_active_backup_commands.py | 64 ++
.../test_active_backup_commands_thru_mock.py | 439 ++++++++++++
10 files changed, 2340 insertions(+)
create mode 100644 doc/active_backup_preview.md
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/_active_backup_client.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/active_backup.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_client.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py
diff --git a/doc/active_backup_preview.md b/doc/active_backup_preview.md
new file mode 100644
index 00000000000..cd6e7c71ee1
--- /dev/null
+++ b/doc/active_backup_preview.md
@@ -0,0 +1,208 @@
+# App Service Active Backup preview
+
+The preview commands manage plan and site **silos** through public ARM APIs. They
+do not configure storage-backed App Service backups, deployment slots, or slot
+traffic routing.
+
+## Availability and API version
+
+New commands use `2026-08-01`, the version registered for the child resources in
+the inspected App Service ARM manifest. Ordinary app/plan commands retain their
+existing API versions. Direct GeoMaster tests using an older date do not establish
+public ARM availability. An unavailable version or feature is reported as a service
+error; the CLI does not silently downgrade or call internal GeoMaster endpoints.
+
+The intended creation preview is Windows/Linux dedicated multitenant plans in the
+PremiumV3, PremiumMV3, PremiumV4, and PremiumMV4 tier families. **P0v3 is excluded**.
+Linux requires service-side flavor enablement. Installing these commands does not
+enable a subscription or region, grant quota, or change service eligibility.
+
+Create a plan silo before a site silo in the same target region. Silo names need
+not match. Region uniqueness, eligible SKUs, slots, primary deletion restrictions,
+and plan placement remain service-enforced. Creating the first site silo can
+restart the primary app. Cross-resource-group/subscription source-plan placement
+is not established as supported by the current backend.
+
+## Create, inspect, and update
+
+Assume an existing eligible primary plan and app in `eastus`. Creating silos
+allocates billable resources; choose a region enabled for your subscription.
+
+```azurecli
+az appservice plan silo create -g MyGroup -n MyPlan --silo WestPlan --location westus2 --primary-location eastus --number-of-workers 2
+az webapp silo create -g MyGroup -n MyApp --silo WestApp --location westus2 --primary-location eastus --traffic-mode Active
+az webapp silo show -g MyGroup -n MyApp --silo WestApp
+az webapp silo list -g MyGroup -n MyApp
+az appservice plan silo update -g MyGroup -n MyPlan --silo WestPlan --zone-redundant false
+az webapp silo update -g MyGroup -n MyApp --silo WestApp --follow-last-known-good false
+```
+
+Resource commands return the ARM envelope. This illustrates the shape, not a
+captured live response or a promised revision number:
+
+```output
+{
+ "id": "/subscriptions/SUB/resourceGroups/MyGroup/providers/Microsoft.Web/sites/MyApp/silos/WestApp",
+ "name": "WestApp",
+ "type": "Microsoft.Web/sites/silos",
+ "location": "westus2",
+ "properties": {
+ "provisioningState": "Succeeded",
+ "revisionNumber": 7,
+ "followLastKnownGood": false
+ }
+}
+```
+
+`--name` always identifies the primary. `--silo` identifies the child. Supported
+non-create commands also accept a complete nested silo ARM ID through `--ids`;
+do not combine it with named resource arguments. No internal replica app or plan
+name is required.
+
+Create/update/delete wait for their API operation, not future content/configuration
+revision, probe-health, or DNS convergence. `--no-wait` returns an accepted-operation
+receipt when the service starts an asynchronous operation:
+
+```powershell
+$operation = az appservice plan silo create -g MyGroup -n MyPlan --silo WestPlan --location westus2 --primary-location eastus --no-wait | ConvertFrom-Json
+az appservice plan silo wait -g MyGroup -n MyPlan --silo WestPlan --operation-id $operation.operationId --timeout 7200
+```
+
+A synchronous/no-op result is already complete and does not need an operation
+wait. A CLI timeout does not cancel the service operation. `silo operation show`
+inspects a CRUD operation; `webapp silo runtime-operation show` inspects a runtime
+operation instead.
+
+Worker overrides are 1-30. Omitted fields preserve service defaults/current values;
+explicit `false` is not omission. Location cannot be changed by update. The current
+API does not establish resetting existing plan overrides back to inheritance.
+
+## Traffic, origin, Last Known Good, and refresh
+
+```azurecli
+az webapp managed-traffic show -g MyGroup -n MyApp
+az webapp managed-traffic list -g MyGroup -n MyApp --expand-endpoints
+az webapp managed-traffic update -g MyGroup -n MyApp --traffic-mode Passive
+az webapp managed-traffic update -g MyGroup -n MyApp --endpoints @endpoints.json
+az webapp silo update -g MyGroup -n MyApp --silo WestApp --endpoint-status Enabled
+az webapp silo update -g MyGroup -n MyApp --silo origin --endpoint-status Disabled
+az webapp last-known-good mark -g MyGroup -n MyApp
+az webapp last-known-good show -g MyGroup -n MyApp
+az webapp silo refresh -g MyGroup -n MyApp
+```
+
+An endpoint file is an array of partial, region-keyed overrides, for example:
+
+```json
+[
+ {"region": "eastus", "priority": 1},
+ {"region": "westus2", "priority": 2}
+]
+```
+
+The first site silo requires `Active` or `Passive` traffic mode. Change subsequent
+mode on the app-wide managed-traffic resource. Active uses weights; Passive uses
+priorities. The service validates endpoint membership, readiness, uniqueness, and
+the merged state. Explicit weights switch those endpoints to manual weighting;
+the API does not currently establish a reset to automatic weighting.
+
+`origin` represents the primary endpoint. Only endpoint status is updatable on
+it through the silo update API; it is not a separately creatable/deletable replica.
+Verify that the target silo is healthy and enabled before disabling origin.
+These commands do not perform a safe failover workflow on the caller's behalf.
+
+LKG mark selects the latest ready revision; it is not arbitrary revision selection
+or a primary rollback. Refresh succeeds immediately on HTTP 204 **acceptance** and
+prints an asynchronous-propagation notice to stderr. It does not poll revisions,
+invent a target revision number, or guarantee completion within a fixed interval.
+Use silo show/list to observe revisions, provisioning state, and error details.
+
+## Networking
+
+```azurecli
+az webapp silo vnet-integration add -g MyGroup -n MyApp --silo WestApp --subnet /subscriptions/SUB/resourceGroups/NETGROUP/providers/Microsoft.Network/virtualNetworks/WestNet/subnets/integration
+az webapp silo vnet-integration show -g MyGroup -n MyApp --silo WestApp
+az appservice plan silo vnet-integration list -g MyGroup -n MyPlan --silo WestPlan
+az webapp silo vnet-integration remove -g MyGroup -n MyApp --silo WestApp
+```
+
+The site API uses the dedicated silo `virtualNetworkConnections/virtualNetwork`
+route. `update` uses PATCH. For full connection properties, `--body @request.json`
+accepts the API envelope, for example `{"properties":{"subnetResourceId":"..."}}`.
+Plan-silo integration inspection is read-only. No normal-primary-region network
+validator runs before a silo call.
+
+## Operational pass-through commands
+
+The `webapp silo` subgroups cover configuration reads, configuration snapshots,
+deployment history/status, instances/processes, logs, WebJobs, installed extensions,
+extension invocation, network checks/traces, metrics, antivirus, and site-container
+reads. There is **no CLI-side OS/SKU/flavor/provisioning filter**: the requested silo
+route is called directly, and service errors such as 404 are surfaced normally.
+
+```azurecli
+az webapp silo config show -g MyGroup -n MyApp --silo WestApp
+az webapp silo config appsettings list -g MyGroup -n MyApp --silo WestApp
+az webapp silo instance list -g MyGroup -n MyApp --silo WestApp
+az webapp silo restart -g MyGroup -n MyApp --silo WestApp
+az webapp silo log startup list -g MyGroup -n MyApp --silo WestApp --latest true
+az webapp silo log container download -g MyGroup -n MyApp --silo WestApp --format zip --file logs.zip
+```
+
+Instance-qualified process/container operations accept `--instance`. Binary
+downloads require `--file`; an existing destination requires `--overwrite`.
+`log tail` streams text, or exact bytes with `--file`. Configuration/connection
+string/auth/private-snapshot reads intentionally return sensitive values: protect
+their output and do not put it in source control.
+
+Request-body operations accept JSON or `@file` containing the API envelope:
+
+| Operation | Example body |
+|---|---|
+| `network-check dns` | `{"properties":{"dnsName":"example.com"}}` |
+| `network-check tcp-ping` | `{"properties":{"host":"example.com","port":"443"}}` |
+| `network-check connectivity` | `{"properties":{"providerType":"Web","credentials":{"credentialType":"AppSetting","appSetting":"MY_ENDPOINT"},"resourceMetadata":{"entityName":"example.com"}}}` |
+| `antivirus config update` | `{"properties":{"deleteInfectedFiles":false}}` |
+
+`extension invoke` requires an instance, relative `--path`, and supports
+GET/POST/PUT/PATCH/DELETE/HEAD with an optional body/content type/file. The service's
+extension-path restrictions still apply. Use `--uri-parameters KEY=VALUE` for
+extension query parameters; repeated keys are preserved and cannot override the
+ARM API version. It is not a general arbitrary-host REST client.
+
+The API has point process reads but no reviewed silo process-list route. No
+primary process-list fallback is used. Configuration snapshot reads do not imply
+snapshot restore. General configuration writes, direct deployment, identity
+assignment, and site-container writes are not invented where the service has no
+silo route. The publishing-profile silo route needs backend target-rewrite
+verification before it can be exposed safely.
+
+## Teardown
+
+Delete site silos before their backing plan silos; then use ordinary commands to
+delete the primary resources if desired. There is no automatic cascade.
+
+```azurecli
+az webapp silo delete -g MyGroup -n MyApp --silo WestApp --yes
+az appservice plan silo delete -g MyGroup -n MyPlan --silo WestPlan --yes
+```
+
+## Validation and release prerequisites
+
+Offline mock coverage exercises all 84 pass-through methods, management bodies,
+operation protocols, identity handling, file/stream behavior, and runtime help.
+It is not evidence that an API version/feature is deployed in a particular cloud
+or that a service-specific operation works for every silo.
+
+The opt-in scenario tests require an enabled preview subscription and explicit
+authorization to allocate resources. Set `AZURE_ACTIVE_BACKUP_LIVE=1` only for that
+purpose, along with `AZURE_ACTIVE_BACKUP_PRIMARY_LOCATION` and
+`AZURE_ACTIVE_BACKUP_SILO_LOCATION`. Linux also needs service-side enablement.
+No live recording or public ARM availability claim should be inferred from a
+passing mock suite.
+
+Use the repository's live runner only after enabling that opt-in:
+
+```powershell
+azdev test test_appservice_active_backup_windows test_appservice_active_backup_linux --live
+```
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_client.py b/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_client.py
new file mode 100644
index 00000000000..6e5880ed3d5
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_client.py
@@ -0,0 +1,337 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+# --------------------------------------------------------------------------------------------
+
+import codecs
+from contextlib import contextmanager
+from datetime import datetime, timezone
+from email.utils import parsedate_to_datetime
+import json
+import math
+import os
+import sys
+import tempfile
+import time
+from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlsplit, urlunsplit
+import uuid
+
+import requests
+
+from azure.cli.core._profile import Profile
+from azure.cli.core.azclierror import (AzureConnectionError, AzureResponseError, FileOperationError,
+ InvalidArgumentValueError, ResourceNotFoundError)
+from azure.cli.core.commands.client_factory import get_subscription_id
+from azure.cli.core.sdk.policies import get_custom_hook_policy
+from azure.cli.core.util import get_az_user_agent, should_disable_connection_verify
+
+
+API_VERSION = '2026-08-01'
+DEFAULT_TIMEOUT = 7200
+_RUNNING = {'accepted', 'created', 'inprogress', 'running', 'waiting'}
+_FAILED = {'failed', 'cancelled', 'canceled', 'timedout'}
+
+
+def segment(value):
+ if not isinstance(value, str) or not value or value in ('.', '..') or any(
+ char in value for char in ('/', '\\', '\r', '\n', '\x00')):
+ raise InvalidArgumentValueError('Provide a non-empty resource name or identifier without path separators.')
+ return quote(value, safe='')
+
+
+def load_json(value, expected_type=dict):
+ if expected_type is None and isinstance(value, (dict, list, int, float, bool)):
+ return value
+ if expected_type is not None and isinstance(value, expected_type):
+ return value
+ try:
+ if isinstance(value, str) and value.startswith('@'):
+ with open(value[1:], encoding='utf-8-sig') as handle:
+ result = json.load(handle)
+ else:
+ result = json.loads(value)
+ except OSError as ex:
+ raise FileOperationError('Unable to read the JSON input file: {}.'.format(ex.strerror)) from ex
+ except (TypeError, ValueError) as ex:
+ raise InvalidArgumentValueError('Provide valid JSON or an @file containing JSON.') from ex
+ if expected_type is not None and not isinstance(result, expected_type):
+ raise InvalidArgumentValueError(
+ 'Expected a JSON {}.'.format('array' if expected_type is list else 'object'))
+ return result
+
+
+def _origin(url):
+ parsed = urlsplit(url)
+ try:
+ return parsed.scheme.lower(), parsed.hostname, 443 if parsed.port is None else parsed.port
+ except ValueError as ex:
+ raise AzureResponseError('Invalid ARM request address.') from ex
+
+
+def _service_error(response):
+ try:
+ payload = response.json()
+ except ValueError:
+ payload = {}
+ detail = payload.get('error', payload.get('Error', payload)) if isinstance(payload, dict) else {}
+ code = detail.get('code', detail.get('Code')) if isinstance(detail, dict) else None
+ message = detail.get('message', detail.get('Message')) if isinstance(detail, dict) else None
+ if isinstance(message, dict):
+ message = message.get('value')
+ message = message if isinstance(message, str) else 'The ARM request failed (HTTP {}).'.format(response.status_code)
+ if code:
+ message = '({}) {}'.format(code, message)
+ request_id = response.headers.get('x-ms-request-id')
+ if request_id:
+ message += ' Request ID: {}.'.format(request_id)
+ error_type = ResourceNotFoundError if response.status_code == 404 else AzureResponseError
+ error = error_type(message)
+ error.response = response
+ return error
+
+
+def _retry_delay(headers):
+ retry = headers.get('Retry-After', '5')
+ try:
+ delay = float(retry)
+ except ValueError:
+ try:
+ delay = (parsedate_to_datetime(retry) - datetime.now(timezone.utc)).total_seconds()
+ except (TypeError, ValueError, OverflowError):
+ delay = 5
+ return max(delay, 0.1) if math.isfinite(delay) else 5
+
+
+class ActiveBackupClient:
+ """ARM-only transport for the silo APIs absent from the Web SDK."""
+
+ def __init__(self, cli_ctx, resource_group_name, name, resource_type='sites', subscription=None):
+ self.cli_ctx = cli_ctx
+ self.subscription = subscription or get_subscription_id(cli_ctx)
+ self.endpoint = cli_ctx.cloud.endpoints.resource_manager.rstrip('/') + '/'
+ self.parent = '/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/{}/{}'.format(
+ segment(self.subscription), segment(resource_group_name), resource_type, segment(name))
+
+ def resource(self, silo=None):
+ return self.parent + '/silos' + ('/' + segment(silo) if silo is not None else '')
+
+ def url(self, path, query=None):
+ try:
+ url = urljoin(self.endpoint, path)
+ parsed = urlsplit(url)
+ except ValueError as ex:
+ raise AzureResponseError('The service returned an invalid ARM address.') from ex
+ if (parsed.scheme != 'https' or parsed.username or parsed.password or parsed.fragment or
+ _origin(url) != _origin(self.endpoint)):
+ raise AzureResponseError('The service returned an address outside the configured ARM endpoint.')
+ parameters = list(parse_qsl(parsed.query, keep_blank_values=True))
+ if not any(key.lower() == 'api-version' for key, _ in parameters):
+ parameters.append(('api-version', API_VERSION))
+ for key, value in (query or {}).items():
+ values = value if isinstance(value, (list, tuple)) else [value]
+ for item in values:
+ if item is not None:
+ parameters.append((key, str(item).lower() if isinstance(item, bool) else str(item)))
+ return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(parameters), ''))
+
+ @contextmanager
+ def _response(self, method, path, body=None, query=None, timeout=60, stream=False, content_type=None):
+ url = self.url(path, query)
+ token_type, token, _ = Profile(cli_ctx=self.cli_ctx).get_raw_token(
+ resource=self.cli_ctx.cloud.endpoints.active_directory_resource_id,
+ subscription=self.subscription)[0]
+ headers = {
+ 'Authorization': '{} {}'.format(token_type, token),
+ 'User-Agent': get_az_user_agent(),
+ 'x-ms-client-request-id': str(uuid.uuid4()),
+ 'CommandName': self.cli_ctx.data.get('command', ''),
+ }
+ if body is not None:
+ headers['Content-Type'] = content_type or 'application/json'
+ if (not content_type or 'json' in content_type.lower()) and not isinstance(body, bytes):
+ body = json.dumps(body)
+ policy = get_custom_hook_policy(self.cli_ctx)
+ if policy is not None:
+ from azure.core.pipeline import PipelineContext, PipelineRequest
+ from azure.core.pipeline.transport import HttpRequest
+ policy_request = HttpRequest(method, url, headers=headers, data=body)
+ policy.on_request(PipelineRequest(policy_request, PipelineContext(None)))
+ headers = dict(policy_request.headers)
+ # Do not log bodies or URLs: these operations can return secrets or accept SAS queries.
+ try:
+ with requests.Session() as session:
+ with session.request(
+ method, url, data=body, headers=headers, stream=stream, allow_redirects=False,
+ timeout=(min(10, timeout), timeout),
+ verify=not should_disable_connection_verify()) as response:
+ if not 200 <= response.status_code < 300:
+ raise _service_error(response)
+ yield response
+ except requests.RequestException as ex:
+ raise AzureConnectionError(
+ 'The Active Backup ARM request could not complete ({}).'.format(type(ex).__name__),
+ recommendation='Check network connectivity and the configured cloud/TLS settings.') from None
+
+ def request(self, method, path, body=None, query=None, timeout=60, content_type=None):
+ with self._response(method, path, body, query, timeout, content_type=content_type) as response:
+ return response
+
+ @staticmethod
+ def decode(response):
+ if response.status_code == 204 or not response.content:
+ return None
+ content_type = response.headers.get('Content-Type', '').lower()
+ if 'json' in content_type:
+ try:
+ return response.json()
+ except ValueError as ex:
+ raise AzureResponseError('ARM returned an invalid JSON response.') from ex
+ if 'octet-stream' in content_type or 'zip' in content_type:
+ raise InvalidArgumentValueError('Use --file to save the binary response.')
+ if content_type.startswith('text/') or 'xml' in content_type:
+ return {'content': response.text}
+ try:
+ return response.json()
+ except ValueError as ex:
+ raise AzureResponseError('ARM returned an unsupported response content type. Use --file.') from ex
+
+ def list(self, path, query=None):
+ result = []
+ visited = set()
+ while path:
+ address = self.url(path, query)
+ if address in visited:
+ raise AzureResponseError('ARM returned a repeated pagination link.')
+ visited.add(address)
+ payload = self.decode(self.request('GET', address))
+ if isinstance(payload, list):
+ result.extend(payload)
+ break
+ if not isinstance(payload, dict) or not isinstance(payload.get('value'), list):
+ raise AzureResponseError('ARM did not return a resource collection.')
+ result.extend(payload['value'])
+ path = payload.get('nextLink')
+ if path is not None and not isinstance(path, str):
+ raise AzureResponseError('ARM returned an invalid pagination link.')
+ query = None
+ return result
+
+ def operation(self, path, response=None):
+ response = response if response is not None else self.request('GET', path)
+ payload = self.decode(response)
+ state = payload.get('status') if isinstance(payload, dict) else None
+ if isinstance(state, str) and state.lower() in _FAILED:
+ detail = payload.get('error') or {}
+ if not isinstance(detail, dict):
+ detail = {}
+ raise AzureResponseError('({}) {}'.format(
+ detail.get('code', state), detail.get('message', 'The Active Backup operation failed.')))
+ if isinstance(state, str) and state.lower() in _RUNNING:
+ state = 'InProgress'
+ elif response.status_code == 202:
+ state = 'InProgress'
+ elif state and str(state).lower() != 'succeeded':
+ raise AzureResponseError('ARM returned an unrecognized operation status: {}.'.format(state))
+ else:
+ state = 'Succeeded'
+ result = {
+ 'operationId': urlsplit(path).path.rstrip('/').split('/')[-1],
+ 'operationUrl': self.url(path),
+ 'status': state,
+ }
+ if isinstance(payload, dict) and 'id' in payload:
+ result['resource'] = payload
+ return result
+
+ def wait(self, path, timeout=DEFAULT_TIMEOUT, initial=None):
+ if timeout <= 0:
+ raise InvalidArgumentValueError('--timeout must be greater than zero.')
+ deadline = time.monotonic() + timeout
+ response = initial
+ read_retries = 0
+ while True:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise AzureResponseError(
+ 'Timed out waiting for operation {}. The service operation was not cancelled.'.format(
+ urlsplit(path).path.rstrip('/').split('/')[-1]))
+ if response is None:
+ try:
+ response = self.request('GET', path, timeout=min(60, remaining))
+ read_retries = 0
+ except (ResourceNotFoundError, AzureResponseError) as ex:
+ failed_response = getattr(ex, 'response', None)
+ status = getattr(failed_response, 'status_code', None)
+ known_pending = isinstance(ex, ResourceNotFoundError) and initial is not None
+ read_retries += 1
+ if (not known_pending and status not in (429, 500, 502, 503, 504)) or read_retries > 3:
+ raise
+ delay = _retry_delay(failed_response.headers if failed_response is not None else {})
+ time.sleep(min(delay, max(0, deadline - time.monotonic())))
+ continue
+ operation = self.operation(path, response)
+ if operation['status'] == 'Succeeded':
+ return operation
+ delay = _retry_delay(response.headers)
+ time.sleep(min(max(delay, 0.1), max(0, deadline - time.monotonic())))
+ response = None
+
+ def mutate(self, method, path, body=None, no_wait=False, timeout=DEFAULT_TIMEOUT, query=None):
+ if timeout <= 0:
+ raise InvalidArgumentValueError('--timeout must be greater than zero.')
+ response = self.request(method, path, body, query)
+ if response.status_code != 202:
+ return None if method == 'DELETE' else self.decode(response)
+ location = response.headers.get('Azure-AsyncOperation') or response.headers.get('Location')
+ if not location:
+ raise AzureResponseError('ARM accepted the operation without returning a polling address.')
+ location = self.url(location)
+ if no_wait:
+ return {
+ 'resourceId': path,
+ 'operationId': urlsplit(location).path.rstrip('/').split('/')[-1],
+ 'operationUrl': location,
+ 'status': 'Accepted',
+ }
+ operation = self.wait(location, timeout, initial=response)
+ if method == 'DELETE':
+ return None
+ if 'resource' in operation:
+ return operation['resource']
+ return self.decode(self.request('GET', path)) if method in ('PUT', 'PATCH') else None
+
+ def consume(self, method, path, filename=None, overwrite=False, query=None, body=None,
+ content_type=None, tail=False):
+ if filename is not None and not filename:
+ raise InvalidArgumentValueError('--file must be a non-empty destination path.')
+ destination = os.path.abspath(filename) if filename else None
+ temporary = None
+ if destination and os.path.exists(destination) and not overwrite:
+ raise FileOperationError('The destination exists. Use --overwrite to replace it.')
+ try:
+ with self._response(method, path, body, query, stream=True, content_type=content_type) as response:
+ if destination:
+ descriptor, temporary = tempfile.mkstemp(dir=os.path.dirname(destination))
+ with os.fdopen(descriptor, 'wb') as handle:
+ for chunk in response.iter_content(chunk_size=65536):
+ handle.write(chunk)
+ if overwrite:
+ os.replace(temporary, destination)
+ temporary = None
+ else:
+ os.link(temporary, destination)
+ return {'file': destination}
+ if not tail:
+ raise InvalidArgumentValueError('Provide --file for this download.')
+ decoder = codecs.getincrementaldecoder('utf-8')(errors='replace')
+ for chunk in response.iter_content(chunk_size=1024):
+ sys.stdout.write(decoder.decode(chunk))
+ sys.stdout.flush()
+ sys.stdout.write(decoder.decode(b'', final=True))
+ sys.stdout.flush()
+ return None
+ except OSError as ex:
+ raise FileOperationError('Unable to save the response: {}.'.format(ex.strerror)) from ex
+ finally:
+ if temporary is not None:
+ os.unlink(temporary)
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py b/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py
new file mode 100644
index 00000000000..53f57cf8685
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py
@@ -0,0 +1,256 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+# --------------------------------------------------------------------------------------------
+
+import inspect
+
+
+def validate_identity(cmd, namespace):
+ from azure.cli.core.azclierror import InvalidArgumentValueError, MutuallyExclusiveArgumentError
+ from azure.mgmt.core.tools import parse_resource_id
+ identifiers = getattr(namespace, '_ids', None)
+ if not identifiers:
+ return
+ named_flags = {'--name', '-n', '--resource-group', '-g', '--silo', '--subscription'}
+ if named_flags.intersection(cmd.cli_ctx.data.get('safe_params', [])):
+ raise MutuallyExclusiveArgumentError('Use --ids or named resource arguments, not both.')
+ expected_type = 'serverfarms' if cmd.name.startswith('appservice plan silo') else 'sites'
+ child_required = hasattr(namespace, 'silo')
+ for identifier in identifiers:
+ parts = parse_resource_id(identifier)
+ if (parts.get('namespace', '').lower() != 'microsoft.web' or
+ parts.get('type', parts.get('resource_type', '')).lower() != expected_type or
+ (child_required and parts.get('child_type_1', '').lower() != 'silos') or
+ (not child_required and 'child_type_1' in parts) or 'child_type_2' in parts):
+ raise InvalidArgumentValueError(
+ 'Provide a Microsoft.Web/{}{} resource ID.'.format(expected_type, '/silos' if child_required else ''))
+
+
+COMMAND_GROUPS = {
+ 'appservice plan silo': (
+ ('create', 'create_plan_silo'), ('update', 'update_plan_silo'),
+ ('show', 'show_plan_silo'), ('list', 'list_plan_silos'), ('delete', 'delete_plan_silo'),
+ ('wait', 'wait_plan_silo')),
+ 'appservice plan silo operation': (('show', 'show_plan_silo_operation'),),
+ 'appservice plan silo vnet-integration': (('list', 'list_plan_silo_vnet_integrations'),),
+ 'webapp silo': (
+ ('create', 'create_site_silo'), ('update', 'update_site_silo'), ('show', 'show_site_silo'),
+ ('list', 'list_site_silos'), ('delete', 'delete_site_silo'), ('wait', 'wait_site_silo'),
+ ('refresh', 'refresh_site_silos'), ('start', 'start_site_silo'),
+ ('stop', 'stop_site_silo'), ('restart', 'restart_site_silo')),
+ 'webapp silo operation': (('show', 'show_site_silo_operation'),),
+ 'webapp silo vnet-integration': (
+ ('show', 'show_silo_vnet_integration'), ('add', 'add_silo_vnet_integration'),
+ ('update', 'update_silo_vnet_integration'), ('remove', 'remove_silo_vnet_integration')),
+ 'webapp managed-traffic': (
+ ('list', 'list_managed_traffic'), ('show', 'show_managed_traffic'), ('update', 'update_managed_traffic')),
+ 'webapp managed-traffic metrics': (('list', 'list_managed_traffic_metrics'),),
+ 'webapp last-known-good': (
+ ('list', 'list_last_known_good'), ('show', 'show_last_known_good'), ('mark', 'mark_last_known_good')),
+ 'webapp silo log php': (('show', 'show_silo_php_logging'),),
+ 'webapp silo deployment': (('list', 'list_silo_deployments'), ('show', 'show_silo_deployment')),
+ 'webapp silo deployment log': (
+ ('list', 'list_silo_deployment_logs'), ('show', 'show_silo_msdeploy_log')),
+ 'webapp silo deployment status': (
+ ('show', 'show_silo_onedeploy_status'), ('list', 'list_silo_deployment_statuses')),
+ 'webapp silo process': (
+ ('show', 'show_silo_process'), ('stop', 'stop_silo_process'), ('delete', 'delete_silo_process'),
+ ('dump', 'dump_silo_process')),
+ 'webapp silo process thread': (
+ ('list', 'list_silo_process_threads'), ('show', 'show_silo_process_thread')),
+ 'webapp silo process module': (
+ ('list', 'list_silo_process_modules'), ('show', 'show_silo_process_module')),
+ 'webapp silo container': (('restart', 'restart_silo_container'),),
+ 'webapp silo log container': (('download', 'download_silo_container_logs'),),
+ 'webapp silo log startup': (
+ ('list', 'list_silo_startup_logs'), ('show-summary', 'show_silo_startup_summary'),
+ ('show', 'show_silo_startup_log')),
+ 'webapp silo log': (('list', 'list_silo_logs'), ('tail', 'tail_silo_logs')),
+ 'webapp silo log config': (('show', 'show_silo_log_config'),),
+ 'webapp silo webjob': (('list', 'list_silo_webjobs'), ('show', 'show_silo_webjob')),
+ 'webapp silo webjob continuous': (
+ ('list', 'list_silo_continuous_webjobs'), ('show', 'show_silo_continuous_webjob'),
+ ('start', 'start_silo_continuous_webjob'), ('stop', 'stop_silo_continuous_webjob')),
+ 'webapp silo webjob triggered': (
+ ('list', 'list_silo_triggered_webjobs'), ('show', 'show_silo_triggered_webjob'),
+ ('run', 'run_silo_triggered_webjob')),
+ 'webapp silo webjob triggered history': (
+ ('list', 'list_silo_webjob_history'), ('show', 'show_silo_webjob_history')),
+ 'webapp silo site-extension': (
+ ('list', 'list_silo_site_extensions'), ('show', 'show_silo_site_extension')),
+ 'webapp silo extension': (('invoke', 'invoke_silo_extension'),),
+ 'webapp silo network-check': (
+ ('connectivity', 'check_silo_connectivity'), ('dns', 'check_silo_dns'), ('tcp-ping', 'check_silo_tcp')),
+ 'webapp silo instance': (('list', 'list_silo_instances'), ('show', 'show_silo_instance')),
+ 'webapp silo config': (('show', 'show_silo_config'), ('list', 'list_silo_config')),
+ 'webapp silo config snapshot': (
+ ('list', 'list_silo_config_snapshots'), ('show', 'show_silo_config_snapshot')),
+ 'webapp silo config keyvault-reference': (
+ ('list', 'list_silo_keyvault_references'), ('refresh', 'refresh_silo_keyvault_references')),
+ 'webapp silo runtime': (('list-detected', 'list_silo_detected_stacks'),),
+ 'webapp silo config appsettings': (('list', 'list_silo_appsettings'),),
+ 'webapp silo config storage-account': (('list', 'list_silo_storage_accounts'),),
+ 'webapp silo config connection-string': (
+ ('list', 'list_silo_connection_strings'), ('show', 'show_silo_connection_strings')),
+ 'webapp silo auth': (('show', 'show_silo_auth'),),
+ 'webapp silo auth v2': (('show', 'show_silo_auth_v2'),),
+ 'webapp silo config push-settings': (('show', 'show_silo_push_settings'),),
+ 'webapp silo config metadata': (('list', 'list_silo_metadata'),),
+ 'webapp silo premier-addon': (('list', 'list_silo_premier_addons'), ('show', 'show_silo_premier_addon')),
+ 'webapp silo metrics': (('list', 'list_silo_metrics'),),
+ 'webapp silo metrics definition': (('list', 'list_silo_metric_definitions'),),
+ 'webapp silo metrics perf-counter': (('list', 'list_silo_perf_counters'),),
+ 'webapp silo network-trace': (
+ ('start', 'start_silo_network_trace'), ('stop', 'stop_silo_network_trace'),
+ ('show', 'show_silo_network_trace')),
+ 'webapp silo network-trace operation': (('show', 'show_silo_network_trace_operation'),),
+ 'webapp silo runtime-operation': (('show', 'show_silo_runtime_operation'),),
+ 'webapp silo antivirus': (('scan', 'scan_silo_antivirus'),),
+ 'webapp silo antivirus config': (
+ ('show', 'show_silo_antivirus_config'), ('update', 'update_silo_antivirus_config')),
+ 'webapp silo antivirus log': (('list', 'list_silo_antivirus_logs'), ('show', 'show_silo_antivirus_log')),
+ 'webapp silo sitecontainers': (('list', 'list_silo_sitecontainers'), ('show', 'show_silo_sitecontainer')),
+}
+
+
+def load_command_table(loader):
+ from azure.cli.core.commands import CliCommandType
+ from . import active_backup
+ command_type = CliCommandType(operations_tmpl='azure.cli.command_modules.appservice.active_backup#{}')
+ for group, commands in COMMAND_GROUPS.items():
+ with loader.command_group(group, custom_command_type=command_type, is_preview=True,
+ validator=validate_identity) as context:
+ for verb, handler_name in commands:
+ handler = getattr(active_backup, handler_name)
+ options = {}
+ if 'no_wait' in inspect.signature(handler).parameters:
+ options['supports_no_wait'] = True
+ if verb == 'delete':
+ options['confirmation'] = True
+ if group in ('appservice plan silo', 'webapp silo') and verb in ('create', 'update', 'show', 'list'):
+ options['table_transformer'] = active_backup.silo_table
+ register = context.custom_show_command if verb == 'show' else context.custom_command
+ register(verb, handler_name, **options)
+
+
+def load_arguments(loader, command):
+ from azure.cli.core.commands.parameters import get_enum_type, get_three_state_flag, resource_group_name_type
+ from . import active_backup
+ handler_name = next((handler for group, entries in COMMAND_GROUPS.items() for verb, handler in entries
+ if group + ' ' + verb == command), None)
+ if handler_name is None:
+ return
+ parameters = inspect.signature(getattr(active_backup, handler_name)).parameters
+ with loader.argument_context(command) as context:
+ context.argument('resource_group_name', arg_type=resource_group_name_type)
+ context.argument('name', options_list=['--name', '-n'], id_part='name',
+ configured_default=None, local_context_attribute=None, completer=None,
+ help='Name of the primary App Service plan or app, not an internal silo resource.')
+ context.argument('silo', id_part='child_name_1', help='Customer-facing silo name, including origin when supported.')
+ for argument in parameters:
+ if argument in ('cmd', 'name', 'resource_group_name', 'silo', 'no_wait'):
+ continue
+ settings = {'help': argument.replace('_', ' ').capitalize() + '.'}
+ if argument in ('zone_redundant', 'follow_last_known_good', 'force_restart',
+ 'soft_restart', 'synchronous', 'details', 'latest', 'slot_view'):
+ settings['arg_type'] = get_three_state_flag()
+ if argument in ('expand_endpoints', 'overwrite', 'include_secrets', 'metadata_only',
+ 'legacy', 'include_file_paths'):
+ settings['action'] = 'store_true'
+ if argument in ('number_of_workers', 'timeout', 'duration', 'max_frame_length'):
+ settings['type'] = int
+ if argument == 'duration' and handler_name == 'show_silo_startup_summary':
+ settings['type'] = float
+ if argument == 'number_of_workers':
+ settings.update(default=None, help='Silo worker-count override, from 1 to 30. Omit to preserve inheritance.')
+ if argument == 'location':
+ settings['help'] = 'Target silo region. Required on create; never inferred from the resource group.'
+ if argument == 'primary_location':
+ settings['help'] = 'Primary region. On create, resolved from the primary resource when omitted.'
+ if argument == 'timeout':
+ settings['help'] = 'Maximum API-operation wait in seconds. Does not cancel the service operation.'
+ if argument == 'body':
+ settings['help'] = 'Request JSON or @file containing the API request envelope, including properties.'
+ if argument == 'endpoints':
+ settings['help'] = 'JSON array or @file with region and optional weight/priority values (1-1000).'
+ if argument == 'subnet':
+ settings['help'] = 'Full subnet ARM resource ID for the silo network integration.'
+ if argument == 'file':
+ settings['help'] = 'Destination file for response bytes. Existing files require --overwrite.'
+ if argument == 'filter_string':
+ settings.update(options_list=['--filter'], help='API OData metric filter; cannot combine with time/name filters.')
+ if argument == 'names':
+ settings['nargs'] = '+'
+ if argument == 'uri_parameters':
+ settings.update(nargs='+', help='Extension query parameters as KEY=VALUE pairs. May repeat keys.')
+ enums = {'traffic_mode': ['Active', 'Passive'], 'endpoint_status': ['Enabled', 'Disabled'],
+ 'format': ['raw', 'zip'], 'method': ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']}
+ if argument in enums:
+ settings['arg_type'] = get_enum_type(enums[argument])
+ if argument == 'time_grain' and command.startswith('webapp managed-traffic'):
+ settings['arg_type'] = get_enum_type(['PT1M', 'PT1H'])
+ context.argument(argument, **settings)
+
+
+def load_help():
+ from knack.help_files import helps
+ from . import active_backup
+ for group, entries in COMMAND_GROUPS.items():
+ root_length = 3 if group.startswith('appservice plan') else 2
+ words = group.split()
+ for length in range(root_length, len(words) + 1):
+ subgroup = ' '.join(words[:length])
+ helps[subgroup] = (
+ 'type: group\nshort-summary: Manage App Service Active Backup {}.\n'
+ 'long-summary: Operations use public ARM APIs. Service eligibility and not-found errors are preserved.\n'
+ ).format(' '.join(words[root_length:length]) or 'silos')
+ for verb, handler_name in entries:
+ command = group + ' ' + verb
+ parameters = inspect.signature(getattr(active_backup, handler_name)).parameters
+ example = 'az ' + command + ' -g MyResourceGroup -n MyPrimary'
+ if 'silo' in parameters:
+ example += ' --silo MySilo'
+ examples = {'location': 'westus2', 'operation_id': '00000000-0000-0000-0000-000000000001',
+ 'file': 'silo-output.bin', 'body': '@request.json', 'path': 'api/settings',
+ 'instance': 'MyInstance', 'process_id': '123', 'thread_id': '456',
+ 'base_address': '0x1000', 'deployment_id': 'MyDeployment',
+ 'filename': 'startup.log', 'webjob_name': 'MyJob', 'run_id': 'MyRun',
+ 'extension_name': 'MyExtension', 'snapshot_id': '1', 'addon_name': 'MyAddon',
+ 'scan_id': 'MyScan', 'container_name': 'MyContainer'}
+ for argument, parameter in parameters.items():
+ if argument not in ('cmd', 'resource_group_name', 'name', 'silo') and parameter.default is inspect.Parameter.empty:
+ example += ' --{} {}'.format(argument.replace('_', '-'), examples[argument])
+ if verb == 'create':
+ example += ' --primary-location eastus'
+ if group == 'webapp silo':
+ example += ' --traffic-mode Active'
+ if handler_name == 'update_managed_traffic':
+ example += ' --traffic-mode Passive'
+ if handler_name in ('add_silo_vnet_integration', 'update_silo_vnet_integration'):
+ example += ' --body @request.json'
+ description = handler_name.replace('_', ' ').capitalize() + '.'
+ helps[command] = (
+ 'type: command\nshort-summary: {}\n'
+ 'long-summary: Calls the supported silo ARM API without client-side OS or SKU eligibility checks. '
+ 'Read operations can return sensitive configuration. File responses require --file. '
+ 'See https://github.com/Azure/azure-cli/blob/dev/doc/active_backup_preview.md '
+ 'for API bodies and service limitations.\n'
+ 'examples:\n - name: {}\n text: {}\n'
+ ).format(description, description, example)
+ for group in ('appservice plan silo', 'webapp silo'):
+ helps[group] = (
+ 'type: group\nshort-summary: Manage App Service Active Backup silos.\n'
+ 'long-summary: >-\n'
+ ' Preview creation targets Windows/Linux dedicated multitenant PremiumV3, PremiumMV3, '
+ 'PremiumV4 and PremiumMV4 plans. P0v3 is excluded. Regional subscription/flavor enablement, '
+ 'slot restrictions and dependency ordering are service-enforced. Creating the first silo '
+ 'can restart the primary app. Pass-through operations are not filtered by OS or SKU.\n')
+ helps['webapp silo refresh'] = """
+type: command
+short-summary: Request a fresh revision and asynchronous propagation to the app's silos.
+long-summary: Returns successfully on HTTP 204 acceptance. Does not wait for revision convergence or invent a target revision.
+examples:
+ - name: Request refresh for all silos of a primary app.
+ text: az webapp silo refresh -g MyResourceGroup -n MyPrimary
+"""
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py
index 7240edab63e..e1eb51b8b2c 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py
@@ -5,6 +5,8 @@
# --------------------------------------------------------------------------------------------
from knack.help_files import helps # pylint: disable=unused-import
+from ._active_backup_commands import load_help as load_active_backup_help
+load_active_backup_help()
# pylint: disable=line-too-long, too-many-lines
helps['appservice'] = """
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_params.py b/src/azure-cli/azure/cli/command_modules/appservice/_params.py
index 52946dbb083..d0338488d11 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py
@@ -52,6 +52,8 @@
def load_arguments(self, _):
+ from ._active_backup_commands import load_arguments as load_active_backup_arguments
+ load_active_backup_arguments(self, _)
# pylint: disable=line-too-long
# PARAMETER REGISTRATION
name_arg_type = CLIArgumentType(options_list=['--name', '-n'], metavar='NAME')
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/active_backup.py b/src/azure-cli/azure/cli/command_modules/appservice/active_backup.py
new file mode 100644
index 00000000000..80649792c8d
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/active_backup.py
@@ -0,0 +1,677 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+# --------------------------------------------------------------------------------------------
+
+from urllib.parse import quote, unquote
+
+from knack.log import get_logger
+
+from azure.cli.core.azclierror import AzureResponseError, FileOperationError, InvalidArgumentValueError
+
+from ._active_backup_client import ActiveBackupClient, DEFAULT_TIMEOUT, load_json, segment
+
+
+logger = get_logger(__name__)
+
+
+def _client(cmd, resource_group_name, name, plan=False):
+ return ActiveBackupClient(cmd.cli_ctx, resource_group_name, name, 'serverfarms' if plan else 'sites')
+
+
+def _boolean(value):
+ if value is None or isinstance(value, bool):
+ return value
+ if str(value).lower() in ('true', 'false'):
+ return str(value).lower() == 'true'
+ raise InvalidArgumentValueError('Provide true or false.')
+
+
+def _primary_location(cmd, resource_group_name, name, plan):
+ from ._client_factory import web_client_factory
+ client = web_client_factory(cmd.cli_ctx)
+ operations = client.app_service_plans if plan else client.web_apps
+ return operations.get(resource_group_name, name).location
+
+
+def _silo_body(cmd, resource_group_name, name, silo, location, primary_location, properties, plan, update):
+ client = _client(cmd, resource_group_name, name, plan)
+ if update:
+ existing = client.decode(client.request('GET', client.resource(silo)))
+ if not isinstance(existing, dict) or not isinstance(existing.get('properties'), dict):
+ raise AzureResponseError('The silo API did not return the resource properties required for update.')
+ existing_properties = existing['properties']
+ location = location or existing.get('location') or existing_properties.get('siloLocation')
+ primary_location = primary_location or existing_properties.get('primaryLocation')
+ elif not primary_location:
+ primary_location = _primary_location(cmd, resource_group_name, name, plan)
+ if not location or not primary_location:
+ raise InvalidArgumentValueError('Provide --location and --primary-location for the silo.')
+ properties = {key: value for key, value in properties.items() if value is not None}
+ properties.update(name=silo, primaryLocation=primary_location)
+ return client, {'location': location, 'properties': properties}
+
+
+def _plan_properties(number_of_workers, zone_redundant):
+ if number_of_workers is not None and not 1 <= number_of_workers <= 30:
+ raise InvalidArgumentValueError('--number-of-workers must be between 1 and 30.')
+ return {'instanceNumberOverride': number_of_workers, 'zoneRedundantOverride': _boolean(zone_redundant)}
+
+
+def create_plan_silo(cmd, resource_group_name, name, silo, location, primary_location=None,
+ number_of_workers=None, zone_redundant=None, no_wait=False, timeout=DEFAULT_TIMEOUT):
+ client, body = _silo_body(cmd, resource_group_name, name, silo, location, primary_location,
+ _plan_properties(number_of_workers, zone_redundant), True, False)
+ return client.mutate('PUT', client.resource(silo), body, no_wait, timeout)
+
+
+def update_plan_silo(cmd, resource_group_name, name, silo, location=None, primary_location=None,
+ number_of_workers=None, zone_redundant=None, no_wait=False, timeout=DEFAULT_TIMEOUT):
+ client, body = _silo_body(cmd, resource_group_name, name, silo, location, primary_location,
+ _plan_properties(number_of_workers, zone_redundant), True, True)
+ return client.mutate('PUT', client.resource(silo), body, no_wait, timeout)
+
+
+def show_plan_silo(cmd, resource_group_name, name, silo):
+ client = _client(cmd, resource_group_name, name, True)
+ return client.decode(client.request('GET', client.resource(silo)))
+
+
+def list_plan_silos(cmd, resource_group_name, name):
+ client = _client(cmd, resource_group_name, name, True)
+ return client.list(client.resource())
+
+
+def delete_plan_silo(cmd, resource_group_name, name, silo, no_wait=False, timeout=DEFAULT_TIMEOUT):
+ client = _client(cmd, resource_group_name, name, True)
+ return client.mutate('DELETE', client.resource(silo), no_wait=no_wait, timeout=timeout)
+
+
+def show_plan_silo_operation(cmd, resource_group_name, name, silo, operation_id):
+ client = _client(cmd, resource_group_name, name, True)
+ return client.operation(client.resource(silo) + '/operationresults/' + segment(operation_id))
+
+
+def wait_plan_silo(cmd, resource_group_name, name, silo, operation_id, timeout=DEFAULT_TIMEOUT):
+ client = _client(cmd, resource_group_name, name, True)
+ return client.wait(client.resource(silo) + '/operationresults/' + segment(operation_id), timeout)
+
+
+def list_plan_silo_vnet_integrations(cmd, resource_group_name, name, silo):
+ client = _client(cmd, resource_group_name, name, True)
+ return client.decode(client.request('GET', client.resource(silo) + '/virtualNetworkIntegrations'))
+
+
+def _site_properties(endpoint_status, follow_last_known_good, subnet):
+ properties = {'endpointStatus': endpoint_status, 'followLastKnownGood': _boolean(follow_last_known_good)}
+ if subnet is not None:
+ properties['networkingOverride'] = {'vnetIntegrationOverride': subnet}
+ return properties
+
+
+def create_site_silo(cmd, resource_group_name, name, silo, location, primary_location=None,
+ traffic_mode=None, endpoint_status=None, follow_last_known_good=None, subnet=None,
+ no_wait=False, timeout=DEFAULT_TIMEOUT):
+ properties = _site_properties(endpoint_status, follow_last_known_good, subnet)
+ properties['trafficMode'] = traffic_mode
+ client, body = _silo_body(cmd, resource_group_name, name, silo, location, primary_location,
+ properties, False, False)
+ return client.mutate('PUT', client.resource(silo), body, no_wait, timeout)
+
+
+def update_site_silo(cmd, resource_group_name, name, silo, location=None, primary_location=None,
+ endpoint_status=None, follow_last_known_good=None, subnet=None,
+ no_wait=False, timeout=DEFAULT_TIMEOUT):
+ client, body = _silo_body(cmd, resource_group_name, name, silo, location, primary_location,
+ _site_properties(endpoint_status, follow_last_known_good, subnet), False, True)
+ return client.mutate('PUT', client.resource(silo), body, no_wait, timeout)
+
+
+def show_site_silo(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo)
+
+
+def list_site_silos(cmd, resource_group_name, name):
+ client = _client(cmd, resource_group_name, name)
+ return client.list(client.resource())
+
+
+def delete_site_silo(cmd, resource_group_name, name, silo, no_wait=False, timeout=DEFAULT_TIMEOUT):
+ client = _client(cmd, resource_group_name, name)
+ return client.mutate('DELETE', client.resource(silo), no_wait=no_wait, timeout=timeout)
+
+
+def show_site_silo_operation(cmd, resource_group_name, name, silo, operation_id):
+ client = _client(cmd, resource_group_name, name)
+ return client.operation(client.resource(silo) + '/operationresults/' + segment(operation_id))
+
+
+def wait_site_silo(cmd, resource_group_name, name, silo, operation_id, timeout=DEFAULT_TIMEOUT):
+ client = _client(cmd, resource_group_name, name)
+ return client.wait(client.resource(silo) + '/operationresults/' + segment(operation_id), timeout)
+
+
+def refresh_site_silos(cmd, resource_group_name, name):
+ client = _client(cmd, resource_group_name, name)
+ response = client.request('POST', client.parent + '/refreshSilos')
+ if response.status_code != 204:
+ raise AzureResponseError('The refresh API did not return the expected 204 acceptance response.')
+ logger.warning('Refresh request accepted. Revision propagation continues asynchronously and may take a few '
+ 'minutes. Use silo show/list to inspect revision numbers.')
+
+
+def list_last_known_good(cmd, resource_group_name, name):
+ client = _client(cmd, resource_group_name, name)
+ return client.list(client.parent + '/lastKnownGood')
+
+
+def show_last_known_good(cmd, resource_group_name, name):
+ client = _client(cmd, resource_group_name, name)
+ return client.decode(client.request('GET', client.parent + '/lastKnownGood/default'))
+
+
+def mark_last_known_good(cmd, resource_group_name, name):
+ client = _client(cmd, resource_group_name, name)
+ return client.decode(client.request('POST', client.parent + '/lastKnownGood/default/mark'))
+
+
+def list_managed_traffic(cmd, resource_group_name, name, expand_endpoints=False):
+ client = _client(cmd, resource_group_name, name)
+ return client.list(client.parent + '/managedTrafficConfig',
+ {'$expand': 'endpoints'} if expand_endpoints else None)
+
+
+def show_managed_traffic(cmd, resource_group_name, name):
+ client = _client(cmd, resource_group_name, name)
+ return client.decode(client.request('GET', client.parent + '/managedTrafficConfig/default'))
+
+
+def update_managed_traffic(cmd, resource_group_name, name, traffic_mode=None, endpoints=None):
+ properties = {}
+ if traffic_mode is not None:
+ properties['trafficMode'] = traffic_mode
+ if endpoints is not None:
+ values = load_json(endpoints, list)
+ for endpoint in values:
+ if not isinstance(endpoint, dict) or not isinstance(endpoint.get('region'), str):
+ raise InvalidArgumentValueError('Each endpoint must be an object with a region.')
+ if set(endpoint) - {'region', 'weight', 'priority'}:
+ raise InvalidArgumentValueError('Endpoint inputs support region, weight, and priority.')
+ for key in ('weight', 'priority'):
+ value = endpoint.get(key)
+ if value is not None and (type(value) is not int or not 1 <= value <= 1000):
+ raise InvalidArgumentValueError('Endpoint {} must be an integer from 1 to 1000.'.format(key))
+ properties['endpoints'] = values
+ if not properties:
+ raise InvalidArgumentValueError('Provide --traffic-mode or --endpoints.')
+ client = _client(cmd, resource_group_name, name)
+ return client.decode(client.request(
+ 'PUT', client.parent + '/managedTrafficConfig/default', {'properties': properties}))
+
+
+def _metric_query(start_time=None, end_time=None, time_grain=None, names=None, filter_string=None):
+ if filter_string:
+ if any(value is not None for value in (start_time, end_time, time_grain, names)):
+ raise InvalidArgumentValueError('Use --filter or individual metric filter arguments, not both.')
+ return {'$filter': filter_string}
+ values = []
+ for key, value in (('startTime', start_time), ('endTime', end_time), ('timeGrain', time_grain)):
+ if value is not None:
+ if "'" in value:
+ raise InvalidArgumentValueError('Metric filter values must not contain quotes.')
+ values.append("{} eq {}'{}'".format(key, 'duration' if key == 'timeGrain' else '', value))
+ if names:
+ name_filters = ["name.value eq '{}'".format(value.replace("'", "''")) for value in names]
+ values.append('(' + ' or '.join(name_filters) + ')')
+ return {'$filter': ' and '.join(values)} if values else {}
+
+
+def list_managed_traffic_metrics(cmd, resource_group_name, name, start_time=None, end_time=None, time_grain=None):
+ client = _client(cmd, resource_group_name, name)
+ return client.decode(client.request('GET', client.parent + '/managedTrafficConfig/default/metrics',
+ query=_metric_query(start_time, end_time, time_grain)))
+
+
+def show_silo_vnet_integration(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/virtualNetworkConnections/virtualNetwork')
+
+
+def _vnet(cmd, resource_group_name, name, silo, method, subnet, body):
+ envelope = load_json(body) if body is not None else {'properties': {}}
+ if not isinstance(envelope.get('properties'), dict):
+ raise InvalidArgumentValueError('--body must contain a properties object.')
+ if subnet is not None:
+ envelope['properties']['subnetResourceId'] = subnet
+ return _site(cmd, resource_group_name, name, silo, '/virtualNetworkConnections/virtualNetwork',
+ method=method, body=envelope)
+
+
+def add_silo_vnet_integration(cmd, resource_group_name, name, silo, subnet=None, body=None):
+ if subnet is None and body is None:
+ raise InvalidArgumentValueError('Provide --subnet or --body.')
+ return _vnet(cmd, resource_group_name, name, silo, 'PUT', subnet, body)
+
+
+def update_silo_vnet_integration(cmd, resource_group_name, name, silo, subnet=None, body=None):
+ if subnet is None and body is None:
+ raise InvalidArgumentValueError('Provide --subnet or --body.')
+ return _vnet(cmd, resource_group_name, name, silo, 'PATCH', subnet, body)
+
+
+def remove_silo_vnet_integration(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/virtualNetworkConnections/virtualNetwork', method='DELETE')
+
+
+def _site(cmd, resource_group_name, name, silo, suffix='', method='GET', query=None, body=None,
+ filename=None, overwrite=False, tail=False, content_type=None, no_wait=False, timeout=None):
+ client = _client(cmd, resource_group_name, name)
+ path = client.resource(silo) + suffix
+ if filename is not None or tail:
+ return client.consume(method, path, filename, overwrite, query, body, content_type, tail)
+ if timeout is not None:
+ return client.mutate(method, path, body, no_wait, timeout, query)
+ return client.decode(client.request(method, path, body, query, content_type=content_type))
+
+
+def _instance(instance):
+ return '/instances/' + segment(instance) if instance is not None else ''
+
+
+def _process(process_id, instance=None):
+ return _instance(instance) + '/processes/' + segment(process_id)
+
+
+def show_silo_php_logging(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/phplogging')
+
+
+def list_silo_deployments(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/deployments')
+
+
+def show_silo_deployment(cmd, resource_group_name, name, silo, deployment_id):
+ return _site(cmd, resource_group_name, name, silo, '/deployments/' + segment(deployment_id))
+
+
+def list_silo_deployment_logs(cmd, resource_group_name, name, silo, deployment_id):
+ return _site(cmd, resource_group_name, name, silo, '/deployments/' + segment(deployment_id) + '/log')
+
+
+def delete_silo_process(cmd, resource_group_name, name, silo, process_id, instance=None):
+ return _site(cmd, resource_group_name, name, silo, _process(process_id, instance), method='DELETE')
+
+
+def stop_silo_process(cmd, resource_group_name, name, silo, process_id, instance=None):
+ return _site(cmd, resource_group_name, name, silo, _process(process_id, instance) + '/stop', method='POST')
+
+
+def restart_silo_container(cmd, resource_group_name, name, silo, container_name, instance=None):
+ return _site(cmd, resource_group_name, name, silo,
+ _instance(instance) + '/containers/' + segment(container_name) + '/restart', method='POST')
+
+
+def show_silo_process(cmd, resource_group_name, name, silo, process_id, instance=None):
+ return _site(cmd, resource_group_name, name, silo, _process(process_id, instance))
+
+
+def list_silo_process_threads(cmd, resource_group_name, name, silo, process_id, instance=None):
+ return _site(cmd, resource_group_name, name, silo, _process(process_id, instance) + '/threads')
+
+
+def show_silo_process_thread(cmd, resource_group_name, name, silo, process_id, thread_id, instance=None):
+ return _site(cmd, resource_group_name, name, silo,
+ _process(process_id, instance) + '/threads/' + segment(thread_id))
+
+
+def list_silo_process_modules(cmd, resource_group_name, name, silo, process_id, instance=None):
+ return _site(cmd, resource_group_name, name, silo, _process(process_id, instance) + '/modules')
+
+
+def show_silo_process_module(cmd, resource_group_name, name, silo, process_id, base_address, instance=None):
+ return _site(cmd, resource_group_name, name, silo,
+ _process(process_id, instance) + '/modules/' + segment(base_address))
+
+
+def dump_silo_process(cmd, resource_group_name, name, silo, process_id, file, instance=None, overwrite=False):
+ return _site(cmd, resource_group_name, name, silo, _process(process_id, instance) + '/dump',
+ filename=file, overwrite=overwrite)
+
+
+def download_silo_container_logs(cmd, resource_group_name, name, silo, file, format='zip', overwrite=False):
+ return _site(cmd, resource_group_name, name, silo, '/containerlogs' + ('/zip' if format == 'zip' else ''),
+ method='POST', filename=file, overwrite=overwrite)
+
+
+def list_silo_startup_logs(cmd, resource_group_name, name, silo, log_type=None, instance=None, latest=None):
+ return _site(cmd, resource_group_name, name, silo, '/startuplogs',
+ query={'type': log_type, 'targetSiteInstance': instance, 'latest': _boolean(latest)})
+
+
+def show_silo_startup_summary(cmd, resource_group_name, name, silo, duration=None, instance=None):
+ return _site(cmd, resource_group_name, name, silo, '/startuplogs/summary',
+ query={'duration': duration, 'targetSiteInstance': instance})
+
+
+def show_silo_startup_log(cmd, resource_group_name, name, silo, filename):
+ return _site(cmd, resource_group_name, name, silo, '/startuplogs/' + segment(filename))
+
+
+def list_silo_logs(cmd, resource_group_name, name, silo, instance=None):
+ return _site(cmd, resource_group_name, name, silo, '/logs', query={'instanceId': instance})
+
+
+def tail_silo_logs(cmd, resource_group_name, name, silo, instance=None, file=None, overwrite=False):
+ return _site(cmd, resource_group_name, name, silo, _instance(instance) + '/logs/live',
+ filename=file, overwrite=overwrite, tail=True)
+
+
+def _webjob(webjob_name, kind='webjobs'):
+ return '/' + kind + '/' + segment(webjob_name)
+
+
+def list_silo_webjobs(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/webjobs')
+
+
+def show_silo_webjob(cmd, resource_group_name, name, silo, webjob_name):
+ return _site(cmd, resource_group_name, name, silo, _webjob(webjob_name))
+
+
+def list_silo_continuous_webjobs(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/continuouswebjobs')
+
+
+def show_silo_continuous_webjob(cmd, resource_group_name, name, silo, webjob_name):
+ return _site(cmd, resource_group_name, name, silo, _webjob(webjob_name, 'continuouswebjobs'))
+
+
+def start_silo_continuous_webjob(cmd, resource_group_name, name, silo, webjob_name):
+ return _site(cmd, resource_group_name, name, silo, _webjob(webjob_name, 'continuouswebjobs') + '/start',
+ method='POST')
+
+
+def stop_silo_continuous_webjob(cmd, resource_group_name, name, silo, webjob_name):
+ return _site(cmd, resource_group_name, name, silo, _webjob(webjob_name, 'continuouswebjobs') + '/stop',
+ method='POST')
+
+
+def list_silo_triggered_webjobs(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/triggeredwebjobs')
+
+
+def show_silo_triggered_webjob(cmd, resource_group_name, name, silo, webjob_name):
+ return _site(cmd, resource_group_name, name, silo, _webjob(webjob_name, 'triggeredwebjobs'))
+
+
+def list_silo_webjob_history(cmd, resource_group_name, name, silo, webjob_name):
+ return _site(cmd, resource_group_name, name, silo, _webjob(webjob_name, 'triggeredwebjobs') + '/history')
+
+
+def show_silo_webjob_history(cmd, resource_group_name, name, silo, webjob_name, run_id):
+ return _site(cmd, resource_group_name, name, silo,
+ _webjob(webjob_name, 'triggeredwebjobs') + '/history/' + segment(run_id))
+
+
+def run_silo_triggered_webjob(cmd, resource_group_name, name, silo, webjob_name):
+ return _site(cmd, resource_group_name, name, silo, _webjob(webjob_name, 'triggeredwebjobs') + '/run',
+ method='POST')
+
+
+def list_silo_site_extensions(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/siteextensions')
+
+
+def show_silo_site_extension(cmd, resource_group_name, name, silo, extension_name):
+ return _site(cmd, resource_group_name, name, silo, '/siteextensions/' + segment(extension_name))
+
+
+def check_silo_connectivity(cmd, resource_group_name, name, silo, body):
+ return _site(cmd, resource_group_name, name, silo, '/connectivityCheck', method='POST', body=load_json(body))
+
+
+def check_silo_dns(cmd, resource_group_name, name, silo, body):
+ return _site(cmd, resource_group_name, name, silo, '/dnsCheck', method='POST', body=load_json(body))
+
+
+def check_silo_tcp(cmd, resource_group_name, name, silo, body):
+ return _site(cmd, resource_group_name, name, silo, '/tcpPingCheck', method='POST', body=load_json(body))
+
+
+def invoke_silo_extension(cmd, resource_group_name, name, silo, instance, path, method='GET',
+ body=None, content_type=None, file=None, overwrite=False, uri_parameters=None):
+ if method.upper() not in ('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'):
+ raise InvalidArgumentValueError('Provide a supported HTTP method.')
+ if not path or path.startswith('/') or '\\' in path or '?' in path or '#' in path:
+ raise InvalidArgumentValueError('--path must be a relative extension path without a query or fragment.')
+ parts = path.split('/')
+ for part in parts:
+ decoded = part
+ for _ in range(3):
+ decoded = unquote(decoded)
+ segment(decoded)
+ suffix = _instance(instance) + '/extensions/' + '/'.join(quote(unquote(part), safe='') for part in parts)
+ if body is not None and (not content_type or 'json' in content_type.lower()):
+ body = load_json(body, expected_type=None)
+ if body is None:
+ body = b'null'
+ elif isinstance(body, str) and body.startswith('@'):
+ try:
+ with open(body[1:], 'rb') as handle:
+ body = handle.read()
+ except OSError as ex:
+ raise FileOperationError('Unable to read the request body file: {}.'.format(ex.strerror)) from ex
+ query = {}
+ for parameter in uri_parameters or []:
+ key, separator, value = parameter.partition('=')
+ if not key or not separator or key.lower() == 'api-version':
+ raise InvalidArgumentValueError('--uri-parameters accepts KEY=VALUE pairs other than api-version.')
+ query.setdefault(key, []).append(value)
+ return _site(cmd, resource_group_name, name, silo, suffix, method=method.upper(), body=body,
+ content_type=content_type, filename=file, overwrite=overwrite, query=query)
+
+
+def show_silo_onedeploy_status(cmd, resource_group_name, name, silo, instance=None):
+ return _site(cmd, resource_group_name, name, silo, '/extensions/onedeploy', query={'instanceId': instance})
+
+
+def show_silo_msdeploy_log(cmd, resource_group_name, name, silo, instance=None):
+ return _site(cmd, resource_group_name, name, silo, '/extensions/MSDeploy/log', query={'instanceId': instance})
+
+
+def list_silo_instances(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/instances')
+
+
+def show_silo_instance(cmd, resource_group_name, name, silo, instance):
+ return _site(cmd, resource_group_name, name, silo, '/instances/' + segment(instance))
+
+
+def show_silo_config(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/config/web')
+
+
+def list_silo_config(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/config')
+
+
+def list_silo_config_snapshots(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/config/web/snapshots')
+
+
+def show_silo_config_snapshot(cmd, resource_group_name, name, silo, snapshot_id, include_secrets=False):
+ return _site(cmd, resource_group_name, name, silo,
+ '/config/web/snapshots/' + segment(snapshot_id) + ('/listsecrets' if include_secrets else ''),
+ method='POST' if include_secrets else 'GET')
+
+
+def list_silo_keyvault_references(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/config/configreferences/connectionstrings')
+
+
+def refresh_silo_keyvault_references(cmd, resource_group_name, name, silo, force_restart=None):
+ return _site(cmd, resource_group_name, name, silo, '/config/configreferences/appsettings/refresh',
+ method='POST', query={'forceRestart': _boolean(force_restart)})
+
+
+def list_silo_detected_stacks(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/detectedstacks')
+
+
+def list_silo_appsettings(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/config/appsettings/list', method='POST')
+
+
+def list_silo_storage_accounts(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/config/azurestorageaccounts/list', method='POST')
+
+
+def show_silo_connection_strings(cmd, resource_group_name, name, silo, key=None):
+ suffix = '/config/web/connectionstrings/' + segment(key) if key is not None else '/config/connectionstrings'
+ return _site(cmd, resource_group_name, name, silo, suffix, method='POST' if key is not None else 'GET')
+
+
+def list_silo_connection_strings(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/config/connectionstrings/list', method='POST')
+
+
+def show_silo_auth(cmd, resource_group_name, name, silo, metadata_only=False):
+ return _site(cmd, resource_group_name, name, silo,
+ '/config/authsettings' + ('' if metadata_only else '/list'),
+ method='GET' if metadata_only else 'POST')
+
+
+def show_silo_auth_v2(cmd, resource_group_name, name, silo, legacy=False):
+ return _site(cmd, resource_group_name, name, silo, '/config/authsettingsV2' + ('/list' if legacy else ''))
+
+
+def show_silo_push_settings(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/config/pushsettings/list', method='POST')
+
+
+def list_silo_metadata(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/config/metadata/list', method='POST')
+
+
+def show_silo_log_config(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/config/logs')
+
+
+def list_silo_premier_addons(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/premieraddons')
+
+
+def show_silo_premier_addon(cmd, resource_group_name, name, silo, addon_name):
+ return _site(cmd, resource_group_name, name, silo, '/premieraddons/' + segment(addon_name))
+
+
+def list_silo_metrics(cmd, resource_group_name, name, silo, start_time=None, end_time=None,
+ time_grain=None, names=None, filter_string=None, details=None, slot_view=None):
+ query = _metric_query(start_time, end_time, time_grain, names, filter_string)
+ query['details'] = _boolean(details)
+ query['slotView'] = _boolean(slot_view)
+ return _site(cmd, resource_group_name, name, silo, '/metrics', query=query)
+
+
+def list_silo_metric_definitions(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/metricdefinitions')
+
+
+def list_silo_perf_counters(
+ cmd, resource_group_name, name, silo, start_time=None, end_time=None,
+ time_grain=None, names=None, filter_string=None):
+ return _site(cmd, resource_group_name, name, silo, '/perfcounters',
+ query=_metric_query(start_time, end_time, time_grain, names, filter_string))
+
+
+def restart_site_silo(
+ cmd, resource_group_name, name, silo, soft_restart=None, synchronous=None,
+ no_wait=False, timeout=DEFAULT_TIMEOUT):
+ return _site(cmd, resource_group_name, name, silo, '/restart', method='POST',
+ query={'softRestart': _boolean(soft_restart), 'synchronous': _boolean(synchronous)},
+ no_wait=no_wait, timeout=timeout)
+
+
+def start_site_silo(cmd, resource_group_name, name, silo, no_wait=False, timeout=DEFAULT_TIMEOUT):
+ return _site(cmd, resource_group_name, name, silo, '/start', method='POST', no_wait=no_wait, timeout=timeout)
+
+
+def stop_site_silo(cmd, resource_group_name, name, silo, no_wait=False, timeout=DEFAULT_TIMEOUT):
+ return _site(cmd, resource_group_name, name, silo, '/stop', method='POST', no_wait=no_wait, timeout=timeout)
+
+
+def start_silo_network_trace(
+ cmd, resource_group_name, name, silo, duration=None, max_frame_length=None,
+ sas_url=None, legacy=False, no_wait=False, timeout=DEFAULT_TIMEOUT):
+ return _site(cmd, resource_group_name, name, silo, '/networkTrace/start' + ('' if legacy else 'Operation'),
+ method='POST', query={
+ 'durationInSeconds': duration, 'maxFrameLength': max_frame_length, 'sasUrl': sas_url},
+ no_wait=no_wait, timeout=timeout)
+
+
+def stop_silo_network_trace(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/networkTrace/stop', method='POST')
+
+
+def show_silo_network_trace_operation(cmd, resource_group_name, name, silo, operation_id):
+ return _site(cmd, resource_group_name, name, silo, '/networkTrace/operationresults/' + segment(operation_id))
+
+
+def show_silo_network_trace(cmd, resource_group_name, name, silo, operation_id):
+ return _site(cmd, resource_group_name, name, silo, '/networkTrace/' + segment(operation_id))
+
+
+def show_silo_runtime_operation(cmd, resource_group_name, name, silo, operation_id):
+ return _site(cmd, resource_group_name, name, silo, '/operations/' + segment(operation_id))
+
+
+def scan_silo_antivirus(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/antivirus/queue', method='POST')
+
+
+def show_silo_antivirus_config(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/antivirus/config')
+
+
+def update_silo_antivirus_config(cmd, resource_group_name, name, silo, body):
+ return _site(cmd, resource_group_name, name, silo, '/antivirus/config', method='PUT', body=load_json(body))
+
+
+def list_silo_antivirus_logs(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/antiviruslogs')
+
+
+def show_silo_antivirus_log(cmd, resource_group_name, name, silo, scan_id, include_file_paths=False):
+ return _site(cmd, resource_group_name, name, silo,
+ '/antiviruslogs/' + segment(scan_id) + ('/list' if include_file_paths else ''),
+ method='POST' if include_file_paths else 'GET')
+
+
+def list_silo_deployment_statuses(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/deploymentStatus')
+
+
+def list_silo_sitecontainers(cmd, resource_group_name, name, silo):
+ return _site(cmd, resource_group_name, name, silo, '/sitecontainers')
+
+
+def show_silo_sitecontainer(cmd, resource_group_name, name, silo, container_name):
+ return _site(cmd, resource_group_name, name, silo, '/sitecontainers/' + segment(container_name))
+
+
+def silo_table(result):
+ rows = result if isinstance(result, list) else [result]
+ output = []
+ for row in rows:
+ if row.get('status') == 'Accepted':
+ output.append({'Status': 'Accepted', 'Operation': row.get('operationId'),
+ 'Resource': row.get('resourceId')})
+ continue
+ properties = row.get('properties') or {}
+ output.append({
+ 'Name': row.get('name'), 'Location': row.get('location'),
+ 'State': properties.get('provisioningState'), 'Revision': properties.get('revisionNumber'),
+ 'FollowLastKnownGood': properties.get('followLastKnownGood'),
+ 'Endpoint': properties.get('endpointStatus'), 'Monitor': properties.get('monitorStatus'),
+ 'Error': properties.get('errorCode'),
+ })
+ return output
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/commands.py b/src/azure-cli/azure/cli/command_modules/appservice/commands.py
index cd4aac51c11..4eca0b8a50a 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/commands.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/commands.py
@@ -284,6 +284,8 @@ def _polish_bad_errors(ex, creating_plan):
# pylint: disable=too-many-statements
def load_command_table(self, _):
+ from ._active_backup_commands import load_command_table as load_active_backup_commands
+ load_active_backup_commands(self)
webclient_sdk = CliCommandType(
operations_tmpl='azure.mgmt.web.operations#WebSiteManagementClientOperationsMixin.{}',
client_factory=cf_web_client
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_client.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_client.py
new file mode 100644
index 00000000000..ade3765d41a
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_client.py
@@ -0,0 +1,353 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+# --------------------------------------------------------------------------------------------
+
+import io
+import json
+from datetime import datetime, timezone
+from pathlib import Path
+import tempfile
+import unittest
+from unittest.mock import MagicMock, patch
+from urllib.parse import parse_qs, urlsplit
+
+import requests
+
+from azure.cli.core.azclierror import (AzureConnectionError, AzureResponseError, FileOperationError,
+ InvalidArgumentValueError, ResourceNotFoundError)
+from azure.cli.command_modules.appservice._active_backup_client import (
+ ActiveBackupClient, API_VERSION, load_json, segment)
+
+
+MODULE = 'azure.cli.command_modules.appservice._active_backup_client'
+
+
+def response(status=200, payload=None, headers=None, content=None):
+ result = requests.Response()
+ result.status_code = status
+ result.headers.update({'Content-Type': 'application/json'})
+ result.headers.update(headers or {})
+ result._content = (json.dumps(payload).encode('utf-8') if payload is not None else b'') if content is None else content
+ result._content_consumed = True
+ result.encoding = 'utf-8'
+ return result
+
+
+def cli_context():
+ cli_ctx = MagicMock()
+ cli_ctx.cloud.endpoints.resource_manager = 'https://management.example.test/'
+ cli_ctx.cloud.endpoints.active_directory_resource_id = 'https://audience.example.test/'
+ cli_ctx.data = {'command': 'webapp silo show'}
+ return cli_ctx
+
+
+class ActiveBackupClientTests(unittest.TestCase):
+
+ def setUp(self):
+ self.ctx = cli_context()
+ self.client = ActiveBackupClient(self.ctx, 'group', 'primary', subscription='subscription')
+ self.resource = self.client.resource('backup')
+
+ def test_resource_identity_and_encoding(self):
+ self.assertEqual(self.resource,
+ '/subscriptions/subscription/resourceGroups/group/providers/Microsoft.Web/sites/primary/silos/backup')
+ client = ActiveBackupClient(self.ctx, 'a group', 'plan', 'serverfarms', subscription='sub')
+ self.assertIn('/resourceGroups/a%20group/providers/Microsoft.Web/serverfarms/plan', client.resource())
+ self.assertEqual(segment('x?#%'), 'x%3F%23%25')
+ for value in ('', '..', '.', 'a/b', 'a\\b', 'a\nb', None):
+ with self.subTest(value=value), self.assertRaises(InvalidArgumentValueError):
+ segment(value)
+
+ def test_api_version_query_and_origin(self):
+ url = self.client.url(self.resource, {'enabled': False, 'count': 0, 'missing': None})
+ query = parse_qs(urlsplit(url).query)
+ self.assertEqual(query, {'api-version': [API_VERSION], 'enabled': ['false'], 'count': ['0']})
+ supplied = self.client.url(self.resource + '?api-version=2026-01-01&x=1')
+ self.assertEqual(parse_qs(urlsplit(supplied).query)['api-version'], ['2026-01-01'])
+ for bad in ('https://evil.test/path', '//evil.test/path',
+ 'http://management.example.test/path', 'https://user@management.example.test/path',
+ 'https://management.example.test/path#fragment', 'https://management.example.test:0/path'):
+ with self.subTest(address=bad), self.assertRaises(AzureResponseError):
+ self.client.url(bad)
+
+ def test_request_uses_cli_auth_cloud_and_no_redirects(self):
+ result = response(payload={'properties': {'secret': 'not-logged'}})
+ with patch(MODULE + '.Profile') as profile, patch(MODULE + '.requests.Session') as factory, \
+ patch(MODULE + '.get_az_user_agent', return_value='test-cli'), \
+ patch(MODULE + '.should_disable_connection_verify', return_value=False):
+ profile.return_value.get_raw_token.return_value = (('Bearer', 'private-token', {}), 'sub', 'tenant')
+ session = factory.return_value.__enter__.return_value
+ session.request.return_value = result
+ actual = self.client.request('PUT', self.resource, {'properties': {'enabled': False}})
+ self.assertEqual(self.client.decode(actual), {'properties': {'secret': 'not-logged'}})
+ call = session.request.call_args
+ self.assertEqual(call.args[0], 'PUT')
+ self.assertTrue(call.args[1].startswith(self.ctx.cloud.endpoints.resource_manager))
+ self.assertFalse(call.kwargs['allow_redirects'])
+ self.assertTrue(call.kwargs['verify'])
+ self.assertEqual(call.kwargs['headers']['Authorization'], 'Bearer private-token')
+ self.assertEqual(json.loads(call.kwargs['data']), {'properties': {'enabled': False}})
+ profile.return_value.get_raw_token.assert_called_once_with(
+ resource='https://audience.example.test/', subscription='subscription')
+
+ def test_service_errors_preserve_code_and_request_id(self):
+ for status, error_type in ((404, ResourceNotFoundError), (409, AzureResponseError), (500, AzureResponseError)):
+ failed = response(status, {'error': {'code': 'SiloBusy', 'message': 'Try again.'}},
+ {'x-ms-request-id': 'request-one'})
+ with self.subTest(status=status), patch(MODULE + '.Profile') as profile, \
+ patch(MODULE + '.requests.Session') as factory:
+ profile.return_value.get_raw_token.return_value = (('Bearer', 'token', {}), None, None)
+ factory.return_value.__enter__.return_value.request.return_value = failed
+ with self.assertRaises(error_type) as caught:
+ self.client.request('GET', self.resource)
+ self.assertIn('SiloBusy', str(caught.exception))
+ self.assertIn('request-one', str(caught.exception))
+ self.assertEqual(caught.exception.response.status_code, status)
+ legacy = response(400, {'Code': 'LegacyFailure', 'Message': 'Legacy detail.'})
+ with patch(MODULE + '.Profile') as profile, patch(MODULE + '.requests.Session') as factory:
+ profile.return_value.get_raw_token.return_value = (('Bearer', 'token', {}), None, None)
+ factory.return_value.__enter__.return_value.request.return_value = legacy
+ with self.assertRaises(AzureResponseError) as caught:
+ self.client.request('GET', self.resource)
+ self.assertIn('LegacyFailure', str(caught.exception))
+ self.assertIn('Legacy detail.', str(caught.exception))
+
+ def test_cli_global_policy_headers_are_forwarded(self):
+ policy = MagicMock()
+ policy.on_request.side_effect = lambda request: request.http_request.headers.update(
+ {'x-ms-policy-external-evaluations': 'policy-token'})
+ with patch(MODULE + '.Profile') as profile, patch(MODULE + '.requests.Session') as factory, \
+ patch(MODULE + '.get_custom_hook_policy', return_value=policy):
+ profile.return_value.get_raw_token.return_value = (('Bearer', 'token', {}), None, None)
+ session = factory.return_value.__enter__.return_value
+ session.request.return_value = response(payload={'id': 'silo'})
+ self.client.request('PUT', self.resource, {'properties': {}})
+ self.assertEqual(session.request.call_args.kwargs['headers']['x-ms-policy-external-evaluations'],
+ 'policy-token')
+ policy.on_request.assert_called_once()
+
+ def test_transport_error_does_not_echo_credentials(self):
+ with patch(MODULE + '.Profile') as profile, patch(MODULE + '.requests.Session') as factory:
+ profile.return_value.get_raw_token.return_value = (('Bearer', 'token', {}), None, None)
+ factory.return_value.__enter__.return_value.request.side_effect = requests.ConnectionError('secret URL')
+ with self.assertRaises(AzureConnectionError) as caught:
+ self.client.request('GET', self.resource)
+ self.assertNotIn('secret URL', str(caught.exception))
+
+ def test_response_decoding(self):
+ self.assertIsNone(self.client.decode(response(204)))
+ self.assertEqual(self.client.decode(response(payload={'value': []})), {'value': []})
+ self.assertEqual(self.client.decode(response(headers={'Content-Type': 'application/xml'},
+ content=b'')), {'content': ''})
+ with self.assertRaises(AzureResponseError):
+ self.client.decode(response(content=b'bad json'))
+ with self.assertRaises(InvalidArgumentValueError):
+ self.client.decode(response(headers={'Content-Type': 'application/zip'}, content=b'zip'))
+
+ def test_collection_pagination_and_repeated_link(self):
+ second = self.resource + '?page=2'
+ with patch.object(self.client, 'request', side_effect=[
+ response(payload={'value': [{'name': 'one'}], 'nextLink': second}),
+ response(payload={'value': [{'name': 'two'}]})]) as request:
+ self.assertEqual(self.client.list(self.resource), [{'name': 'one'}, {'name': 'two'}])
+ self.assertEqual(request.call_count, 2)
+ with patch.object(self.client, 'request', return_value=response(
+ payload={'value': [], 'nextLink': self.resource})), self.assertRaises(AzureResponseError):
+ self.client.list(self.resource)
+ with patch.object(self.client, 'request', return_value=response(payload={'unexpected': []})), \
+ self.assertRaises(AzureResponseError):
+ self.client.list(self.resource)
+ with patch.object(self.client, 'request', return_value=response(
+ payload={'value': [], 'nextLink': 'https://evil.test/page'})) as request, \
+ self.assertRaises(AzureResponseError):
+ self.client.list(self.resource)
+ request.assert_called_once()
+
+ def test_synchronous_mutation_and_empty_delete(self):
+ with patch.object(self.client, 'request', return_value=response(payload={'id': self.resource})):
+ self.assertEqual(self.client.mutate('PUT', self.resource), {'id': self.resource})
+ self.assertIsNone(self.client.mutate('DELETE', self.resource))
+ with patch.object(self.client, 'request', return_value=response(204)):
+ self.assertIsNone(self.client.mutate('DELETE', self.resource))
+
+ def test_no_wait_returns_receipt_without_polling(self):
+ poll = self.resource + '/operationresults/operation'
+ for header in ('Location', 'Azure-AsyncOperation'):
+ with self.subTest(header=header), patch.object(self.client, 'request', return_value=response(
+ 202, {'id': self.resource}, {header: poll})) as request:
+ result = self.client.mutate('PUT', self.resource, no_wait=True)
+ self.assertEqual(result['status'], 'Accepted')
+ self.assertEqual(result['operationId'], 'operation')
+ self.assertEqual(result['resourceId'], self.resource)
+ request.assert_called_once()
+
+ def test_poll_status_body_and_final_resource(self):
+ poll = self.resource + '/operationresults/operation'
+ with patch.object(self.client, 'request', side_effect=[
+ response(202, headers={'Location': poll, 'Retry-After': '0'}),
+ response(payload={'status': 'InProgress'}, headers={'Retry-After': '0'}),
+ response(payload={'status': 'Succeeded'}),
+ response(payload={'id': self.resource, 'properties': {'revisionNumber': 7}})]) as request, \
+ patch(MODULE + '.time.sleep'):
+ result = self.client.mutate('PUT', self.resource)
+ self.assertEqual(result['properties']['revisionNumber'], 7)
+ self.assertEqual(request.call_args.args, ('GET', self.resource))
+ self.assertEqual(request.call_count, 4)
+
+ def test_delete_and_post_do_not_get_deleted_resource_or_action(self):
+ poll = self.resource + '/operationresults/operation'
+ for method in ('DELETE', 'POST'):
+ with self.subTest(method=method), patch.object(self.client, 'request', side_effect=[
+ response(202, headers={'Location': poll, 'Retry-After': '0'}),
+ response(payload={'status': 'Succeeded'})]) as request, patch(MODULE + '.time.sleep'):
+ self.assertIsNone(self.client.mutate(method, self.resource))
+ self.assertEqual(request.call_count, 2)
+
+ def test_http_200_terminal_failure_is_not_success(self):
+ for status in ('Failed', 'Cancelled', 'TimedOut'):
+ with self.subTest(status=status), self.assertRaises(AzureResponseError) as caught:
+ self.client.operation(self.resource, response(payload={
+ 'status': status, 'error': {'code': 'SiloFailed', 'message': 'Provisioning failed.'}}))
+ self.assertIn('SiloFailed', str(caught.exception))
+
+ def test_missing_or_cross_origin_poll_address_fails(self):
+ for headers in ({}, {'Location': 'https://evil.test/poll'}):
+ with self.subTest(headers=headers), \
+ patch.object(self.client, 'request', return_value=response(202, headers=headers)), \
+ self.assertRaises(AzureResponseError):
+ self.client.mutate('PUT', self.resource)
+
+ def test_timeout_does_not_cancel_service(self):
+ with patch(MODULE + '.time.monotonic', side_effect=[0, 2]), \
+ patch.object(self.client, 'request') as request, self.assertRaises(AzureResponseError):
+ self.client.wait(self.resource, timeout=1)
+ request.assert_not_called()
+ for value in (0, -1):
+ with self.subTest(timeout=value), self.assertRaises(InvalidArgumentValueError):
+ self.client.wait(self.resource, timeout=value)
+ with patch.object(self.client, 'request') as request, self.assertRaises(InvalidArgumentValueError):
+ self.client.mutate('PUT', self.resource, timeout=value)
+ request.assert_not_called()
+
+ def test_poll_io_timeout_is_bounded_by_remaining_deadline(self):
+ with patch(MODULE + '.time.monotonic', side_effect=[0, 0.25]), \
+ patch.object(self.client, 'request', return_value=response(payload={'status': 'Succeeded'})) as request:
+ self.client.wait(self.resource, timeout=1)
+ self.assertEqual(request.call_args.kwargs['timeout'], 0.75)
+
+ def test_http_date_retry_after(self):
+ with patch(MODULE + '.datetime') as clock, patch(MODULE + '.time.sleep') as sleep, \
+ patch.object(self.client, 'request', return_value=response(payload={'status': 'Succeeded'})):
+ clock.now.return_value = datetime(2026, 10, 18, 23, 0, 0, tzinfo=timezone.utc)
+ self.client.wait(self.resource, initial=response(
+ 202, headers={'Retry-After': 'Sun, 18 Oct 2026 23:00:05 GMT'}))
+ sleep.assert_called_once_with(5)
+
+ def test_only_transient_polling_reads_are_retried(self):
+ for status in (429, 500, 502, 503, 504):
+ error = AzureResponseError('Transient failure')
+ error.response = response(status, headers={'Retry-After': '0'})
+ with self.subTest(status=status), patch.object(self.client, 'request', side_effect=[
+ error, response(payload={'status': 'Succeeded'})]) as request, patch(MODULE + '.time.sleep'):
+ self.assertEqual(self.client.wait(self.resource)['status'], 'Succeeded')
+ self.assertEqual(request.call_count, 2)
+ error = AzureResponseError('Bad input')
+ error.response = response(400)
+ with patch.object(self.client, 'request', side_effect=error) as request, self.assertRaises(AzureResponseError):
+ self.client.wait(self.resource)
+ request.assert_called_once()
+
+ def test_operation_inspection_preserves_pending_and_success(self):
+ running = self.client.operation(self.resource, response(202))
+ succeeded = self.client.operation(self.resource, response())
+ self.assertEqual(running['status'], 'InProgress')
+ self.assertEqual(succeeded['status'], 'Succeeded')
+ with self.assertRaises(AzureResponseError):
+ self.client.operation(self.resource, response(payload={'status': 'Unexpected'}))
+
+ def test_poll_retry_after_and_transient_missing_operation(self):
+ initial = response(202, headers={'Retry-After': '0'})
+ with patch.object(self.client, 'request', side_effect=[
+ ResourceNotFoundError('Not yet visible'), response(payload={'status': 'Succeeded'})]) as request, \
+ patch(MODULE + '.time.sleep'):
+ self.assertEqual(self.client.wait(self.resource, initial=initial)['status'], 'Succeeded')
+ self.assertEqual(request.call_count, 2)
+ with patch.object(self.client, 'request', side_effect=ResourceNotFoundError('Missing')), \
+ self.assertRaises(ResourceNotFoundError):
+ self.client.wait(self.resource)
+ for retry in ('malformed', 'nan', '-1', '0'):
+ with self.subTest(header=retry), \
+ patch.object(self.client, 'request', return_value=response(payload={'status': 'Succeeded'})), \
+ patch(MODULE + '.time.sleep') as sleep:
+ self.client.wait(self.resource, initial=response(202, headers={'Retry-After': retry}))
+ self.assertGreaterEqual(sleep.call_args.args[0], 0.1)
+ self.assertLessEqual(sleep.call_args.args[0], 5)
+
+ def test_json_inputs(self):
+ self.assertEqual(load_json('{"enabled":false}'), {'enabled': False})
+ self.assertEqual(load_json('[1,2]', list), [1, 2])
+ self.assertEqual(load_json('[1,2]', expected_type=None), [1, 2])
+ for value in ('not json', '[1]', 'null'):
+ with self.subTest(value=value), self.assertRaises(InvalidArgumentValueError):
+ load_json(value)
+ with tempfile.TemporaryDirectory() as directory:
+ filename = Path(directory) / 'body.json'
+ filename.write_text('{"value":1}', encoding='utf-8')
+ self.assertEqual(load_json('@' + str(filename)), {'value': 1})
+
+ def test_byte_exact_download_and_overwrite_policy(self):
+ payload = b'\x00\xffbinary\x00'
+ with tempfile.TemporaryDirectory() as directory:
+ destination = Path(directory) / 'output.bin'
+ with patch.object(self.client, '_response') as context:
+ context.return_value.__enter__.return_value = response(content=payload)
+ self.assertEqual(self.client.consume('GET', self.resource, str(destination)),
+ {'file': str(destination)})
+ self.assertEqual(destination.read_bytes(), payload)
+ with self.assertRaises(FileOperationError):
+ self.client.consume('GET', self.resource, str(destination))
+ with patch.object(self.client, '_response') as context:
+ context.return_value.__enter__.return_value = response(content=b'new')
+ self.client.consume('GET', self.resource, str(destination), overwrite=True)
+ self.assertEqual(destination.read_bytes(), b'new')
+ self.assertEqual(list(Path(directory).iterdir()), [destination])
+
+ def test_live_log_decoding_without_buffering(self):
+ streamed = MagicMock()
+ streamed.iter_content.return_value = iter([b'\xe2', b'\x82\xac', b'\xff'])
+ with patch.object(self.client, '_response') as context, patch(MODULE + '.sys.stdout', new_callable=io.StringIO) as stdout:
+ context.return_value.__enter__.return_value = streamed
+ self.assertIsNone(self.client.consume('GET', self.resource, tail=True))
+ self.assertEqual(stdout.getvalue(), '\u20ac\ufffd')
+ streamed.json.assert_not_called()
+
+ def test_interrupted_download_removes_only_its_own_partial_file(self):
+ with tempfile.TemporaryDirectory() as directory:
+ destination = Path(directory) / 'output.bin'
+ keep = Path(directory) / 'unrelated'
+ keep.write_bytes(b'keep')
+ streamed = MagicMock()
+ streamed.iter_content.side_effect = KeyboardInterrupt
+ with patch.object(self.client, '_response') as context, self.assertRaises(KeyboardInterrupt):
+ context.return_value.__enter__.return_value = streamed
+ self.client.consume('GET', self.resource, str(destination))
+ self.assertFalse(destination.exists())
+ self.assertEqual(list(Path(directory).iterdir()), [keep])
+ self.assertEqual(keep.read_bytes(), b'keep')
+ context.return_value.__exit__.assert_called_once()
+
+ def test_download_permission_failure_is_explicit(self):
+ with tempfile.TemporaryDirectory() as directory:
+ destination = Path(directory) / 'output.bin'
+ with patch.object(self.client, '_response') as context, \
+ patch(MODULE + '.tempfile.mkstemp', side_effect=PermissionError(13, 'Permission denied')), \
+ self.assertRaises(FileOperationError) as caught:
+ context.return_value.__enter__.return_value = response(content=b'bytes')
+ self.client.consume('GET', self.resource, str(destination))
+ self.assertIn('Permission denied', str(caught.exception))
+ self.assertEqual(list(Path(directory).iterdir()), [])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands.py
new file mode 100644
index 00000000000..0e3bfaa4124
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands.py
@@ -0,0 +1,64 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+# --------------------------------------------------------------------------------------------
+
+import os
+import unittest
+
+from azure.cli.testsdk import LiveScenarioTest, ResourceGroupPreparer
+
+
+PRIMARY_LOCATION = os.environ.get('AZURE_ACTIVE_BACKUP_PRIMARY_LOCATION', 'eastus')
+SILO_LOCATION = os.environ.get('AZURE_ACTIVE_BACKUP_SILO_LOCATION', 'westus2')
+
+
+@unittest.skipUnless(os.environ.get('AZURE_ACTIVE_BACKUP_LIVE') == '1',
+ 'Requires explicit live-resource authorization and an enabled Active Backup subscription.')
+class ActiveBackupLiveTests(LiveScenarioTest):
+
+ @ResourceGroupPreparer(location=PRIMARY_LOCATION)
+ def test_appservice_active_backup_windows(self, resource_group):
+ self._lifecycle(resource_group, linux=False)
+
+ @ResourceGroupPreparer(location=PRIMARY_LOCATION)
+ def test_appservice_active_backup_linux(self, resource_group):
+ self._lifecycle(resource_group, linux=True)
+
+ def _lifecycle(self, resource_group, linux):
+ plan = self.create_random_name('abplan', 20)
+ app = self.create_random_name('abapp', 24)
+ self.kwargs.update(rg=resource_group, plan=plan, app=app,
+ primary=PRIMARY_LOCATION, secondary=SILO_LOCATION,
+ linux='true' if linux else 'false')
+ self.cmd('appservice plan create -g {rg} -n {plan} --location {primary} '
+ '--sku P1V3 --number-of-workers 2 --is-linux {linux}')
+ self.cmd('webapp create -g {rg} -n {app} --plan {plan}' +
+ (' --runtime "PYTHON:3.12"' if linux else ''))
+ try:
+ self.cmd('appservice plan silo create -g {rg} -n {plan} --silo backup-plan '
+ '--location {secondary} --primary-location {primary} --number-of-workers 2')
+ self.cmd('webapp silo create -g {rg} -n {app} --silo backup-app --location {secondary} '
+ '--primary-location {primary} --traffic-mode Active')
+ self.cmd('webapp silo show -g {rg} -n {app} --silo backup-app',
+ checks=[self.check('properties.provisioningState', 'Succeeded')])
+ self.cmd('webapp silo list -g {rg} -n {app}')
+ self.cmd('appservice plan silo list -g {rg} -n {plan}')
+ self.cmd('appservice plan silo update -g {rg} -n {plan} --silo backup-plan --number-of-workers 2')
+ self.cmd('webapp silo update -g {rg} -n {app} --silo backup-app --follow-last-known-good false')
+ self.cmd('webapp managed-traffic update -g {rg} -n {app} --traffic-mode Passive')
+ self.cmd('webapp managed-traffic list -g {rg} -n {app} --expand-endpoints')
+ self.cmd('webapp managed-traffic show -g {rg} -n {app}')
+ self.cmd('webapp last-known-good mark -g {rg} -n {app}')
+ self.cmd('webapp last-known-good show -g {rg} -n {app}')
+ self.cmd('webapp last-known-good list -g {rg} -n {app}')
+ self.cmd('webapp silo refresh -g {rg} -n {app}')
+ self.cmd('webapp silo config show -g {rg} -n {app} --silo backup-app')
+ self.cmd('webapp silo instance list -g {rg} -n {app} --silo backup-app')
+ self.cmd('webapp silo deployment list -g {rg} -n {app} --silo backup-app')
+ self.cmd('webapp silo restart -g {rg} -n {app} --silo backup-app --synchronous true')
+ finally:
+ try:
+ self.cmd('webapp silo delete -g {rg} -n {app} --silo backup-app --yes')
+ finally:
+ self.cmd('appservice plan silo delete -g {rg} -n {plan} --silo backup-plan --yes')
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py
new file mode 100644
index 00000000000..06723b2d3d3
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py
@@ -0,0 +1,439 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+# --------------------------------------------------------------------------------------------
+
+from contextlib import redirect_stderr, redirect_stdout
+import inspect
+import io
+import unittest
+from unittest.mock import patch
+from urllib.parse import urlsplit
+
+from azure.cli.core.azclierror import AzureResponseError, InvalidArgumentValueError, ResourceNotFoundError
+from azure.cli.command_modules.appservice import active_backup as commands
+from azure.cli.command_modules.appservice._active_backup_client import ActiveBackupClient
+from azure.cli.command_modules.appservice._active_backup_commands import COMMAND_GROUPS
+from .test_active_backup_client import cli_context, response
+
+
+MODULE = 'azure.cli.command_modules.appservice.active_backup'
+
+# Independent route expectations from SitesCsmSurface, including each hidden API and input variant.
+PASS_THROUGH_CASES = (
+ ('P01', 'show_silo_php_logging', 'GET', '/phplogging', {}),
+ ('P02', 'list_silo_deployments', 'GET', '/deployments', {}),
+ ('P03', 'show_silo_deployment', 'GET', '/deployments/deploy', {'deployment_id': 'deploy'}),
+ ('P04', 'list_silo_deployment_logs', 'GET', '/deployments/deploy/log', {'deployment_id': 'deploy'}),
+ ('P05', 'delete_silo_process', 'DELETE', '/processes/123', {'process_id': '123'}),
+ ('P06', 'stop_silo_process', 'POST', '/processes/123/stop', {'process_id': '123'}),
+ ('P07', 'restart_silo_container', 'POST', '/containers/main/restart', {'container_name': 'main'}),
+ ('P08', 'show_silo_process', 'GET', '/processes/123', {'process_id': '123'}),
+ ('P09', 'list_silo_process_threads', 'GET', '/processes/123/threads', {'process_id': '123'}),
+ ('P10', 'show_silo_process_thread', 'GET', '/processes/123/threads/thread',
+ {'process_id': '123', 'thread_id': 'thread'}),
+ ('P11', 'list_silo_process_modules', 'GET', '/processes/123/modules', {'process_id': '123'}),
+ ('P12', 'show_silo_process_module', 'GET', '/processes/123/modules/0x1',
+ {'process_id': '123', 'base_address': '0x1'}),
+ ('P13', 'dump_silo_process', 'GET', '/processes/123/dump', {'process_id': '123', 'file': 'out.bin'}),
+ ('P14', 'download_silo_container_logs', 'POST', '/containerlogs', {'format': 'raw', 'file': 'out.bin'}),
+ ('P15', 'download_silo_container_logs', 'POST', '/containerlogs/zip', {'format': 'zip', 'file': 'out.bin'}),
+ ('P16', 'list_silo_startup_logs', 'GET', '/startuplogs', {}),
+ ('P17', 'show_silo_startup_summary', 'GET', '/startuplogs/summary', {}),
+ ('P18', 'show_silo_startup_log', 'GET', '/startuplogs/startup.log', {'filename': 'startup.log'}),
+ ('P19', 'list_silo_logs', 'GET', '/logs', {}),
+ ('P20', 'tail_silo_logs', 'GET', '/logs/live', {}),
+ ('P21', 'list_silo_webjobs', 'GET', '/webjobs', {}),
+ ('P22', 'show_silo_webjob', 'GET', '/webjobs/job', {'webjob_name': 'job'}),
+ ('P23', 'list_silo_continuous_webjobs', 'GET', '/continuouswebjobs', {}),
+ ('P24', 'show_silo_continuous_webjob', 'GET', '/continuouswebjobs/job', {'webjob_name': 'job'}),
+ ('P25', 'start_silo_continuous_webjob', 'POST', '/continuouswebjobs/job/start', {'webjob_name': 'job'}),
+ ('P26', 'stop_silo_continuous_webjob', 'POST', '/continuouswebjobs/job/stop', {'webjob_name': 'job'}),
+ ('P27', 'list_silo_triggered_webjobs', 'GET', '/triggeredwebjobs', {}),
+ ('P28', 'show_silo_triggered_webjob', 'GET', '/triggeredwebjobs/job', {'webjob_name': 'job'}),
+ ('P29', 'list_silo_webjob_history', 'GET', '/triggeredwebjobs/job/history', {'webjob_name': 'job'}),
+ ('P30', 'show_silo_webjob_history', 'GET', '/triggeredwebjobs/job/history/run',
+ {'webjob_name': 'job', 'run_id': 'run'}),
+ ('P31', 'run_silo_triggered_webjob', 'POST', '/triggeredwebjobs/job/run', {'webjob_name': 'job'}),
+ ('P32', 'list_silo_site_extensions', 'GET', '/siteextensions', {}),
+ ('P33', 'show_silo_site_extension', 'GET', '/siteextensions/ext', {'extension_name': 'ext'}),
+ ('P34', 'check_silo_connectivity', 'POST', '/connectivityCheck', {'body': '{"properties":{}}'}),
+ ('P35', 'check_silo_dns', 'POST', '/dnsCheck', {'body': '{"properties":{}}'}),
+ ('P36', 'check_silo_tcp', 'POST', '/tcpPingCheck', {'body': '{"properties":{}}'}),
+ ('P37', 'invoke_silo_extension', 'GET', '/instances/worker/extensions/api/settings',
+ {'instance': 'worker', 'path': 'api/settings'}),
+ ('P38', 'show_silo_onedeploy_status', 'GET', '/extensions/onedeploy', {}),
+ ('P39', 'show_silo_msdeploy_log', 'GET', '/extensions/MSDeploy/log', {}),
+ ('P40', 'list_silo_instances', 'GET', '/instances', {}),
+ ('P41', 'show_silo_instance', 'GET', '/instances/worker', {'instance': 'worker'}),
+ ('P42', 'show_silo_config', 'GET', '/config/web', {}),
+ ('P43', 'list_silo_config', 'GET', '/config', {}),
+ ('P44', 'list_silo_config_snapshots', 'GET', '/config/web/snapshots', {}),
+ ('P45', 'show_silo_config_snapshot', 'GET', '/config/web/snapshots/snap', {'snapshot_id': 'snap'}),
+ ('P46', 'show_silo_config_snapshot', 'POST', '/config/web/snapshots/snap/listsecrets',
+ {'snapshot_id': 'snap', 'include_secrets': True}),
+ ('P47', 'list_silo_keyvault_references', 'GET', '/config/configreferences/connectionstrings', {}),
+ ('P48', 'refresh_silo_keyvault_references', 'POST', '/config/configreferences/appsettings/refresh', {}),
+ ('P49', 'list_silo_detected_stacks', 'GET', '/detectedstacks', {}),
+ ('P50', 'list_silo_appsettings', 'POST', '/config/appsettings/list', {}),
+ ('P51', 'list_silo_storage_accounts', 'POST', '/config/azurestorageaccounts/list', {}),
+ ('P52', 'show_silo_connection_strings', 'GET', '/config/connectionstrings', {}),
+ ('P53', 'list_silo_connection_strings', 'POST', '/config/connectionstrings/list', {}),
+ ('P54', 'show_silo_connection_strings', 'POST', '/config/web/connectionstrings/key', {'key': 'key'}),
+ ('P55', 'show_silo_auth', 'GET', '/config/authsettings', {'metadata_only': True}),
+ ('P56', 'show_silo_auth', 'POST', '/config/authsettings/list', {}),
+ ('P57', 'show_silo_auth_v2', 'GET', '/config/authsettingsV2/list', {'legacy': True}),
+ ('P58', 'show_silo_auth_v2', 'GET', '/config/authsettingsV2', {}),
+ ('P59', 'show_silo_push_settings', 'POST', '/config/pushsettings/list', {}),
+ ('P60', 'list_silo_metadata', 'POST', '/config/metadata/list', {}),
+ ('P61', 'show_silo_log_config', 'GET', '/config/logs', {}),
+ ('P62', 'list_silo_premier_addons', 'GET', '/premieraddons', {}),
+ ('P63', 'show_silo_premier_addon', 'GET', '/premieraddons/addon', {'addon_name': 'addon'}),
+ ('P64', 'list_silo_metrics', 'GET', '/metrics', {}),
+ ('P65', 'list_silo_metric_definitions', 'GET', '/metricdefinitions', {}),
+ ('P66', 'list_silo_perf_counters', 'GET', '/perfcounters', {}),
+ ('P67', 'restart_site_silo', 'POST', '/restart', {}),
+ ('P68', 'start_site_silo', 'POST', '/start', {}),
+ ('P69', 'stop_site_silo', 'POST', '/stop', {}),
+ ('P70', 'start_silo_network_trace', 'POST', '/networkTrace/start', {'legacy': True}),
+ ('P71', 'start_silo_network_trace', 'POST', '/networkTrace/startOperation', {}),
+ ('P72', 'stop_silo_network_trace', 'POST', '/networkTrace/stop', {}),
+ ('P73', 'show_silo_network_trace_operation', 'GET', '/networkTrace/operationresults/op', {'operation_id': 'op'}),
+ ('P74', 'show_silo_network_trace', 'GET', '/networkTrace/op', {'operation_id': 'op'}),
+ ('P75', 'show_silo_runtime_operation', 'GET', '/operations/op', {'operation_id': 'op'}),
+ ('P76', 'scan_silo_antivirus', 'POST', '/antivirus/queue', {}),
+ ('P77', 'show_silo_antivirus_config', 'GET', '/antivirus/config', {}),
+ ('P78', 'update_silo_antivirus_config', 'PUT', '/antivirus/config', {'body': '{"properties":{}}'}),
+ ('P79', 'list_silo_antivirus_logs', 'GET', '/antiviruslogs', {}),
+ ('P80', 'show_silo_antivirus_log', 'POST', '/antiviruslogs/scan/list',
+ {'scan_id': 'scan', 'include_file_paths': True}),
+ ('P81', 'show_silo_antivirus_log', 'GET', '/antiviruslogs/scan', {'scan_id': 'scan'}),
+ ('P82', 'list_silo_deployment_statuses', 'GET', '/deploymentStatus', {}),
+ ('P83', 'list_silo_sitecontainers', 'GET', '/sitecontainers', {}),
+ ('P84', 'show_silo_sitecontainer', 'GET', '/sitecontainers/main', {'container_name': 'main'}),
+)
+
+RESOURCE_CASES = (
+ ('C01', 'list_plan_silos', True, 'GET', '/silos', {}),
+ ('C02', 'show_plan_silo', True, 'GET', '/silos/backup', {'silo': 'backup'}),
+ ('C03', 'create_plan_silo', True, 'PUT', '/silos/backup',
+ {'silo': 'backup', 'location': 'westus2', 'primary_location': 'eastus'}),
+ ('C04', 'update_plan_silo', True, 'PUT', '/silos/backup', {'silo': 'backup', 'number_of_workers': 2}),
+ ('C05', 'delete_plan_silo', True, 'DELETE', '/silos/backup', {'silo': 'backup'}),
+ ('C06', 'show_plan_silo_operation', True, 'GET', '/silos/backup/operationresults/op',
+ {'silo': 'backup', 'operation_id': 'op'}),
+ ('C07', 'list_plan_silo_vnet_integrations', True, 'GET', '/silos/backup/virtualNetworkIntegrations',
+ {'silo': 'backup'}),
+ ('C08', 'list_site_silos', False, 'GET', '/silos', {}),
+ ('C09', 'show_site_silo', False, 'GET', '/silos/backup', {'silo': 'backup'}),
+ ('C10', 'create_site_silo', False, 'PUT', '/silos/backup',
+ {'silo': 'backup', 'location': 'westus2', 'primary_location': 'eastus', 'traffic_mode': 'Active'}),
+ ('C11', 'update_site_silo', False, 'PUT', '/silos/backup',
+ {'silo': 'backup', 'endpoint_status': 'Disabled'}),
+ ('C12', 'delete_site_silo', False, 'DELETE', '/silos/backup', {'silo': 'backup'}),
+ ('C13', 'show_site_silo_operation', False, 'GET', '/silos/backup/operationresults/op',
+ {'silo': 'backup', 'operation_id': 'op'}),
+ ('C14', 'refresh_site_silos', False, 'POST', '/refreshSilos', {}),
+ ('C15', 'list_last_known_good', False, 'GET', '/lastKnownGood', {}),
+ ('C16', 'show_last_known_good', False, 'GET', '/lastKnownGood/default', {}),
+ ('C17', 'mark_last_known_good', False, 'POST', '/lastKnownGood/default/mark', {}),
+ ('C18', 'list_managed_traffic', False, 'GET', '/managedTrafficConfig', {}),
+ ('C19', 'show_managed_traffic', False, 'GET', '/managedTrafficConfig/default', {}),
+ ('C20', 'update_managed_traffic', False, 'PUT', '/managedTrafficConfig/default', {'traffic_mode': 'Active'}),
+ ('C21', 'list_managed_traffic_metrics', False, 'GET', '/managedTrafficConfig/default/metrics', {}),
+ ('C22', 'show_silo_vnet_integration', False, 'GET', '/silos/backup/virtualNetworkConnections/virtualNetwork',
+ {'silo': 'backup'}),
+ ('C23', 'add_silo_vnet_integration', False, 'PUT', '/silos/backup/virtualNetworkConnections/virtualNetwork',
+ {'silo': 'backup', 'subnet': '/subscriptions/sub/subnets/net'}),
+ ('C24', 'update_silo_vnet_integration', False, 'PATCH', '/silos/backup/virtualNetworkConnections/virtualNetwork',
+ {'silo': 'backup', 'subnet': '/subscriptions/sub/subnets/net'}),
+ ('C25', 'remove_silo_vnet_integration', False, 'DELETE', '/silos/backup/virtualNetworkConnections/virtualNetwork',
+ {'silo': 'backup'}),
+)
+
+
+class ActiveBackupCommandTests(unittest.TestCase):
+
+ def setUp(self):
+ self.ctx = cli_context()
+ self.cmd = type('Command', (), {'cli_ctx': self.ctx})()
+ self.client = ActiveBackupClient(self.ctx, 'group', 'primary', subscription='subscription')
+ self.client_patch = patch(MODULE + '._client', return_value=self.client)
+ self.client_patch.start()
+ self.addCleanup(self.client_patch.stop)
+ self.request_patch = patch.object(self.client, 'request', return_value=response(payload={'value': []}))
+ self.request = self.request_patch.start()
+ self.addCleanup(self.request_patch.stop)
+
+ def test_all_25_resource_action_mappings(self):
+ self.assertEqual([case[0] for case in RESOURCE_CASES], ['C{:02}'.format(i) for i in range(1, 26)])
+ for key, handler, plan, method, suffix, kwargs in RESOURCE_CASES:
+ client = ActiveBackupClient(self.ctx, 'group', 'primary',
+ 'serverfarms' if plan else 'sites', subscription='subscription')
+ result = response(204) if key in ('C14', 'C25') else response(payload={
+ 'location': 'westus2', 'properties': {'primaryLocation': 'eastus'}, 'value': []})
+ with self.subTest(api=key), patch(MODULE + '._client', return_value=client), \
+ patch.object(client, 'request', return_value=result) as request, patch(MODULE + '.logger'):
+ getattr(commands, handler)(self.cmd, 'group', 'primary', **kwargs)
+ call = request.call_args
+ self.assertEqual(call.args[0], method)
+ self.assertEqual(urlsplit(call.args[1]).path, client.parent + suffix)
+ self.assertEqual(request.call_count, 2 if key in ('C04', 'C11') else 1)
+
+ def test_all_84_pass_through_methods_have_exact_route_and_no_preflight(self):
+ self.assertEqual([case[0] for case in PASS_THROUGH_CASES], ['P{:02}'.format(i) for i in range(1, 85)])
+ registered = {handler for entries in COMMAND_GROUPS.values() for _, handler in entries}
+ for key, handler, method, suffix, kwargs in PASS_THROUGH_CASES:
+ with self.subTest(api=key, handler=handler), \
+ patch.object(self.client, 'consume', return_value={'file': 'out'}) as consume, \
+ patch(MODULE + '._primary_location') as primary:
+ self.request.reset_mock()
+ getattr(commands, handler)(self.cmd, 'group', 'primary', 'backup', **kwargs)
+ call = consume.call_args if consume.called else self.request.call_args
+ self.assertIsNotNone(call)
+ self.assertEqual(call.args[:2], (method, self.client.resource('backup') + suffix))
+ self.assertEqual(self.request.call_count + consume.call_count, 1)
+ primary.assert_not_called()
+ self.assertIn(handler, registered)
+
+ def test_instance_qualified_process_and_live_log_routes(self):
+ for _, handler, method, suffix, kwargs in PASS_THROUGH_CASES:
+ if not (suffix.startswith('/processes/') or handler in ('restart_silo_container', 'tail_silo_logs')):
+ continue
+ with self.subTest(handler=handler), patch.object(self.client, 'consume') as consume:
+ self.request.reset_mock()
+ getattr(commands, handler)(self.cmd, 'group', 'primary', 'backup',
+ **dict(kwargs, instance='worker'))
+ call = consume.call_args if consume.called else self.request.call_args
+ self.assertEqual(call.args[:2], (method, self.client.resource('backup') + '/instances/worker' + suffix))
+
+ def test_extension_verbs_bodies_and_path_validation(self):
+ for method in ('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'):
+ commands.invoke_silo_extension(self.cmd, 'group', 'primary', 'backup', 'worker', 'api/settings',
+ method=method, body='[1,2]')
+ self.assertEqual(self.request.call_args.args[0], method)
+ self.assertEqual(self.request.call_args.args[2], [1, 2])
+ for path in ('../primary', '%2e%2e/primary', 'api/%252e%252e', '/api/x', 'api/x?y=z',
+ 'api\\x', 'api/%2fescape', 'https://other.test/api'):
+ with self.subTest(path=path), self.assertRaises(InvalidArgumentValueError):
+ commands.invoke_silo_extension(self.cmd, 'group', 'primary', 'backup', 'worker', path)
+ commands.invoke_silo_extension(self.cmd, 'group', 'primary', 'backup', 'worker', 'api/settings',
+ uri_parameters=['item=one', 'item=two'])
+ self.assertEqual(self.request.call_args.args[3], {'item': ['one', 'two']})
+ with self.assertRaises(InvalidArgumentValueError):
+ commands.invoke_silo_extension(self.cmd, 'group', 'primary', 'backup', 'worker', 'api/settings',
+ uri_parameters=['api-version=old'])
+ commands.invoke_silo_extension(self.cmd, 'group', 'primary', 'backup', 'worker', 'api/settings',
+ method='POST', body='null')
+ self.assertEqual(self.request.call_args.args[2], b'null')
+
+ def test_normal_not_found_is_not_empty_success_or_primary_fallback(self):
+ self.request.side_effect = ResourceNotFoundError('Silo not found')
+ with self.assertRaises(ResourceNotFoundError):
+ commands.show_silo_config(self.cmd, 'group', 'primary', 'backup')
+ self.request.assert_called_once()
+
+ def test_plan_creation_and_explicit_false(self):
+ commands.create_plan_silo(self.cmd, 'group', 'primary', 'backup', 'westus2',
+ primary_location='eastus', number_of_workers=2, zone_redundant='false')
+ self.assertEqual(self.request.call_args.args[2], {
+ 'location': 'westus2', 'properties': {
+ 'name': 'backup', 'primaryLocation': 'eastus',
+ 'instanceNumberOverride': 2, 'zoneRedundantOverride': False}})
+ self.request.assert_called_once()
+
+ def test_site_creation_does_not_create_plan_or_invent_overrides(self):
+ commands.create_site_silo(self.cmd, 'group', 'primary', 'backup', 'westus2',
+ primary_location='eastus', traffic_mode='Active')
+ self.assertEqual(self.request.call_args.args[2], {
+ 'location': 'westus2', 'properties': {
+ 'name': 'backup', 'primaryLocation': 'eastus', 'trafficMode': 'Active'}})
+ self.request.assert_called_once()
+
+ def test_create_resolves_primary_location_only_when_omitted(self):
+ with patch(MODULE + '._primary_location', return_value='eastus') as primary:
+ commands.create_site_silo(self.cmd, 'group', 'primary', 'backup', 'westus2', traffic_mode='Active')
+ primary.assert_called_once_with(self.cmd, 'group', 'primary', False)
+ self.assertEqual(self.request.call_args.args[2]['properties']['primaryLocation'], 'eastus')
+
+ def test_update_reads_silo_and_sends_only_writable_requested_fields(self):
+ self.request.side_effect = [
+ response(payload={'location': 'westus2', 'properties': {
+ 'primaryLocation': 'eastus', 'revisionNumber': 8, 'provisioningState': 'Succeeded',
+ 'followLastKnownGood': True}}),
+ response(payload={'id': 'silo'})]
+ commands.update_site_silo(self.cmd, 'group', 'primary', 'backup', endpoint_status='Disabled')
+ self.assertEqual(self.request.call_args.args[2], {
+ 'location': 'westus2', 'properties': {
+ 'name': 'backup', 'primaryLocation': 'eastus', 'endpointStatus': 'Disabled'}})
+ self.assertEqual(self.request.call_count, 2)
+
+ def test_worker_override_boundaries(self):
+ for value in (0, -1, 31):
+ with self.subTest(value=value), self.assertRaises(InvalidArgumentValueError):
+ commands.create_plan_silo(self.cmd, 'group', 'primary', 'backup', 'westus2',
+ primary_location='eastus', number_of_workers=value)
+ self.request.assert_not_called()
+ for value in (1, 30):
+ commands.create_plan_silo(self.cmd, 'group', 'primary', 'backup', 'westus2',
+ primary_location='eastus', number_of_workers=value)
+ self.assertEqual(self.request.call_args.args[2]['properties']['instanceNumberOverride'], value)
+
+ def test_refresh_204_is_one_request_and_notice_without_revision_wait(self):
+ self.request.return_value = response(204)
+ with patch(MODULE + '.logger') as logger:
+ self.assertIsNone(commands.refresh_site_silos(self.cmd, 'group', 'primary'))
+ self.request.assert_called_once_with('POST', self.client.parent + '/refreshSilos')
+ logger.warning.assert_called_once()
+ self.assertIn('accepted', logger.warning.call_args.args[0])
+ self.assertNotIn('revision X', logger.warning.call_args.args[0])
+
+ def test_refresh_conflict_does_not_report_acceptance(self):
+ for status in (400, 409):
+ error = AzureResponseError('Refresh rejected')
+ error.response = response(status)
+ self.request.side_effect = error
+ with self.subTest(status=status), patch(MODULE + '.logger') as logger, \
+ self.assertRaises(AzureResponseError):
+ commands.refresh_site_silos(self.cmd, 'group', 'primary')
+ logger.warning.assert_not_called()
+
+ def test_managed_traffic_mode_only_and_partial_endpoint_overlay(self):
+ commands.update_managed_traffic(self.cmd, 'group', 'primary', traffic_mode='Passive')
+ self.assertEqual(self.request.call_args.args[2], {'properties': {'trafficMode': 'Passive'}})
+ commands.update_managed_traffic(self.cmd, 'group', 'primary',
+ endpoints='[{"region":"westus2","weight":3}]')
+ self.assertEqual(self.request.call_args.args[2], {
+ 'properties': {'endpoints': [{'region': 'westus2', 'weight': 3}]}})
+ for endpoints in ('[{"weight":2}]', '[{"region":"x","weight":0}]',
+ '[{"region":"x","priority":true}]', '[{"region":"x","isWeightAutoComputed":true}]'):
+ with self.subTest(endpoints=endpoints), self.assertRaises(InvalidArgumentValueError):
+ commands.update_managed_traffic(self.cmd, 'group', 'primary', endpoints=endpoints)
+
+ def test_traffic_expansion_only_on_collection(self):
+ commands.list_managed_traffic(self.cmd, 'group', 'primary', expand_endpoints=True)
+ self.assertIn('%24expand=endpoints', self.request.call_args.args[1])
+ commands.show_managed_traffic(self.cmd, 'group', 'primary')
+ self.assertEqual(self.request.call_args.args, ('GET', self.client.parent + '/managedTrafficConfig/default'))
+
+ def test_last_known_good_mark_does_not_refresh(self):
+ commands.mark_last_known_good(self.cmd, 'group', 'primary')
+ self.request.assert_called_once_with('POST', self.client.parent + '/lastKnownGood/default/mark')
+
+ def test_networking_exact_routes_verbs_and_body(self):
+ for handler, method in ((commands.add_silo_vnet_integration, 'PUT'),
+ (commands.update_silo_vnet_integration, 'PATCH')):
+ with self.subTest(method=method):
+ handler(self.cmd, 'group', 'primary', 'backup', subnet='/subscriptions/sub/subnets/net')
+ self.assertEqual(self.request.call_args.args[:3], (
+ method, self.client.resource('backup') + '/virtualNetworkConnections/virtualNetwork',
+ {'properties': {'subnetResourceId': '/subscriptions/sub/subnets/net'}}))
+ self.request.return_value = response(204)
+ self.assertIsNone(commands.remove_silo_vnet_integration(self.cmd, 'group', 'primary', 'backup'))
+
+ def test_startup_log_queries_and_explicit_false_flags(self):
+ commands.list_silo_startup_logs(self.cmd, 'group', 'primary', 'backup',
+ log_type='All', instance='worker', latest=False)
+ self.assertEqual(self.request.call_args.args[3], {
+ 'type': 'All', 'targetSiteInstance': 'worker', 'latest': False})
+ commands.restart_site_silo(self.cmd, 'group', 'primary', 'backup',
+ soft_restart=False, synchronous=False)
+ self.assertEqual(self.request.call_args.args[3], {'softRestart': False, 'synchronous': False})
+
+ def test_metric_filters_and_service_validation(self):
+ query = commands._metric_query('2026-10-18T00:00:00Z', None, 'PT1H', ['Requests'])
+ self.assertIn("timeGrain eq duration'PT1H'", query['$filter'])
+ self.assertIn("name.value eq 'Requests'", query['$filter'])
+ with self.assertRaises(InvalidArgumentValueError):
+ commands._metric_query(start_time='x', filter_string='x')
+
+ def test_table_preserves_degraded_and_no_wait_receipt(self):
+ row = commands.silo_table({'name': 'backup', 'properties': {
+ 'provisioningState': 'Degraded', 'revisionNumber': 3, 'errorCode': 'SyncFailed'}})[0]
+ self.assertEqual(row['State'], 'Degraded')
+ self.assertEqual(row['Error'], 'SyncFailed')
+ receipt = commands.silo_table({'status': 'Accepted', 'operationId': 'op', 'resourceId': 'id'})[0]
+ self.assertEqual(receipt['Status'], 'Accepted')
+
+ def test_all_registered_handlers_and_help_exist(self):
+ from knack.help_files import helps
+ seen = set()
+ for group, entries in COMMAND_GROUPS.items():
+ for verb, handler in entries:
+ command = group + ' ' + verb
+ with self.subTest(command=command):
+ self.assertNotIn(command, seen)
+ seen.add(command)
+ self.assertTrue(callable(getattr(commands, handler)))
+ self.assertIn(command, helps)
+ self.assertIn('examples:', helps[command])
+ self.assertIn('cmd', inspect.signature(getattr(commands, handler)).parameters)
+ self.assertEqual(len(seen), 104)
+
+
+class ActiveBackupHelpTests(unittest.TestCase):
+
+ @staticmethod
+ def invoke(arguments):
+ from azure.cli.core import get_default_cli
+ if '--help' not in arguments:
+ arguments = arguments + ['--output', 'none']
+ with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
+ try:
+ return get_default_cli().invoke(arguments)
+ except SystemExit as ex:
+ return ex.code
+
+ def test_every_command_loads_help(self):
+ for group, entries in COMMAND_GROUPS.items():
+ for verb, _ in entries:
+ command = group + ' ' + verb
+ with self.subTest(command=command):
+ self.assertEqual(self.invoke(command.split() + ['--help']), 0)
+
+ def test_named_command_executes_exact_silo_request(self):
+ client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
+ with patch(MODULE + '._client', return_value=client), \
+ patch.object(client, 'request', return_value=response(payload={'id': 'silo'})) as request:
+ self.assertEqual(self.invoke(
+ 'webapp silo config show -g group -n primary --silo backup'.split()), 0)
+ self.assertEqual(request.call_args.args[:2], ('GET', client.resource('backup') + '/config/web'))
+
+ def test_nested_ids_are_typed_and_do_not_silently_override_names(self):
+ resource_id = (
+ '/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/group/'
+ 'providers/Microsoft.Web/sites/primary/silos/backup')
+ client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
+ with patch(MODULE + '._client', return_value=client) as factory, \
+ patch.object(client, 'request', return_value=response(payload={'id': 'silo'})) as request:
+ self.assertEqual(self.invoke(['webapp', 'silo', 'show', '--ids', resource_id]), 0)
+ self.assertEqual(factory.call_args.args[1:3], ('group', 'primary'))
+ self.assertEqual(request.call_args.args[:2], ('GET', client.resource('backup')))
+ for arguments in (
+ ['webapp', 'silo', 'show', '--ids', resource_id, '-n', 'another'],
+ ['webapp', 'silo', 'show', '--ids', resource_id.replace('/sites/', '/serverfarms/')]):
+ request.reset_mock()
+ self.assertNotEqual(self.invoke(arguments), 0)
+ request.assert_not_called()
+
+ def test_cli_explicit_false_and_no_wait(self):
+ client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
+ initial = response(202, headers={'Location': client.resource('backup') + '/operationresults/op'})
+ with patch(MODULE + '._client', return_value=client), \
+ patch.object(client, 'request', return_value=initial) as request:
+ self.assertEqual(self.invoke(
+ 'appservice plan silo create -g group -n primary --silo backup --location westus2 '
+ '--primary-location eastus --zone-redundant false --no-wait'.split()), 0)
+ self.assertIs(request.call_args.args[2]['properties']['zoneRedundantOverride'], False)
+ request.assert_called_once()
+
+ def test_cli_not_found_has_show_exit_code(self):
+ client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
+ with patch(MODULE + '._client', return_value=client), \
+ patch.object(client, 'request', side_effect=ResourceNotFoundError('Not found')) as request:
+ self.assertEqual(self.invoke('webapp silo show -g group -n primary --silo backup'.split()), 3)
+ request.assert_called_once()
+
+
+if __name__ == '__main__':
+ unittest.main()
From 4dead4de6749fd937070f35d919d9d3d748f59b5 Mon Sep 17 00:00:00 2001
From: Your Name
Date: Tue, 20 Oct 2026 09:49:32 -0500
Subject: [PATCH 2/6] Fix Active Backup CLI lifecycle results and operation
polling
Preserve no-wait receipts, return canonical resources after asynchronous writes, add the approved origin-only collection compatibility, and use async-status mode for standalone CRUD operation inspection and waits. Keep service errors visible and do not infer deletion success from polling 404.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 224abf0a-0923-4347-a5ea-5c4a1b4e4824
---
doc/active_backup_preview.md | 24 +-
.../appservice/_active_backup_client.py | 48 +++-
.../appservice/_active_backup_commands.py | 16 +-
.../appservice/active_backup.py | 13 +-
.../tests/latest/test_active_backup_client.py | 144 +++++++++++-
.../test_active_backup_commands_thru_mock.py | 220 +++++++++++++++++-
6 files changed, 444 insertions(+), 21 deletions(-)
diff --git a/doc/active_backup_preview.md b/doc/active_backup_preview.md
index cd6e7c71ee1..79fd2760353 100644
--- a/doc/active_backup_preview.md
+++ b/doc/active_backup_preview.md
@@ -61,7 +61,13 @@ name is required.
Create/update/delete wait for their API operation, not future content/configuration
revision, probe-health, or DNS convergence. `--no-wait` returns an accepted-operation
-receipt when the service starts an asynchronous operation:
+receipt when the service starts an asynchronous operation.
+After an asynchronous create/update succeeds, the CLI reads and returns the
+original target resource, rather than returning the operation-status envelope.
+
+The receipt contains `resourceId`, `operationId`, `operationUrl`, and
+`status: Accepted`. It is returned in the selected output format; `--output none`
+explicitly suppresses it.
```powershell
$operation = az appservice plan silo create -g MyGroup -n MyPlan --silo WestPlan --location westus2 --primary-location eastus --no-wait | ConvertFrom-Json
@@ -73,12 +79,28 @@ wait. A CLI timeout does not cancel the service operation. `silo operation show`
inspects a CRUD operation; `webapp silo runtime-operation show` inspects a runtime
operation instead.
+Both plan and site `silo operation show`/`silo wait` request the API's asynchronous
+status representation (`azureAsyncOperation=1`). They report operation status
+without reading the target resource after success, including after deletion.
+Polling errors remain errors; a missing resource alone is not proof of successful
+operation completion.
+
Worker overrides are 1-30. Omitted fields preserve service defaults/current values;
explicit `false` is not omission. Location cannot be changed by update. The current
API does not establish resetting existing plan overrides back to inheritance.
## Traffic, origin, Last Known Good, and refresh
+For the site `origin` entry only, a point-read HTTP 404 can be worked around using
+the exact matching origin resource returned by the paginated silo collection.
+The CLI emits a warning when it does this for show, update pre-read, or update
+readback. The returned object is the service's collection entry, without invented
+fields; stamp-derived IP fields from point reads may be absent. Missing,
+duplicate, or malformed origin entries and list/permission failures remain
+errors. Ordinary replica, plan-origin and pass-through reads are unchanged.
+Origin writes and operation polling still target their original API routes;
+the workaround does not prove that the raw ARM point endpoint is working.
+
```azurecli
az webapp managed-traffic show -g MyGroup -n MyApp
az webapp managed-traffic list -g MyGroup -n MyApp --expand-endpoints
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_client.py b/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_client.py
index 6e5880ed3d5..a1aa12655a8 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_client.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_client.py
@@ -13,10 +13,11 @@
import sys
import tempfile
import time
-from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlsplit, urlunsplit
+from urllib.parse import parse_qsl, quote, unquote, urlencode, urljoin, urlsplit, urlunsplit
import uuid
import requests
+from knack.log import get_logger
from azure.cli.core._profile import Profile
from azure.cli.core.azclierror import (AzureConnectionError, AzureResponseError, FileOperationError,
@@ -30,6 +31,7 @@
DEFAULT_TIMEOUT = 7200
_RUNNING = {'accepted', 'created', 'inprogress', 'running', 'waiting'}
_FAILED = {'failed', 'cancelled', 'canceled', 'timedout'}
+logger = get_logger(__name__)
def segment(value):
@@ -107,6 +109,8 @@ class ActiveBackupClient:
def __init__(self, cli_ctx, resource_group_name, name, resource_type='sites', subscription=None):
self.cli_ctx = cli_ctx
+ self._resource_type = resource_type
+ self._origin_fallback_warned = False
self.subscription = subscription or get_subscription_id(cli_ctx)
self.endpoint = cli_ctx.cloud.endpoints.resource_manager.rstrip('/') + '/'
self.parent = '/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/{}/{}'.format(
@@ -115,6 +119,11 @@ def __init__(self, cli_ctx, resource_group_name, name, resource_type='sites', su
def resource(self, silo=None):
return self.parent + '/silos' + ('/' + segment(silo) if silo is not None else '')
+ def operation_url(self, silo, operation_id):
+ """Select the CRUD status response rather than the final-resource response."""
+ return self.url(self.resource(silo) + '/operationresults/' + segment(operation_id),
+ {'azureAsyncOperation': 1})
+
def url(self, path, query=None):
try:
url = urljoin(self.endpoint, path)
@@ -195,6 +204,34 @@ def decode(response):
except ValueError as ex:
raise AzureResponseError('ARM returned an unsupported response content type. Use --file.') from ex
+ def get_resource(self, path):
+ try:
+ return self.decode(self.request('GET', path))
+ except ResourceNotFoundError:
+ if (self._resource_type != 'sites' or
+ unquote(path).casefold() != unquote(self.resource('origin')).casefold()):
+ raise
+ matches = [
+ resource for resource in self.list(self.resource())
+ if isinstance(resource, dict) and isinstance(resource.get('id'), str) and
+ unquote(resource['id']).casefold() == unquote(path).casefold()
+ ]
+ if not matches:
+ raise
+ if len(matches) != 1:
+ raise AzureResponseError('ARM returned duplicate origin resources in the silo collection.')
+ resource = matches[0]
+ resource_type = resource.get('type')
+ if (not isinstance(resource_type, str) or resource_type.casefold() != 'microsoft.web/sites/silos' or
+ not isinstance(resource.get('properties'), dict)):
+ raise AzureResponseError('ARM returned an invalid origin resource in the silo collection.')
+ if not self._origin_fallback_warned:
+ logger.warning(
+ 'Origin point GET returned HTTP 404; using the matching origin from the silo collection. '
+ 'Stamp-derived IP fields may be absent.')
+ self._origin_fallback_warned = True
+ return resource
+
def list(self, path, query=None):
result = []
visited = set()
@@ -239,7 +276,8 @@ def operation(self, path, response=None):
'operationUrl': self.url(path),
'status': state,
}
- if isinstance(payload, dict) and 'id' in payload:
+ if (isinstance(payload, dict) and isinstance(payload.get('id'), str) and payload['id'].strip() and
+ not payload.get('status')):
result['resource'] = payload
return result
@@ -294,11 +332,11 @@ def mutate(self, method, path, body=None, no_wait=False, timeout=DEFAULT_TIMEOUT
'status': 'Accepted',
}
operation = self.wait(location, timeout, initial=response)
+ if method in ('PUT', 'PATCH'):
+ return self.get_resource(path)
if method == 'DELETE':
return None
- if 'resource' in operation:
- return operation['resource']
- return self.decode(self.request('GET', path)) if method in ('PUT', 'PATCH') else None
+ return operation.get('resource')
def consume(self, method, path, filename=None, overwrite=False, query=None, body=None,
content_type=None, tail=False):
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py b/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py
index 53f57cf8685..cabd587922e 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py
@@ -122,10 +122,7 @@ def load_command_table(loader):
with loader.command_group(group, custom_command_type=command_type, is_preview=True,
validator=validate_identity) as context:
for verb, handler_name in commands:
- handler = getattr(active_backup, handler_name)
options = {}
- if 'no_wait' in inspect.signature(handler).parameters:
- options['supports_no_wait'] = True
if verb == 'delete':
options['confirmation'] = True
if group in ('appservice plan silo', 'webapp silo') and verb in ('create', 'update', 'show', 'list'):
@@ -148,6 +145,11 @@ def load_arguments(loader, command):
configured_default=None, local_context_attribute=None, completer=None,
help='Name of the primary App Service plan or app, not an internal silo resource.')
context.argument('silo', id_part='child_name_1', help='Customer-facing silo name, including origin when supported.')
+ if 'no_wait' in parameters:
+ # Register the reserved flag locally; supports_no_wait would discard the receipt.
+ context.extra(
+ 'no_wait', options_list=['--no-wait'], action='store_true', default=False,
+ help='Return without waiting for the API operation, preserving its resource or accepted-operation receipt.')
for argument in parameters:
if argument in ('cmd', 'name', 'resource_group_name', 'silo', 'no_wait'):
continue
@@ -230,14 +232,18 @@ def load_help():
if handler_name in ('add_silo_vnet_integration', 'update_silo_vnet_integration'):
example += ' --body @request.json'
description = handler_name.replace('_', ' ').capitalize() + '.'
+ origin_help = (
+ ' Origin point GET 404s can use the matching silo-list entry for metadata, with a warning. '
+ 'Origin writes and operation polling are unchanged.'
+ if handler_name in ('show_site_silo', 'update_site_silo') else '')
helps[command] = (
'type: command\nshort-summary: {}\n'
'long-summary: Calls the supported silo ARM API without client-side OS or SKU eligibility checks. '
'Read operations can return sensitive configuration. File responses require --file. '
'See https://github.com/Azure/azure-cli/blob/dev/doc/active_backup_preview.md '
- 'for API bodies and service limitations.\n'
+ 'for API bodies and service limitations.{}\n'
'examples:\n - name: {}\n text: {}\n'
- ).format(description, description, example)
+ ).format(description, origin_help, description, example)
for group in ('appservice plan silo', 'webapp silo'):
helps[group] = (
'type: group\nshort-summary: Manage App Service Active Backup silos.\n'
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/active_backup.py b/src/azure-cli/azure/cli/command_modules/appservice/active_backup.py
index 80649792c8d..70d7de45e52 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/active_backup.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/active_backup.py
@@ -37,7 +37,7 @@ def _primary_location(cmd, resource_group_name, name, plan):
def _silo_body(cmd, resource_group_name, name, silo, location, primary_location, properties, plan, update):
client = _client(cmd, resource_group_name, name, plan)
if update:
- existing = client.decode(client.request('GET', client.resource(silo)))
+ existing = client.get_resource(client.resource(silo))
if not isinstance(existing, dict) or not isinstance(existing.get('properties'), dict):
raise AzureResponseError('The silo API did not return the resource properties required for update.')
existing_properties = existing['properties']
@@ -89,12 +89,12 @@ def delete_plan_silo(cmd, resource_group_name, name, silo, no_wait=False, timeou
def show_plan_silo_operation(cmd, resource_group_name, name, silo, operation_id):
client = _client(cmd, resource_group_name, name, True)
- return client.operation(client.resource(silo) + '/operationresults/' + segment(operation_id))
+ return client.operation(client.operation_url(silo, operation_id))
def wait_plan_silo(cmd, resource_group_name, name, silo, operation_id, timeout=DEFAULT_TIMEOUT):
client = _client(cmd, resource_group_name, name, True)
- return client.wait(client.resource(silo) + '/operationresults/' + segment(operation_id), timeout)
+ return client.wait(client.operation_url(silo, operation_id), timeout)
def list_plan_silo_vnet_integrations(cmd, resource_group_name, name, silo):
@@ -128,7 +128,8 @@ def update_site_silo(cmd, resource_group_name, name, silo, location=None, primar
def show_site_silo(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo)
+ client = _client(cmd, resource_group_name, name)
+ return client.get_resource(client.resource(silo))
def list_site_silos(cmd, resource_group_name, name):
@@ -143,12 +144,12 @@ def delete_site_silo(cmd, resource_group_name, name, silo, no_wait=False, timeou
def show_site_silo_operation(cmd, resource_group_name, name, silo, operation_id):
client = _client(cmd, resource_group_name, name)
- return client.operation(client.resource(silo) + '/operationresults/' + segment(operation_id))
+ return client.operation(client.operation_url(silo, operation_id))
def wait_site_silo(cmd, resource_group_name, name, silo, operation_id, timeout=DEFAULT_TIMEOUT):
client = _client(cmd, resource_group_name, name)
- return client.wait(client.resource(silo) + '/operationresults/' + segment(operation_id), timeout)
+ return client.wait(client.operation_url(silo, operation_id), timeout)
def refresh_site_silos(cmd, resource_group_name, name):
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_client.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_client.py
index ade3765d41a..21e103fc8d7 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_client.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_client.py
@@ -10,7 +10,7 @@
import tempfile
import unittest
from unittest.mock import MagicMock, patch
-from urllib.parse import parse_qs, urlsplit
+from urllib.parse import parse_qs, unquote, urlsplit
import requests
@@ -71,6 +71,23 @@ def test_api_version_query_and_origin(self):
with self.subTest(address=bad), self.assertRaises(AzureResponseError):
self.client.url(bad)
+ def test_crud_operation_url_uses_async_status_mode_in_the_selected_cloud(self):
+ self.ctx.cloud.endpoints.resource_manager = 'https://management.sovereign.example.test/'
+ for resource_type in ('sites', 'serverfarms'):
+ with self.subTest(resource_type=resource_type):
+ client = ActiveBackupClient(
+ self.ctx, 'group', 'primary', resource_type, subscription='subscription')
+ parsed = urlsplit(client.operation_url('back up', 'op?#%'))
+ self.assertEqual(parsed.scheme, 'https')
+ self.assertEqual(parsed.netloc, 'management.sovereign.example.test')
+ self.assertEqual(parsed.path, client.resource('back up') + '/operationresults/op%3F%23%25')
+ self.assertEqual(parse_qs(parsed.query),
+ {'api-version': [API_VERSION], 'azureAsyncOperation': ['1']})
+ self.assertEqual(parsed.fragment, '')
+ for operation_id in ('', '..', 'a/b', 'a\\b', None):
+ with self.subTest(operation_id=operation_id), self.assertRaises(InvalidArgumentValueError):
+ self.client.operation_url('backup', operation_id)
+
def test_request_uses_cli_auth_cloud_and_no_redirects(self):
result = response(payload={'properties': {'secret': 'not-logged'}})
with patch(MODULE + '.Profile') as profile, patch(MODULE + '.requests.Session') as factory, \
@@ -171,6 +188,76 @@ def test_synchronous_mutation_and_empty_delete(self):
with patch.object(self.client, 'request', return_value=response(204)):
self.assertIsNone(self.client.mutate('DELETE', self.resource))
+ def test_origin_resource_read_prefers_a_working_point_endpoint(self):
+ origin = self.client.resource('origin')
+ payload = {'id': origin, 'properties': {'outboundIpAddresses': 'fixture-ip', 'unknown': 1}}
+ with patch.object(self.client, 'request', return_value=response(payload=payload)) as request, \
+ patch.object(self.client, 'list') as listing, patch(MODULE + '.logger.warning') as warning:
+ self.assertEqual(self.client.get_resource(origin), payload)
+ request.assert_called_once_with('GET', origin)
+ listing.assert_not_called()
+ warning.assert_not_called()
+
+ def test_origin_404_uses_the_exact_paginated_resource_and_warns_once(self):
+ client = ActiveBackupClient(self.ctx, 'grüp', 'primary', subscription='subscription')
+ origin = client.resource('origin')
+ payload = {'id': unquote(origin).upper(), 'name': 'origin', 'type': 'Microsoft.Web/sites/silos',
+ 'properties': {'endpointStatus': 'Enabled', 'unknown': {'preserved': True}}}
+ foreign = dict(payload, id=origin.replace('/primary/', '/different/'))
+ missing = ResourceNotFoundError('Original origin error; request-id.')
+ with patch.object(client, 'request', side_effect=[
+ missing, response(payload={'value': [foreign], 'nextLink': client.resource() + '?page=2'}),
+ response(payload={'value': [payload]}), missing, response(payload={'value': [payload]})]) as request, \
+ patch(MODULE + '.logger.warning') as warning:
+ self.assertEqual(client.get_resource(origin), payload)
+ self.assertEqual(client.get_resource(origin), payload)
+ warning.assert_called_once()
+ self.assertIn('Origin point GET returned HTTP 404', warning.call_args.args[0])
+ self.assertEqual(request.call_count, 5)
+
+ def test_origin_fallback_never_applies_to_other_reads_or_errors(self):
+ plan = ActiveBackupClient(self.ctx, 'group', 'primary', 'serverfarms', subscription='subscription')
+ cases = (
+ (self.client, self.resource, ResourceNotFoundError('Missing replica')),
+ (self.client, self.client.resource('origin') + '/config/web', ResourceNotFoundError('Missing config')),
+ (plan, plan.resource('origin'), ResourceNotFoundError('Missing plan origin')),
+ (self.client, self.client.resource('origin'), AzureResponseError('Forbidden')),
+ (self.client, self.client.resource('origin'), AzureResponseError('Service failed')),
+ )
+ for client, path, error in cases:
+ with self.subTest(path=path, error=str(error)), \
+ patch.object(client, 'request', side_effect=error), patch.object(client, 'list') as listing:
+ with self.assertRaises(type(error)) as caught:
+ client.get_resource(path)
+ self.assertIs(caught.exception, error)
+ listing.assert_not_called()
+
+ def test_origin_collection_missing_duplicate_or_invalid_results_remain_errors(self):
+ origin = self.client.resource('origin')
+ payload = {'id': origin, 'type': 'Microsoft.Web/sites/silos', 'properties': {'endpointStatus': 'Enabled'}}
+ for entries, error_type in (
+ ([], ResourceNotFoundError),
+ ([dict(payload, id=self.resource)], ResourceNotFoundError),
+ ([payload, dict(payload)], AzureResponseError),
+ ([dict(payload, type='Microsoft.Web/sites')], AzureResponseError),
+ ([dict(payload, properties=None)], AzureResponseError)):
+ with self.subTest(entries=entries), \
+ patch.object(self.client, 'request', side_effect=ResourceNotFoundError('Original error')), \
+ patch.object(self.client, 'list', return_value=entries), \
+ patch(MODULE + '.logger.warning') as warning, self.assertRaises(error_type):
+ self.client.get_resource(origin)
+ warning.assert_not_called()
+
+ def test_origin_collection_failures_are_not_hidden(self):
+ origin = self.client.resource('origin')
+ for error in (AzureResponseError('List forbidden'), ResourceNotFoundError('List missing')):
+ with self.subTest(error=str(error)), \
+ patch.object(self.client, 'request', side_effect=ResourceNotFoundError('Point missing')), \
+ patch.object(self.client, 'list', side_effect=error):
+ with self.assertRaises(type(error)) as caught:
+ self.client.get_resource(origin)
+ self.assertIs(caught.exception, error)
+
def test_no_wait_returns_receipt_without_polling(self):
poll = self.resource + '/operationresults/operation'
for header in ('Location', 'Azure-AsyncOperation'):
@@ -182,6 +269,21 @@ def test_no_wait_returns_receipt_without_polling(self):
self.assertEqual(result['resourceId'], self.resource)
request.assert_called_once()
+ def test_async_status_header_takes_precedence_and_preserves_its_query(self):
+ location = self.resource + '/operationresults/operation'
+ poll = self.client.url(location, {'azureAsyncOperation': 1})
+ initial = response(202, headers={
+ 'Location': location, 'Azure-AsyncOperation': poll, 'Retry-After': '0'})
+ with patch.object(self.client, 'request', return_value=initial) as request:
+ receipt = self.client.mutate('DELETE', self.resource, no_wait=True)
+ self.assertEqual(receipt['operationUrl'], poll)
+ request.assert_called_once()
+ with patch.object(self.client, 'request', side_effect=[
+ initial, response(payload={'status': 'Succeeded'})]) as request, patch(MODULE + '.time.sleep'):
+ self.assertIsNone(self.client.mutate('DELETE', self.resource))
+ self.assertEqual(request.call_args.args[:2], ('GET', poll))
+ self.assertEqual(request.call_count, 2)
+
def test_poll_status_body_and_final_resource(self):
poll = self.resource + '/operationresults/operation'
with patch.object(self.client, 'request', side_effect=[
@@ -195,12 +297,50 @@ def test_poll_status_body_and_final_resource(self):
self.assertEqual(request.call_args.args, ('GET', self.resource))
self.assertEqual(request.call_count, 4)
+ def test_operation_status_envelopes_are_not_resources(self):
+ for identifier in (None, '', '/operations/op', 123):
+ with self.subTest(identifier=identifier):
+ result = self.client.operation(self.resource, response(payload={
+ 'id': identifier, 'status': 'Succeeded', 'properties': None}))
+ self.assertEqual(result['status'], 'Succeeded')
+ self.assertNotIn('resource', result)
+ resource = {'id': self.resource, 'properties': {'provisioningState': 'Succeeded'}}
+ self.assertEqual(self.client.operation(self.resource, response(payload=resource))['resource'], resource)
+
+ def test_async_writes_return_the_canonical_target_resource(self):
+ poll = self.resource + '/operationresults/op?azureAsyncOperation=1'
+ resource = {'id': self.resource, 'properties': {'revisionNumber': 8}}
+ for method in ('PUT', 'PATCH'):
+ for terminal in (
+ {'status': 'Succeeded'},
+ {'id': None, 'status': 'Succeeded', 'properties': None},
+ {'id': '/operations/op', 'status': 'Succeeded'},
+ {'id': self.resource, 'properties': {'revisionNumber': 7}}):
+ with self.subTest(method=method, terminal=terminal), \
+ patch.object(self.client, 'request', side_effect=[
+ response(202, headers={'Azure-AsyncOperation': poll, 'Retry-After': '0'}),
+ response(payload=terminal), response(payload=resource)]) as request, \
+ patch(MODULE + '.time.sleep'):
+ self.assertEqual(self.client.mutate(method, self.resource), resource)
+ self.assertEqual(request.call_args.args, ('GET', self.resource))
+ self.assertEqual(request.call_count, 3)
+
+ def test_final_resource_read_errors_remain_errors(self):
+ poll = self.resource + '/operationresults/op'
+ with patch.object(self.client, 'request', side_effect=[
+ response(202, headers={'Location': poll, 'Retry-After': '0'}),
+ response(payload={'id': None, 'status': 'Succeeded'}),
+ ResourceNotFoundError('The target was not found.')]) as request, \
+ patch(MODULE + '.time.sleep'), self.assertRaises(ResourceNotFoundError):
+ self.client.mutate('PUT', self.resource)
+ self.assertEqual(request.call_args.args, ('GET', self.resource))
+
def test_delete_and_post_do_not_get_deleted_resource_or_action(self):
poll = self.resource + '/operationresults/operation'
for method in ('DELETE', 'POST'):
with self.subTest(method=method), patch.object(self.client, 'request', side_effect=[
response(202, headers={'Location': poll, 'Retry-After': '0'}),
- response(payload={'status': 'Succeeded'})]) as request, patch(MODULE + '.time.sleep'):
+ response(payload={'id': None, 'status': 'Succeeded'})]) as request, patch(MODULE + '.time.sleep'):
self.assertIsNone(self.client.mutate(method, self.resource))
self.assertEqual(request.call_count, 2)
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py
index 06723b2d3d3..35725a57c50 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py
@@ -6,13 +6,14 @@
from contextlib import redirect_stderr, redirect_stdout
import inspect
import io
+import json
import unittest
from unittest.mock import patch
-from urllib.parse import urlsplit
+from urllib.parse import parse_qs, urlsplit
from azure.cli.core.azclierror import AzureResponseError, InvalidArgumentValueError, ResourceNotFoundError
from azure.cli.command_modules.appservice import active_backup as commands
-from azure.cli.command_modules.appservice._active_backup_client import ActiveBackupClient
+from azure.cli.command_modules.appservice._active_backup_client import ActiveBackupClient, API_VERSION
from azure.cli.command_modules.appservice._active_backup_commands import COMMAND_GROUPS
from .test_active_backup_client import cli_context, response
@@ -373,6 +374,20 @@ def test_all_registered_handlers_and_help_exist(self):
class ActiveBackupHelpTests(unittest.TestCase):
+ @staticmethod
+ def invoke_json(arguments):
+ from azure.cli.core import get_default_cli
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ with redirect_stdout(stdout), redirect_stderr(stderr):
+ try:
+ exit_code = get_default_cli().invoke(arguments + ['--output', 'json'], out_file=stdout)
+ except SystemExit as ex:
+ exit_code = ex.code
+ if exit_code:
+ raise AssertionError(stderr.getvalue())
+ return exit_code, stdout.getvalue()
+
@staticmethod
def invoke(arguments):
from azure.cli.core import get_default_cli
@@ -427,6 +442,207 @@ def test_cli_explicit_false_and_no_wait(self):
self.assertIs(request.call_args.args[2]['properties']['zoneRedundantOverride'], False)
request.assert_called_once()
+ def test_cli_no_wait_receipts_are_not_discarded(self):
+ for group, entries in COMMAND_GROUPS.items():
+ for verb, handler_name in entries:
+ parameters = inspect.signature(getattr(commands, handler_name)).parameters
+ if 'no_wait' not in parameters:
+ continue
+ with self.subTest(command=group + ' ' + verb):
+ client = ActiveBackupClient(
+ cli_context(), 'group', 'primary',
+ 'serverfarms' if group.startswith('appservice plan') else 'sites',
+ subscription='sub')
+ resource_id = client.resource('backup')
+ poll = resource_id + '/operationresults/op'
+ initial = response(202, headers={'Location': poll})
+ existing = response(payload={
+ 'id': resource_id, 'location': 'westus2',
+ 'properties': {'primaryLocation': 'eastus'}})
+ arguments = (group + ' ' + verb).split() + [
+ '-g', 'group', '-n', 'primary', '--silo', 'backup', '--no-wait']
+ if 'location' in parameters:
+ arguments += ['--location', 'westus2', '--primary-location', 'eastus']
+ if verb == 'delete':
+ arguments += ['--yes']
+ with patch(MODULE + '._client', return_value=client), \
+ patch.object(client, 'request', side_effect=lambda method, *args, **kwargs:
+ existing if method == 'GET' else initial) as request:
+ exit_code, output = self.invoke_json(arguments)
+ self.assertEqual(exit_code, 0)
+ self.assertTrue(output.strip(), 'The CLI discarded the no-wait operation receipt.')
+ receipt = json.loads(output)
+ self.assertEqual(receipt['status'], 'Accepted')
+ self.assertEqual(receipt['operationId'], 'op')
+ self.assertEqual(receipt['resourceId'], request.call_args.args[1])
+ self.assertEqual(receipt['operationUrl'], client.url(poll))
+ self.assertEqual(request.call_count, 2 if verb == 'update' else 1)
+
+ def test_cli_no_wait_preserves_synchronous_resource_results(self):
+ for group in ('appservice plan silo', 'webapp silo'):
+ with self.subTest(command=group + ' create'):
+ client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
+ resource = {'id': client.resource('backup'), 'properties': {'provisioningState': 'Succeeded'}}
+ with patch(MODULE + '._client', return_value=client), \
+ patch.object(client, 'request', return_value=response(payload=resource)) as request:
+ exit_code, output = self.invoke_json(
+ (group + ' create -g group -n primary --silo backup --location westus2 '
+ '--primary-location eastus --no-wait').split())
+ self.assertEqual(exit_code, 0)
+ self.assertTrue(output.strip(), 'The CLI discarded the synchronous resource result.')
+ self.assertEqual(json.loads(output), dict(resource, resourceGroup='group'))
+ request.assert_called_once()
+
+ def test_cli_waited_writes_return_resource_json_not_operation_status(self):
+ for group in ('appservice plan silo', 'webapp silo'):
+ for verb in ('create', 'update'):
+ with self.subTest(command=group + ' ' + verb):
+ client = ActiveBackupClient(
+ cli_context(), 'group', 'primary',
+ 'serverfarms' if group.startswith('appservice plan') else 'sites',
+ subscription='sub')
+ resource_id = client.resource('backup')
+ existing = {'id': resource_id, 'location': 'westus2',
+ 'properties': {'primaryLocation': 'eastus'}}
+ resource = dict(existing, properties={
+ 'primaryLocation': 'eastus', 'provisioningState': 'Succeeded',
+ 'instanceNumberOverride': 2, 'zoneRedundantOverride': False})
+ poll = resource_id + '/operationresults/op?azureAsyncOperation=1'
+ replies = [response(payload=existing)] if verb == 'update' else []
+ replies += [
+ response(202, headers={'Azure-AsyncOperation': poll, 'Retry-After': '0'}),
+ response(payload={'id': None, 'status': 'Succeeded', 'properties': None}),
+ response(payload=resource)]
+ with patch(MODULE + '._client', return_value=client), \
+ patch.object(client, 'request', side_effect=replies) as request, \
+ patch('azure.cli.command_modules.appservice._active_backup_client.time.sleep'):
+ exit_code, output = self.invoke_json(
+ (group + ' ' + verb + ' -g group -n primary --silo backup '
+ '--location westus2 --primary-location eastus').split())
+ self.assertEqual(exit_code, 0)
+ self.assertEqual(json.loads(output), dict(resource, resourceGroup='group'))
+ self.assertEqual(request.call_args.args, ('GET', resource_id))
+ self.assertEqual(request.call_count, 4 if verb == 'update' else 3)
+
+ def test_cli_operation_show_uses_async_status_mode(self):
+ for group, resource_type in (('appservice plan silo', 'serverfarms'), ('webapp silo', 'sites')):
+ for state in ('InProgress', 'Succeeded'):
+ with self.subTest(group=group, state=state):
+ client = ActiveBackupClient(
+ cli_context(), 'group', 'primary', resource_type, subscription='sub')
+ with patch(MODULE + '._client', return_value=client), \
+ patch.object(client, 'request', return_value=response(
+ payload={'id': None, 'status': state, 'properties': None})) as request:
+ exit_code, output = self.invoke_json(
+ (group + ' operation show -g group -n primary --silo backup --operation-id op').split())
+ self.assertEqual(exit_code, 0)
+ request.assert_called_once()
+ method, address = request.call_args.args
+ self.assertEqual(method, 'GET')
+ self.assertEqual(urlsplit(address).path, client.resource('backup') + '/operationresults/op')
+ self.assertEqual(parse_qs(urlsplit(address).query),
+ {'api-version': [API_VERSION], 'azureAsyncOperation': ['1']})
+ self.assertEqual(json.loads(output),
+ {'operationId': 'op', 'operationUrl': address, 'status': state})
+
+ def test_cli_wait_uses_async_status_mode_without_resource_readback(self):
+ for group, resource_type in (('appservice plan silo', 'serverfarms'), ('webapp silo', 'sites')):
+ with self.subTest(group=group):
+ client = ActiveBackupClient(cli_context(), 'group', 'primary', resource_type, subscription='sub')
+ with patch(MODULE + '._client', return_value=client), \
+ patch.object(client, 'request', side_effect=[
+ response(202, headers={'Retry-After': '0'}),
+ response(payload={'id': None, 'status': 'Succeeded', 'properties': None})]) as request, \
+ patch('azure.cli.command_modules.appservice._active_backup_client.time.sleep'):
+ exit_code, output = self.invoke_json(
+ (group + ' wait -g group -n primary --silo backup --operation-id op').split())
+ self.assertEqual(exit_code, 0)
+ self.assertEqual(request.call_count, 2)
+ for call in request.call_args_list:
+ self.assertEqual(call.args[0], 'GET')
+ self.assertEqual(urlsplit(call.args[1]).path, client.resource('backup') + '/operationresults/op')
+ self.assertEqual(parse_qs(urlsplit(call.args[1]).query),
+ {'api-version': [API_VERSION], 'azureAsyncOperation': ['1']})
+ self.assertEqual(json.loads(output),
+ {'operationId': 'op', 'operationUrl': request.call_args.args[1], 'status': 'Succeeded'})
+
+ def test_cli_operation_failures_are_not_masked(self):
+ forbidden = AzureResponseError('Operation access denied.')
+ forbidden.response = response(403)
+ failures = [ResourceNotFoundError('Operation missing.'), forbidden] + [
+ response(payload={'status': status, 'error': {'code': 'SiloFailed', 'message': 'Operation failed.'}})
+ for status in ('Failed', 'Cancelled', 'TimedOut')]
+ for group, resource_type in (('appservice plan silo', 'serverfarms'), ('webapp silo', 'sites')):
+ for action in ('operation show', 'wait'):
+ for failure in failures:
+ with self.subTest(group=group, action=action, failure=failure):
+ client = ActiveBackupClient(
+ cli_context(), 'group', 'primary', resource_type, subscription='sub')
+ with patch(MODULE + '._client', return_value=client), \
+ patch.object(client, 'request', side_effect=[failure]) as request:
+ exit_code = self.invoke(
+ (group + ' ' + action + ' -g group -n primary --silo origin --operation-id op').split())
+ self.assertNotEqual(exit_code, 0)
+ request.assert_called_once()
+ self.assertEqual(request.call_args.args[0], 'GET')
+ self.assertEqual(urlsplit(request.call_args.args[1]).path,
+ client.resource('origin') + '/operationresults/op')
+ self.assertEqual(parse_qs(urlsplit(request.call_args.args[1]).query),
+ {'api-version': [API_VERSION], 'azureAsyncOperation': ['1']})
+
+ def test_cli_origin_show_reads_the_collection_after_point_404(self):
+ client = ActiveBackupClient(cli_context(), 'group', 'primary',
+ subscription='00000000-0000-0000-0000-000000000001')
+ origin = client.resource('origin')
+ resource = {'id': origin, 'name': 'origin', 'type': 'Microsoft.Web/sites/silos',
+ 'location': 'eastus', 'properties': {'primaryLocation': 'eastus', 'endpointStatus': 'Enabled'}}
+ with patch(MODULE + '._client', return_value=client), \
+ patch.object(client, 'request', side_effect=[
+ ResourceNotFoundError('Origin point missing'), response(payload={'value': [resource]})]) as request, \
+ patch('azure.cli.command_modules.appservice._active_backup_client.logger.warning') as warning:
+ exit_code, output = self.invoke_json(['webapp', 'silo', 'show', '--ids', origin])
+ self.assertEqual(exit_code, 0)
+ self.assertEqual(json.loads(output), dict(resource, resourceGroup='group'))
+ self.assertEqual([call.args[:2] for call in request.call_args_list],
+ [('GET', origin), ('GET', client.url(client.resource()))])
+ warning.assert_called_once()
+
+ def test_cli_origin_update_uses_the_collection_before_and_after_async_write(self):
+ client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
+ origin = client.resource('origin')
+ before = {'id': origin, 'name': 'origin', 'type': 'Microsoft.Web/sites/silos', 'location': 'eastus',
+ 'properties': {'primaryLocation': 'eastus', 'endpointStatus': 'Enabled'}}
+ after = dict(before, properties={'primaryLocation': 'eastus', 'endpointStatus': 'Disabled'})
+ poll = origin + '/operationresults/op'
+ with patch(MODULE + '._client', return_value=client), \
+ patch.object(client, 'request', side_effect=[
+ ResourceNotFoundError('Point missing'), response(payload={'value': [before]}),
+ response(202, headers={'Azure-AsyncOperation': poll, 'Retry-After': '0'}),
+ response(payload={'id': None, 'status': 'Succeeded'}),
+ ResourceNotFoundError('Point still missing'), response(payload={'value': [after]})]) as request, \
+ patch('azure.cli.command_modules.appservice._active_backup_client.time.sleep'), \
+ patch('azure.cli.command_modules.appservice._active_backup_client.logger.warning') as warning:
+ exit_code, output = self.invoke_json(
+ 'webapp silo update -g group -n primary --silo origin --endpoint-status Disabled'.split())
+ self.assertEqual(exit_code, 0)
+ self.assertEqual(json.loads(output), dict(after, resourceGroup='group'))
+ self.assertEqual(request.call_count, 6)
+ self.assertEqual(request.call_args_list[2].args[:2], ('PUT', origin))
+ expected_body = {
+ 'location': 'eastus',
+ 'properties': {'name': 'origin', 'primaryLocation': 'eastus', 'endpointStatus': 'Disabled'}}
+ self.assertEqual(request.call_args_list[2].args[2], expected_body)
+ warning.assert_called_once()
+
+ def test_cli_origin_pass_through_does_not_use_collection_fallback(self):
+ client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
+ with patch(MODULE + '._client', return_value=client), \
+ patch.object(client, 'request', side_effect=ResourceNotFoundError('Config missing')) as request:
+ self.assertEqual(self.invoke(
+ 'webapp silo config show -g group -n primary --silo origin'.split()), 3)
+ request.assert_called_once()
+ self.assertEqual(request.call_args.args[:2], ('GET', client.resource('origin') + '/config/web'))
+
def test_cli_not_found_has_show_exit_code(self):
client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
with patch(MODULE + '._client', return_value=client), \
From d29a206dbbf52f6305c2bd99902b5acb0a6cf46b Mon Sep 17 00:00:00 2001
From: Your Name
Date: Tue, 20 Oct 2026 09:49:49 -0500
Subject: [PATCH 3/6] Add local Active Backup CLI validation harness
Add checkout-bound PowerShell Plan, Preflight, Run, Resume and Cleanup modes with explicit subscription/resource authorization, protected evidence, ownership-checked cleanup and offline safety tests. Cover Windows Core workflows while preserving known service polling failures in report history.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 224abf0a-0923-4347-a5ea-5c4a1b4e4824
---
.gitignore | 1 +
.../appservice/ActiveBackupTestHarness.psm1 | 1756 +++++++++++++++++
scripts/appservice/README.md | 311 +++
scripts/appservice/test_active_backup.ps1 | 150 ++
.../tests/Test-ActiveBackupHarness.ps1 | 991 ++++++++++
5 files changed, 3209 insertions(+)
create mode 100644 scripts/appservice/ActiveBackupTestHarness.psm1
create mode 100644 scripts/appservice/README.md
create mode 100644 scripts/appservice/test_active_backup.ps1
create mode 100644 scripts/appservice/tests/Test-ActiveBackupHarness.ps1
diff --git a/.gitignore b/.gitignore
index 329fdbc1087..4ab1dcda4f3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -96,6 +96,7 @@ artifacts/
# local Azure configuration
.azure-config/
+.x/active-backup/
# Python version
.python-version
diff --git a/scripts/appservice/ActiveBackupTestHarness.psm1 b/scripts/appservice/ActiveBackupTestHarness.psm1
new file mode 100644
index 00000000000..69537485303
--- /dev/null
+++ b/scripts/appservice/ActiveBackupTestHarness.psm1
@@ -0,0 +1,1756 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#requires -Version 7.4
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+function Get-AbValue {
+ param($Object, [string]$Name, $Default = $null)
+ if ($Object -is [System.Collections.IDictionary] -and $Object.Contains($Name)) { return $Object[$Name] }
+ if ($null -ne $Object -and $null -ne $Object.PSObject.Properties[$Name]) { return $Object.$Name }
+ return $Default
+}
+
+function Stop-Ab {
+ param([string]$Message, [ValidateSet('Fail', 'BlockedEnvironment', 'BlockedFixture', 'Invalid', 'Cleanup')][string]$Kind = 'Fail',
+ [Exception]$InnerException)
+ $exception = [System.InvalidOperationException]::new($Message, $InnerException)
+ $exception.Data['AbKind'] = $Kind
+ $exception.Data['AbSafeMessage'] = $Message
+ throw $exception
+}
+
+function Assert-Ab {
+ param([bool]$Condition, [string]$Message)
+ if (-not $Condition) { Stop-Ab $Message }
+}
+
+function Assert-AbLocalPath {
+ param([string]$RepoRoot, [string]$Path, [switch]$RequireIgnored)
+ $root = [IO.Path]::GetFullPath($RepoRoot).TrimEnd([IO.Path]::DirectorySeparatorChar)
+ $full = [IO.Path]::GetFullPath($Path, $root)
+ if (-not $full.StartsWith($root + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
+ Stop-Ab 'Runtime paths must be inside the repository, not its root or an external directory.' Invalid
+ }
+ $item = $full
+ while ($item -and $item.Length -ge $root.Length) {
+ if (Test-Path -LiteralPath $item) {
+ if ((Get-Item -Force -LiteralPath $item).Attributes -band [IO.FileAttributes]::ReparsePoint) {
+ Stop-Ab 'Runtime paths must not traverse a reparse point.' Invalid
+ }
+ }
+ $item = [IO.Path]::GetDirectoryName($item)
+ }
+ if ($RequireIgnored) {
+ $relative = [IO.Path]::GetRelativePath($root, $full)
+ & git -C $root check-ignore --quiet -- $relative
+ if ($LASTEXITCODE -ne 0) { Stop-Ab 'Runtime/auth/output paths must be ignored by Git before use.' Invalid }
+ }
+ return $full
+}
+
+function Get-AbCommandCatalog {
+ param([string]$RepoRoot)
+ $path = Join-Path $RepoRoot 'src\azure-cli\azure\cli\command_modules\appservice\_active_backup_commands.py'
+ if (-not (Test-Path -LiteralPath $path)) { Stop-Ab 'The checkout does not contain the Active Backup commands.' Invalid }
+ $source = [IO.File]::ReadAllText($path)
+ $section = ($source -split '(?m)^def load_command_table')[0]
+ $groups = [regex]::Matches($section, "(?ms)^ '(?[^']+)': \((?.*?)(?=^ '[^']+':|\r?\n\})")
+ $catalog = @()
+ foreach ($group in $groups) {
+ foreach ($entry in [regex]::Matches($group.Groups['entries'].Value, "\('(?[^']+)', '(?[^']+)'\)")) {
+ $catalog += @{
+ command = $group.Groups['group'].Value + ' ' + $entry.Groups['verb'].Value
+ handler = $entry.Groups['handler'].Value
+ }
+ }
+ }
+ Assert-Ab ($catalog.Count -gt 0) 'Unable to read the explicit Active Backup command registry.'
+ $tests = Join-Path $RepoRoot 'src\azure-cli\azure\cli\command_modules\appservice\tests\latest\test_active_backup_commands_thru_mock.py'
+ $variants = @()
+ foreach ($match in [regex]::Matches([IO.File]::ReadAllText($tests), "\('(?[PC]\d\d)', '(?[^']+)'")) {
+ if ($match.Groups['handler'].Value -in @($catalog.handler) -and
+ -not @($variants | Where-Object key -eq $match.Groups['key'].Value).Count) {
+ $variants += @{ key = $match.Groups['key'].Value; handler = $match.Groups['handler'].Value }
+ }
+ }
+ return @{ commands = @($catalog); variants = @($variants) }
+}
+
+function Invoke-AbProcess {
+ param(
+ [string]$Executable, [string[]]$Arguments, [string]$WorkingDirectory,
+ [hashtable]$Environment = @{}, [int]$TimeoutSeconds = 120
+ )
+ $start = [Diagnostics.ProcessStartInfo]::new()
+ $start.FileName = $Executable
+ $start.WorkingDirectory = $WorkingDirectory
+ $start.UseShellExecute = $false
+ $start.RedirectStandardOutput = $true
+ $start.RedirectStandardError = $true
+ $start.StandardOutputEncoding = [Text.UTF8Encoding]::new($false)
+ $start.StandardErrorEncoding = [Text.UTF8Encoding]::new($false)
+ $start.Environment['PYTHONDONTWRITEBYTECODE'] = '1'
+ $start.Environment['PYTHONIOENCODING'] = 'utf-8'
+ foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) }
+ foreach ($key in $Environment.Keys) { $start.Environment[$key] = [string]$Environment[$key] }
+ $process = [Diagnostics.Process]::new()
+ $process.StartInfo = $start
+ $clock = [Diagnostics.Stopwatch]::StartNew()
+ $started = $false
+ try {
+ if (-not $process.Start()) { Stop-Ab 'Unable to start the selected interpreter.' Invalid }
+ $started = $true
+ $stdout = $process.StandardOutput.ReadToEndAsync()
+ $stderr = $process.StandardError.ReadToEndAsync()
+ $completed = $process.WaitForExit([Math]::Min([int]::MaxValue, $TimeoutSeconds * 1000L))
+ if (-not $completed) {
+ $process.Kill($true)
+ $process.WaitForExit()
+ }
+ return @{
+ exitCode = $(if ($completed) { $process.ExitCode } else { 124 })
+ stdout = $stdout.GetAwaiter().GetResult()
+ stderr = $stderr.GetAwaiter().GetResult()
+ elapsedMs = $clock.ElapsedMilliseconds
+ timedOut = -not $completed
+ }
+ }
+ finally {
+ if ($started -and -not $process.HasExited) { $process.Kill($true); $process.WaitForExit() }
+ $process.Dispose()
+ }
+}
+
+function Resolve-AbPython {
+ param([string]$RepoRoot, [string]$PythonExe)
+ if ($PythonExe) {
+ $candidate = [IO.Path]::GetFullPath($PythonExe, $RepoRoot)
+ if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { Stop-Ab 'The selected Python executable does not exist.' Invalid }
+ return $candidate
+ }
+ foreach ($relative in @('env\Scripts\python.exe', '.venv\Scripts\python.exe')) {
+ $candidate = Join-Path $RepoRoot $relative
+ if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate }
+ }
+ Stop-Ab 'Prepare a repository CLI environment or pass -PythonExe explicitly. The system az is not used.' Invalid
+}
+
+function New-AbContext {
+ param(
+ [string]$RepoRoot, [string]$Subscription, [string]$PrimaryRegion = 'eastus2euap',
+ [string]$SiloRegion = 'centraluseuap', [string]$PythonExe,
+ [string]$Profile = 'Core', [string]$OperatingSystem = 'Both',
+ [string]$OutputRoot = '.x\active-backup\runs', [string]$AuthConfigDir = '.x\active-backup\auth',
+ [string]$RunId, [int]$OperationTimeoutSeconds = 1800, [int]$ObservationTimeoutSeconds = 900,
+ [int]$RunTimeoutMinutes = 120, [int]$CleanupTimeoutMinutes = 30, [int]$MaxWorkersPerPlan = 2,
+ [bool]$IncludeSkuMatrix = $false, [bool]$IncludeInvasiveDiagnostics = $false,
+ [bool]$IncludeZoneRedundancy = $false, [string]$Mode = 'Plan'
+ )
+ $root = [IO.Path]::GetFullPath($RepoRoot)
+ if (-not (Test-Path -LiteralPath (Join-Path $root '.git'))) { Stop-Ab 'Select the azure-cli repository root.' Invalid }
+ $guid = [guid]::Empty
+ if (-not [guid]::TryParse($Subscription, [ref]$guid)) { Stop-Ab '-Subscription must be an explicit subscription GUID.' Invalid }
+ if ($PrimaryRegion -notmatch '^[a-z0-9]+$' -or $SiloRegion -notmatch '^[a-z0-9]+$' -or $PrimaryRegion -eq $SiloRegion) {
+ Stop-Ab 'Provide two distinct canonical Azure region names.' Invalid
+ }
+ if (-not $RunId) { $RunId = [DateTime]::UtcNow.ToString('yyyyMMddHHmmss') + '-' + [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(3)).ToLowerInvariant() }
+ if ($RunId -notmatch '^\d{14}-[a-f0-9]{6}$') { Stop-Ab 'RunId must be the generated UTC timestamp and six hex characters.' Invalid }
+ if ($MaxWorkersPerPlan -notin @(1, 2)) { Stop-Ab 'This harness supports a maximum of one or two workers per plan.' Invalid }
+ $catalog = Get-AbCommandCatalog $root
+ $out = Assert-AbLocalPath $root $OutputRoot -RequireIgnored
+ $auth = Assert-AbLocalPath $root $AuthConfigDir -RequireIgnored
+ $directory = Assert-AbLocalPath $root (Join-Path $out $RunId) -RequireIgnored
+ if ($directory.StartsWith($auth + '\', [StringComparison]::OrdinalIgnoreCase) -or $auth.StartsWith($directory, [StringComparison]::OrdinalIgnoreCase)) {
+ Stop-Ab 'Authentication and report directories must be separate.' Invalid
+ }
+ $sha = (& git -C $root rev-parse HEAD | Out-String).Trim()
+ if ($LASTEXITCODE -ne 0) { Stop-Ab 'Cannot resolve the checkout commit.' Invalid }
+ $client = Join-Path $root 'src\azure-cli\azure\cli\command_modules\appservice\_active_backup_client.py'
+ $version = [regex]::Match([IO.File]::ReadAllText($client), "(?m)^API_VERSION = '([^']+)'").Groups[1].Value
+ if ($version -ne '2026-08-01') { Stop-Ab 'The selected API version differs from the reviewed harness contract.' Invalid }
+ $manifest = @{
+ schemaVersion = 1; purpose = 'active-backup-cli-validation'; runId = $RunId; repoRoot = $root
+ sourceCommit = $sha; subscription = $guid.ToString(); primaryRegion = $PrimaryRegion; siloRegion = $SiloRegion
+ apiVersion = $version; profile = $Profile; operatingSystem = $OperatingSystem
+ pythonExe = $PythonExe; authConfigDir = $auth; runDirectory = $directory
+ createdUtc = [DateTime]::UtcNow.ToString('o'); owner = [Environment]::UserName
+ state = 'Planned'; suites = @(); cases = @(); pendingMutation = $null
+ cleanup = @{ status = 'NotStarted'; remaining = @() }
+ options = @{
+ operationTimeoutSeconds = $OperationTimeoutSeconds; observationTimeoutSeconds = $ObservationTimeoutSeconds
+ runTimeoutMinutes = $RunTimeoutMinutes; cleanupTimeoutMinutes = $CleanupTimeoutMinutes
+ maxWorkersPerPlan = $MaxWorkersPerPlan; includeSkuMatrix = $IncludeSkuMatrix
+ includeInvasiveDiagnostics = $IncludeInvasiveDiagnostics; includeZoneRedundancy = $IncludeZoneRedundancy
+ }
+ }
+ $manifest.sourceHashes = @{}
+ foreach ($relative in @(
+ 'src\azure-cli\azure\cli\command_modules\appservice\active_backup.py',
+ 'src\azure-cli\azure\cli\command_modules\appservice\_active_backup_client.py',
+ 'src\azure-cli\azure\cli\command_modules\appservice\_active_backup_commands.py',
+ 'scripts\appservice\test_active_backup.ps1',
+ 'scripts\appservice\ActiveBackupTestHarness.psm1')) {
+ $manifest.sourceHashes[$relative] = (Get-FileHash -LiteralPath (Join-Path $root $relative) -Algorithm SHA256).Hash
+ }
+ return @{
+ RepoRoot = $root; Subscription = $guid.ToString(); PrimaryRegion = $PrimaryRegion; SiloRegion = $SiloRegion
+ PythonExe = $PythonExe; Profile = $Profile; OperatingSystem = $OperatingSystem; Mode = $Mode
+ RunDirectory = $directory; ManifestPath = Join-Path $directory 'manifest.json'
+ AuthConfigDir = $auth; Catalog = $catalog; Manifest = $manifest; CurrentCase = $null
+ RunDeadline = [DateTime]::UtcNow.AddMinutes($RunTimeoutMinutes); CleanupDeadline = $null
+ Executor = $null; HttpExecutor = $null; Lock = $null; ArmEndpoint = 'https://management.azure.com/'
+ ApiVersion = $version; Environment = @{}; InCleanup = $false
+ }
+}
+
+function Protect-AbDirectory {
+ param([string]$Path)
+ [IO.Directory]::CreateDirectory($Path) | Out-Null
+ if ($IsWindows) {
+ $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User
+ $directory = [IO.DirectoryInfo]::new($Path)
+ $acl = [IO.FileSystemAclExtensions]::GetAccessControl($directory, [Security.AccessControl.AccessControlSections]::Access)
+ $acl.SetAccessRuleProtection($true, $false)
+ foreach ($rule in $acl.GetAccessRules($true, $false, [Security.Principal.SecurityIdentifier])) {
+ $acl.RemoveAccessRuleSpecific($rule)
+ }
+ foreach ($identity in @($sid, [Security.Principal.SecurityIdentifier]::new('S-1-5-18'))) {
+ $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
+ $identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
+ }
+ [IO.FileSystemAclExtensions]::SetAccessControl($directory, $acl)
+ }
+ else { [IO.File]::SetUnixFileMode($Path, [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor [IO.UnixFileMode]::UserExecute) }
+}
+
+function Initialize-AbLocalState {
+ param([hashtable]$Context, [switch]$Existing)
+ if (-not $Existing -and (Test-Path -LiteralPath $Context.RunDirectory)) { Stop-Ab 'The run directory already exists. Use its manifest with Resume or Cleanup.' Invalid }
+ Protect-AbDirectory $Context.AuthConfigDir
+ Protect-AbDirectory $Context.RunDirectory
+ foreach ($name in @('temp', 'extensions', 'inputs', 'fixtures', 'downloads')) {
+ Protect-AbDirectory (Join-Path $Context.RunDirectory $name)
+ }
+ $Context.Lock = [IO.File]::Open((Join-Path $Context.RunDirectory 'run.lock'), 'OpenOrCreate', 'ReadWrite', 'None')
+ $Context.PythonExe = Resolve-AbPython $Context.RepoRoot $Context.PythonExe
+ $Context.Manifest.pythonExe = $Context.PythonExe
+ $Context.Environment = @{
+ PYTHONPATH = (Join-Path $Context.RepoRoot 'src\azure-cli') + [IO.Path]::PathSeparator + (Join-Path $Context.RepoRoot 'src\azure-cli-core')
+ PYTHONDONTWRITEBYTECODE = '1'; PYTHONIOENCODING = 'utf-8'; AZURE_CONFIG_DIR = $Context.AuthConfigDir
+ AZURE_EXTENSION_DIR = Join-Path $Context.RunDirectory 'extensions'; AZURE_CORE_COLLECT_TELEMETRY = 'false'
+ AZURE_EXTENSION_USE_DYNAMIC_INSTALL = 'no'; TEMP = Join-Path $Context.RunDirectory 'temp'
+ TMP = Join-Path $Context.RunDirectory 'temp'
+ }
+ Save-AbManifest $Context
+}
+
+function Write-AbJson {
+ param([string]$Path, $Value)
+ $temporary = $Path + '.' + [guid]::NewGuid().ToString('N') + '.tmp'
+ try {
+ [IO.File]::WriteAllText($temporary, (ConvertTo-Json -InputObject $Value -Depth 50), [Text.UTF8Encoding]::new($false))
+ [IO.File]::Move($temporary, $Path, $true)
+ }
+ finally { if (Test-Path -LiteralPath $temporary) { [IO.File]::Delete($temporary) } }
+}
+
+function Save-AbManifest { param([hashtable]$Context) Write-AbJson $Context.ManifestPath $Context.Manifest }
+
+function Add-AbEvent {
+ param([hashtable]$Context, [string]$Kind, [hashtable]$Data = @{})
+ $record = @{ utc = [DateTime]::UtcNow.ToString('o'); kind = $Kind; data = $Data }
+ [IO.File]::AppendAllText((Join-Path $Context.RunDirectory 'events.jsonl'),
+ (ConvertTo-Json -InputObject $record -Depth 10 -Compress) + [Environment]::NewLine, [Text.UTF8Encoding]::new($false))
+}
+
+function Assert-AbProvenance {
+ param([hashtable]$Context, [hashtable]$Provenance)
+ foreach ($name in @('azure.cli', 'azure.cli.core', 'azure.cli.command_modules.appservice',
+ 'azure.cli.command_modules.appservice.active_backup', 'azure.cli.command_modules.appservice._active_backup_client')) {
+ $expected = Join-Path $Context.RepoRoot $(if ($name -eq 'azure.cli.core') { 'src\azure-cli-core' } else { 'src\azure-cli' })
+ $path = [string](Get-AbValue $Provenance.modules $name '')
+ if (-not $path -or -not ([IO.Path]::GetFullPath($path)).StartsWith($expected + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
+ Stop-Ab 'Source provenance failed: an installed CLI module masked this checkout.' Invalid
+ }
+ }
+ Assert-Ab ($Provenance.apiVersion -eq $Context.ApiVersion) 'Source probe API version mismatch.'
+ Assert-Ab (@($Provenance.helpFailures).Count -eq 0) 'At least one Active Backup command help entry did not load.'
+ Assert-Ab (@(Compare-Object @($Provenance.commands | Sort-Object) @($Context.Catalog.commands.command | Sort-Object)).Count -eq 0) 'Source command inventory mismatch.'
+}
+
+function Test-AbSource {
+ param([hashtable]$Context)
+ $code = @'
+import contextlib, importlib, io, json, sys
+names = ["azure.cli", "azure.cli.core", "azure.cli.command_modules.appservice",
+ "azure.cli.command_modules.appservice.active_backup",
+ "azure.cli.command_modules.appservice._active_backup_client"]
+modules = {name: importlib.import_module(name).__file__ for name in names}
+from azure.cli.command_modules.appservice._active_backup_client import API_VERSION
+from azure.cli.command_modules.appservice._active_backup_commands import COMMAND_GROUPS
+from azure.cli.core import get_default_cli
+cli = get_default_cli()
+failures = []
+for group, entries in COMMAND_GROUPS.items():
+ for verb, _ in entries:
+ command = group + " " + verb
+ with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
+ try:
+ status = cli.invoke(command.split() + ["--help"])
+ except SystemExit as ex:
+ status = ex.code
+ if status != 0:
+ failures.append(command)
+print(json.dumps({"modules": modules, "apiVersion": API_VERSION, "helpFailures": failures,
+ "commands": [g + " " + v for g, es in COMMAND_GROUPS.items() for v, _ in es],
+ "pythonVersion": sys.version.split()[0]}))
+'@
+ $result = Invoke-AbProcess $Context.PythonExe @('-c', $code) $Context.RepoRoot $Context.Environment 240
+ if ($result.exitCode -ne 0) { Stop-Ab 'The interpreter cannot load the local CLI. Prepare the CLI development environment; no Azure call was made.' Invalid }
+ try { $provenance = ConvertFrom-Json -InputObject $result.stdout -AsHashtable } catch { Stop-Ab 'The local source probe did not return valid JSON.' Invalid }
+ Assert-AbProvenance $Context $provenance
+ $Context.Manifest.provenance = $provenance
+ Save-AbManifest $Context
+}
+
+function Get-AbCommandName {
+ param([hashtable]$Context, [string[]]$Arguments)
+ $joined = $Arguments -join ' '
+ return @($Context.Catalog.commands.command | Where-Object {
+ $joined -eq $_ -or $joined.StartsWith($_ + ' ', [StringComparison]::Ordinal)
+ } | Sort-Object Length -Descending | Select-Object -First 1)
+}
+
+function Assert-AbResourceId {
+ param([hashtable]$Context, [string]$Id, [string]$Group)
+ $pattern = '^/subscriptions/(?[a-fA-F0-9-]{36})/resourceGroups/(?[^/]+)(?/.*)?$'
+ if ($Id -notmatch $pattern -or $Matches.sub -ne $Context.Subscription) { Stop-Ab 'Resource ID is outside the selected subscription.' Invalid }
+ if ($Group -and $Matches.group -ne $Group) { Stop-Ab 'Resource ID is outside the owned resource group.' Invalid }
+}
+
+function Get-AbSiloResponseMetadata {
+ param([hashtable]$Context, $Payload)
+ $metadata = @{ shape = $(if ($null -eq $Payload) { 'null' } elseif ($Payload -is [Collections.IDictionary]) { 'object' }
+ elseif ($Payload -is [array]) { 'array' } else { 'scalar' }) }
+ if ($Payload -isnot [Collections.IDictionary]) { return $metadata }
+ foreach ($field in @('id', 'resourceId', 'status', 'type', 'location')) {
+ if (-not $Payload.Contains($field)) { continue }
+ $value = $Payload[$field]
+ if ($null -eq $value) { $metadata[$field] = $null; continue }
+ $allowed = $false
+ if ($value -is [string] -and $value.Length -le 2048) {
+ $allowed = switch ($field) {
+ { $_ -in @('id', 'resourceId') } {
+ $value.StartsWith("/subscriptions/$($Context.Subscription)/resourceGroups/", [StringComparison]::OrdinalIgnoreCase) -and
+ $value -match '^/[A-Za-z0-9/_.%()-]+$'
+ }
+ 'status' { $value -in @('Accepted', 'Created', 'Waiting', 'InProgress', 'Running', 'Succeeded', 'Failed', 'Cancelled', 'Canceled', 'TimedOut') }
+ 'type' { $value -in @('Microsoft.Web/serverfarms/silos', 'Microsoft.Web/sites/silos') }
+ 'location' { $value -in @($Context.PrimaryRegion, $Context.SiloRegion) }
+ }
+ }
+ $metadata[$field] = $(if ($allowed) { $value } else { '[omitted]' })
+ }
+ $state = Get-AbValue (Get-AbValue $Payload 'properties') 'provisioningState'
+ if ($state -in @('Accepted', 'Creating', 'Updating', 'Deleting', 'InProgress', 'Succeeded', 'Failed', 'Degraded')) {
+ $metadata.provisioningState = $state
+ }
+ return $metadata
+}
+
+function Invoke-AbAz {
+ param(
+ [hashtable]$Context, [string[]]$Arguments,
+ [ValidateSet('Json', 'Empty', 'Text')][string]$OutputShape = 'Json',
+ [int[]]$ExpectedExitCodes = @(0), [int]$TimeoutSeconds = 0,
+ [switch]$Mutation, [ValidateSet('Observe', 'Create', 'Idempotent', 'NoReplay', 'Delete')][string]$Replay = 'Observe',
+ [string]$IntentId, [string[]]$ApiKeys = @(), [switch]$AllowFailure
+ )
+ if ($Context.Mode -eq 'Plan') { Stop-Ab 'Plan mode must not invoke Azure.' Invalid }
+ if ($Mutation -and $Context.Mode -eq 'Preflight') { Stop-Ab 'Preflight cannot mutate Azure.' Invalid }
+ if ($Context.Mode -eq 'Preflight') {
+ $allowed = @('account show', 'cloud show', 'provider show', 'appservice list-locations', 'webapp list-runtimes', 'rest')
+ if (-not @($allowed | Where-Object { ($Arguments -join ' ').StartsWith($_ + ' ') -or ($Arguments -join ' ') -eq $_ }).Count) {
+ Stop-Ab 'An undeclared command was attempted during read-only preflight.' Invalid
+ }
+ }
+ $argsList = [Collections.Generic.List[string]]::new()
+ foreach ($argument in $Arguments) { $argsList.Add($argument) }
+ if ($argsList.Contains('--debug')) { Stop-Ab 'Debug body logging is not permitted in the harness.' Invalid }
+ if (@($Arguments | Where-Object { $_.StartsWith('--ids=') -or $_.StartsWith('--subscription=') }).Count) {
+ Stop-Ab 'Use separate identity flags and values so every target can be validated.' Invalid
+ }
+ foreach ($identityFlag in @('--subscription', '--ids')) {
+ if (@($Arguments | Where-Object { $_ -eq $identityFlag }).Count -gt 1) { Stop-Ab 'Duplicate identity flags are not permitted.' Invalid }
+ }
+ if ($argsList.Count -ge 2 -and $argsList[0] -eq 'cloud' -and $argsList[1] -eq 'show') {
+ if ($argsList.Contains('--subscription') -or $argsList.Contains('--ids')) {
+ Stop-Ab 'Local cloud metadata does not accept subscription or resource ID arguments.' Invalid
+ }
+ }
+ elseif ($argsList.Contains('--ids')) {
+ foreach ($flag in @('--subscription', '-g', '--resource-group', '-n', '--name', '--silo')) {
+ if ($argsList.Contains($flag)) { Stop-Ab 'Do not combine resource IDs with named subscription/resource arguments.' Invalid }
+ }
+ $idIndex = $argsList.IndexOf('--ids') + 1
+ if ($idIndex -ge $argsList.Count) { Stop-Ab '--ids requires a resource ID.' Invalid }
+ $ids = @()
+ while ($idIndex -lt $argsList.Count -and -not $argsList[$idIndex].StartsWith('-')) {
+ $ids += $argsList[$idIndex]
+ $idIndex++
+ }
+ if (-not $ids.Count) { Stop-Ab '--ids requires at least one resource ID.' Invalid }
+ foreach ($id in $ids) { Assert-AbResourceId $Context $id '' }
+ }
+ else {
+ $index = $argsList.IndexOf('--subscription')
+ if ($index -ge 0) {
+ if ($index + 1 -ge $argsList.Count -or $argsList[$index + 1] -ne $Context.Subscription) {
+ Stop-Ab 'Command subscription differs from the selected run.' Invalid
+ }
+ }
+ else { $argsList.Add('--subscription'); $argsList.Add($Context.Subscription) }
+ }
+ if ($argsList.Count -ge 1 -and $argsList[0] -eq 'rest') {
+ $urlIndex = $argsList.IndexOf('--url')
+ $methodIndex = $argsList.IndexOf('--method')
+ if ($urlIndex -lt 0 -or $urlIndex + 1 -ge $argsList.Count -or $argsList[$urlIndex + 1].StartsWith('-') -or
+ $methodIndex -lt 0 -or $methodIndex + 1 -ge $argsList.Count -or $argsList[$methodIndex + 1] -ne 'get') {
+ Stop-Ab 'Reference REST requests must be explicitly scoped GETs.' Invalid
+ }
+ $address = [uri]::new([uri]$Context.ArmEndpoint, [string]$argsList[$urlIndex + 1])
+ if ($address.GetLeftPart('Authority') -ne ([uri]$Context.ArmEndpoint).GetLeftPart('Authority')) { Stop-Ab 'Reference REST origin mismatch.' Invalid }
+ $subscriptionLocations = $address.AbsolutePath -ieq "/subscriptions/$($Context.Subscription)/locations" -and
+ $address.Query -eq '?api-version=2022-12-01'
+ if ($Context.Mode -eq 'Preflight' -and -not $subscriptionLocations) {
+ Stop-Ab 'Preflight REST is restricted to the exact selected-subscription locations endpoint.' Invalid
+ }
+ if (-not $subscriptionLocations) { Assert-AbResourceId $Context $address.AbsolutePath '' }
+ }
+ if (-not $argsList.Contains('--output') -and -not $argsList.Contains('-o')) {
+ $argsList.Add('--output'); $argsList.Add($(if ($OutputShape -eq 'Text') { 'tsv' } else { 'json' }))
+ }
+ $remaining = $(if ($Context.InCleanup) {
+ ($Context.CleanupDeadline - [DateTime]::UtcNow).TotalSeconds
+ } else { ($Context.RunDeadline - [DateTime]::UtcNow).TotalSeconds })
+ if ($remaining -le 0) { Stop-Ab 'The run/cleanup deadline was reached; no additional request was sent.' $(if ($Context.InCleanup) { 'Cleanup' } else { 'Fail' }) }
+ if ($TimeoutSeconds -le 0) { $TimeoutSeconds = $Context.Manifest.options.operationTimeoutSeconds + 90 }
+ $TimeoutSeconds = [int][Math]::Max(1, [Math]::Min($remaining, $TimeoutSeconds))
+ $command = @(Get-AbCommandName $Context $Arguments)
+ $commandLabel = $(if ($command.Count) { $command[0] } else {
+ $words = @()
+ foreach ($argument in $Arguments) {
+ if ($argument.StartsWith('-')) { break }
+ $words += $argument
+ }
+ $words -join ' '
+ })
+ $timedMutations = @(
+ 'appservice plan silo create', 'appservice plan silo update', 'appservice plan silo delete',
+ 'webapp silo create', 'webapp silo update', 'webapp silo delete',
+ 'webapp silo start', 'webapp silo stop', 'webapp silo restart', 'webapp silo network-trace start'
+ )
+ if ($Mutation -and $command.Count -and $command[0] -in $timedMutations -and -not $argsList.Contains('--timeout')) {
+ $argsList.Add('--timeout')
+ $argsList.Add([string][int][Math]::Max(1, [Math]::Min($Context.Manifest.options.operationTimeoutSeconds, $remaining)))
+ }
+ if ($Mutation) {
+ if (-not $IntentId) { Stop-Ab 'Every mutation requires an exact recorded resource intent.' Invalid }
+ Assert-AbResourceId $Context $IntentId ''
+ if (-not @($Context.Manifest.suites | Where-Object { $IntentId -in @($_.intents) }).Count) {
+ Stop-Ab 'Mutation was refused because its resource intent was not journaled.' Invalid
+ }
+ $Context.Manifest.pendingMutation = @{
+ case = $(if ($Context.CurrentCase) { $Context.CurrentCase.id } else { 'cleanup' })
+ command = $commandLabel
+ resourceId = $IntentId; replay = $Replay; utc = [DateTime]::UtcNow.ToString('o')
+ }
+ Save-AbManifest $Context
+ }
+ if ($Context.Executor) { $raw = & $Context.Executor $argsList.ToArray() }
+ else {
+ $raw = Invoke-AbProcess $Context.PythonExe (@('-m', 'azure.cli') + $argsList.ToArray()) $Context.RepoRoot $Context.Environment $TimeoutSeconds
+ }
+ $serviceCode = ''
+ $requestId = ''
+ if ($raw.stderr -match '(?m)^\s*ERROR:\s*\(([A-Za-z0-9_.-]{1,80})\)' -and -not $Matches[1].StartsWith('eyJ')) { $serviceCode = $Matches[1] }
+ if ($raw.stderr -match '(?i)Request ID:\s*([a-f0-9-]{36})') { $requestId = $Matches[1] }
+ $result = @{
+ exitCode = [int]$raw.exitCode; elapsedMs = [long]$raw.elapsedMs; timedOut = [bool]$raw.timedOut
+ stdout = [string]$raw.stdout; stderr = [string]$raw.stderr; json = $null
+ serviceCode = $serviceCode; requestId = $requestId; command = $commandLabel
+ originCollectionFallback = $raw.stderr -match 'Origin point GET returned HTTP 404; using the matching origin from the silo collection'
+ }
+ if ($Context.CurrentCase) {
+ $Context.CurrentCase.calls += @(@{ command = $result.command; apiKeys = $ApiKeys; exitCode = $result.exitCode
+ mutationReplay = $(if ($Mutation) { $Replay } else { 'Observe' }); originCollectionFallback = $result.originCollectionFallback })
+ }
+ Add-AbEvent $Context 'command' @{
+ command = $result.command; exitCode = $result.exitCode; elapsedMs = $result.elapsedMs
+ serviceCode = $serviceCode; requestId = $requestId; timedOut = $result.timedOut
+ originCollectionFallback = $result.originCollectionFallback
+ }
+ if ($result.exitCode -notin $ExpectedExitCodes) {
+ if ($AllowFailure) { return $result }
+ $environmentCodes = @('AuthorizationFailed', 'MissingSubscriptionRegistration', 'SubscriptionIsDisabled',
+ 'QuotaExceeded', 'SkuNotAvailable', 'NotEnoughAvailableWorkers',
+ 'MaxActiveBackupSitesInSubscription', 'RequestedFeaturesNotSupportedInThisRegion')
+ $flavorBlocked = $commandLabel -eq 'appservice plan silo create' -and
+ $result.stderr -match "The base App Service plan app flavor '(Windows|Linux)' is not supported for Active Backup silos"
+ $message = $(if ($flavorBlocked) { 'The service flavor allowlist blocked Active Backup creation. No OS/SKU/region fallback was used.' }
+ else { "CLI command failed (exit $($result.exitCode); service code '$serviceCode'). Raw output was not persisted." })
+ $failure = [InvalidOperationException]::new($message)
+ $failure.Data['AbKind'] = $(if ($serviceCode -in $environmentCodes -or $flavorBlocked) { 'BlockedEnvironment' } else { 'Fail' })
+ $failure.Data['AbSafeMessage'] = $failure.Message
+ $failure.Data['AbTransientRead'] = -not $Mutation -and $serviceCode -in @('TooManyRequests', 'Throttled', 'ServiceUnavailable', 'InternalServerError')
+ throw $failure
+ }
+ if ($OutputShape -eq 'Empty') { Assert-Ab ([string]::IsNullOrWhiteSpace($result.stdout)) 'Expected an empty successful CLI result.' }
+ elseif ($OutputShape -eq 'Json' -and $result.exitCode -eq 0) {
+ if (-not [string]::IsNullOrWhiteSpace($result.stdout)) {
+ try { $result.json = ConvertFrom-Json -InputObject $result.stdout -AsHashtable -NoEnumerate } catch { Stop-Ab 'The CLI did not return the expected JSON.' }
+ }
+ if ($Mutation -and $commandLabel -in @(
+ 'appservice plan silo create', 'appservice plan silo update', 'webapp silo create', 'webapp silo update')) {
+ Add-AbEvent $Context 'silo-response' @{
+ command = $commandLabel; targetResourceId = $IntentId
+ response = Get-AbSiloResponseMetadata $Context $result.json
+ }
+ }
+ if ($Mutation -and -not $result.timedOut -and $result.exitCode -eq 0) {
+ $Context.Manifest.pendingMutation = $null
+ Save-AbManifest $Context
+ }
+ }
+ return $result
+}
+
+function Invoke-AbCase {
+ param([hashtable]$Context, [string]$Id, [scriptblock]$Action, [bool]$Required = $true,
+ [ValidateSet('Behavioral', 'StructuralOnly', 'NegativeOnly')][string]$EvidenceKind = 'Behavioral')
+ $previous = @($Context.Manifest.cases | Where-Object id -eq $Id | Select-Object -Last 1)
+ if ($Context.Mode -eq 'Resume' -and $previous.Count -and $previous[0].status -eq 'Pass' -and -not $Id.StartsWith('preflight.')) { return }
+ $record = @{
+ id = $Id; required = $Required; status = 'Running'; evidenceKind = $EvidenceKind
+ startedUtc = [DateTime]::UtcNow.ToString('o'); elapsedMs = 0; reason = ''; calls = @()
+ }
+ $Context.Manifest.cases += @($record)
+ $Context.CurrentCase = $record
+ Save-AbManifest $Context
+ $clock = [Diagnostics.Stopwatch]::StartNew()
+ try {
+ & $Action | Out-Null
+ $record.status = 'Pass'
+ }
+ catch {
+ $record.status = $(if ($_.Exception.Data.Contains('AbKind')) { $_.Exception.Data['AbKind'] } else { 'Fail' })
+ $record.reason = $(if ($_.Exception.Data.Contains('AbSafeMessage')) { $_.Exception.Data['AbSafeMessage'] } else { 'Unexpected local exception; raw output was withheld.' })
+ if ($Required) { throw }
+ }
+ finally {
+ $record.elapsedMs = $clock.ElapsedMilliseconds
+ $Context.CurrentCase = $null
+ Save-AbManifest $Context
+ Write-Host ("{0}: {1}" -f $Id, $record.status)
+ }
+}
+
+function Wait-AbCondition {
+ param([hashtable]$Context, [scriptblock]$Probe, [string]$Description, [int]$TimeoutSeconds = 0, [int]$IntervalSeconds = 10)
+ if ($TimeoutSeconds -le 0) { $TimeoutSeconds = $Context.Manifest.options.observationTimeoutSeconds }
+ $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
+ while ([DateTime]::UtcNow -lt $deadline) {
+ try { if (& $Probe) { return } }
+ catch { if (-not $_.Exception.Data['AbTransientRead']) { throw } }
+ $outer = $(if ($Context.InCleanup) { $Context.CleanupDeadline } else { $Context.RunDeadline })
+ if ([DateTime]::UtcNow -ge $outer) { break }
+ Start-Sleep -Milliseconds ([int][Math]::Max(1, [Math]::Min($IntervalSeconds * 1000, ($deadline - [DateTime]::UtcNow).TotalMilliseconds)))
+ }
+ Stop-Ab ("Observation timed out: " + $Description) $(if ($Context.InCleanup) { 'Cleanup' } else { 'Fail' })
+}
+
+function Invoke-AbTargetCheck {
+ param([hashtable]$Context)
+ Invoke-AbCase $Context 'preflight.account' {
+ $accountRead = Invoke-AbAz $Context @('account', 'show') -TimeoutSeconds 60 -AllowFailure
+ if ($accountRead.exitCode -ne 0) { Stop-Ab 'The isolated CLI cache is not authenticated for the requested subscription. Follow the README login instructions, then repeat Preflight.' BlockedEnvironment }
+ $account = $accountRead.json
+ Assert-Ab ((Get-AbValue $account 'id') -eq $Context.Subscription) 'Account response selected the wrong subscription.'
+ if ((Get-AbValue $account 'state') -ne 'Enabled') { Stop-Ab 'The requested subscription is not enabled.' BlockedEnvironment }
+ $tenant = Get-AbValue $account 'tenantId'
+ $recordedTenant = Get-AbValue $Context.Manifest 'tenantId'
+ if ($recordedTenant -and $recordedTenant -ne $tenant) { Stop-Ab 'The subscription tenant differs from the recorded run; review the target before recovery.' BlockedEnvironment }
+ $Context.Manifest.tenantId = $tenant
+ $cloud = (Invoke-AbAz $Context @('cloud', 'show') -TimeoutSeconds 60).json
+ if ((Get-AbValue $cloud 'name') -ne 'AzureCloud') { Stop-Ab 'The EUAP profile requires the public Azure cloud.' BlockedEnvironment }
+ $Context.ArmEndpoint = [string](Get-AbValue (Get-AbValue $cloud 'endpoints') 'resourceManager')
+ if ([string]::IsNullOrWhiteSpace($Context.ArmEndpoint)) { Stop-Ab 'Cloud metadata omitted the ARM endpoint.' BlockedEnvironment }
+ $Context.Manifest.armEndpoint = $Context.ArmEndpoint
+ }
+}
+
+function Invoke-AbPreflight {
+ param([hashtable]$Context)
+ Invoke-AbTargetCheck $Context
+ Invoke-AbCase $Context 'preflight.regions-api' {
+ $locationsUrl = "/subscriptions/$($Context.Subscription)/locations?api-version=2022-12-01"
+ $locations = @(Get-AbValue (Invoke-AbAz $Context @('rest', '--method', 'get', '--url', $locationsUrl) -TimeoutSeconds 60).json 'value' @())
+ foreach ($region in @($Context.PrimaryRegion, $Context.SiloRegion)) {
+ if (-not @($locations | Where-Object { (Get-AbValue $_ 'name') -eq $region }).Count) { Stop-Ab 'The subscription does not expose a requested region.' BlockedEnvironment }
+ }
+ $provider = (Invoke-AbAz $Context @('provider', 'show', '--namespace', 'Microsoft.Web') -TimeoutSeconds 90).json
+ if ((Get-AbValue $provider 'registrationState') -ne 'Registered') { Stop-Ab 'Microsoft.Web is not registered. The harness will not register it.' BlockedEnvironment }
+ foreach ($type in @('serverFarms/silos', 'sites/silos', 'sites/managedTrafficConfig', 'sites/lastKnownGood')) {
+ $types = Get-AbValue $provider 'resourceTypes' @()
+ $entry = @($types | Where-Object { (Get-AbValue $_ 'resourceType') -ieq $type })
+ if (-not $entry.Count -or $Context.ApiVersion -notin @(Get-AbValue $entry[0] 'apiVersions' @())) {
+ Stop-Ab ("Public ARM metadata does not advertise the reviewed API for " + $type + '. No fallback was attempted.') BlockedEnvironment
+ }
+ }
+ if ($Context.Profile -in @('Core', 'Extended')) {
+ $network = (Invoke-AbAz $Context @('provider', 'show', '--namespace', 'Microsoft.Network') -TimeoutSeconds 60).json
+ if ((Get-AbValue $network 'registrationState') -ne 'Registered') { Stop-Ab 'Core networking requires Microsoft.Network registration. The harness will not register it.' BlockedEnvironment }
+ }
+ }
+ Invoke-AbCase $Context 'preflight.sku-runtime' {
+ $skus = $(if ($Context.Manifest.options.includeSkuMatrix) { @('P1V3', 'P1MV3', 'P1V4', 'P1MV4') } else { @('P1V3') })
+ foreach ($sku in $skus) {
+ $regions = (Invoke-AbAz $Context @('appservice', 'list-locations', '--sku', $sku) -TimeoutSeconds 90).json
+ $normalized = @($regions | ForEach-Object { ([string](Get-AbValue $_ 'name')).Replace(' ', '').ToLowerInvariant() })
+ foreach ($region in @($Context.PrimaryRegion, $Context.SiloRegion)) {
+ if ($region -notin $normalized) { Stop-Ab 'SKU discovery does not list both requested regions. Allocation was not attempted.' BlockedEnvironment }
+ }
+ }
+ if ($Context.OperatingSystem -in @('Linux', 'Both')) {
+ $runtimes = (Invoke-AbAz $Context @('webapp', 'list-runtimes', '--os-type', 'linux') -TimeoutSeconds 90).json
+ if ('PYTHON:3.12' -notin @($runtimes)) { Stop-Ab 'The required PYTHON:3.12 fixture runtime is not advertised.' BlockedEnvironment }
+ }
+ }
+}
+
+function New-AbSuite {
+ param([hashtable]$Context, [string]$OperatingSystem, [string]$Sku = 'P1V3')
+ $short = $(if ($OperatingSystem -eq 'Windows') { 'win' } else { 'linux' })
+ $key = $short + '-' + $Sku.ToLowerInvariant()
+ $stem = 'abcli-' + $Context.Manifest.runId + '-' + $key
+ $group = 'rg-' + $stem
+ $groupId = '/subscriptions/' + $Context.Subscription + '/resourceGroups/' + $group
+ $plan = $stem + '-plan'
+ $app = $stem + '-app'
+ return @{
+ key = $key; operatingSystem = $OperatingSystem; sku = $Sku; group = $group; groupId = $groupId
+ plan = $plan; app = $app; planSilo = 'secondary-plan'; siteSilo = 'secondary-app'
+ planId = $groupId + '/providers/Microsoft.Web/serverfarms/' + $plan
+ appId = $groupId + '/providers/Microsoft.Web/sites/' + $app
+ planSiloId = $groupId + '/providers/Microsoft.Web/serverfarms/' + $plan + '/silos/secondary-plan'
+ siteSiloId = $groupId + '/providers/Microsoft.Web/sites/' + $app + '/silos/secondary-app'
+ vnet = $stem + '-vnet'; groupCreateAttempted = $false; groupOwned = $false
+ intents = @(); receipts = @(); hosts = @(); cleanupComplete = $false
+ }
+}
+
+function Register-AbIntent {
+ param([hashtable]$Context, [hashtable]$Suite, [string]$ResourceId)
+ Assert-AbResourceId $Context $ResourceId $Suite.group
+ if ($ResourceId -notin @($Suite.intents)) { $Suite.intents += @($ResourceId) }
+ Save-AbManifest $Context
+}
+
+function Get-AbTags {
+ param([hashtable]$Context)
+ return @(
+ 'abcliRunId=' + $Context.Manifest.runId
+ 'abcliOwner=' + $Context.Manifest.owner
+ 'abcliCreatedUtc=' + $Context.Manifest.createdUtc
+ 'abcliExpiresUtc=' + ([DateTime]::Parse($Context.Manifest.createdUtc).ToUniversalTime().AddMinutes(
+ $Context.Manifest.options.runTimeoutMinutes + $Context.Manifest.options.cleanupTimeoutMinutes + 60).ToString('o'))
+ 'abcliPurpose=local-cli-validation'
+ 'abcliCommit=' + $Context.Manifest.sourceCommit
+ )
+}
+
+function Assert-AbOwnedGroup {
+ param([hashtable]$Context, [hashtable]$Suite)
+ if (-not $Suite.groupCreateAttempted) { Stop-Ab 'The manifest has no creation intent for this group.' Cleanup }
+ $result = Invoke-AbAz $Context @('group', 'show', '--name', $Suite.group) -AllowFailure -TimeoutSeconds 60
+ if ($result.exitCode -eq 3) { return $false }
+ if ($result.exitCode -ne 0) { Stop-Ab 'Cannot verify the resource-group ownership before cleanup.' Cleanup }
+ $tags = Get-AbValue $result.json 'tags' @{}
+ if ((Get-AbValue $result.json 'id') -ine $Suite.groupId -or
+ (Get-AbValue $tags 'abcliRunId') -ne $Context.Manifest.runId -or
+ (Get-AbValue $tags 'abcliPurpose') -ne 'local-cli-validation' -or
+ (Get-AbValue $tags 'abcliOwner') -ne $Context.Manifest.owner -or
+ (Get-AbValue $tags 'abcliCommit') -ne $Context.Manifest.sourceCommit) {
+ Stop-Ab 'Resource-group identity or ownership tags do not match the recorded run.' Cleanup
+ }
+ return $true
+}
+
+function Read-AbManifest {
+ param([string]$RepoRoot, [string]$ManifestPath, [string]$Subscription, [string]$Mode = 'Cleanup')
+ $path = Assert-AbLocalPath $RepoRoot $ManifestPath -RequireIgnored
+ if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { Stop-Ab 'The manifest file does not exist.' Invalid }
+ try { $manifest = ConvertFrom-Json -InputObject ([IO.File]::ReadAllText($path)) -AsHashtable } catch { Stop-Ab 'The manifest is not valid JSON.' Invalid }
+ if ($manifest.schemaVersion -ne 1 -or $manifest.purpose -ne 'active-backup-cli-validation' -or
+ $manifest.subscription -ne $Subscription -or
+ $manifest.repoRoot -ine [IO.Path]::GetFullPath($RepoRoot) -or
+ $manifest.owner -ne [Environment]::UserName -or
+ [IO.Path]::GetFileName($path) -ne 'manifest.json') {
+ Stop-Ab 'Manifest schema, repository or subscription does not match the request.' Invalid
+ }
+ $options = $manifest.options
+ $context = New-AbContext -RepoRoot $RepoRoot -Subscription $Subscription -PrimaryRegion $manifest.primaryRegion `
+ -SiloRegion $manifest.siloRegion -PythonExe $manifest.pythonExe -Profile $manifest.profile `
+ -OperatingSystem $manifest.operatingSystem -OutputRoot ([IO.Path]::GetDirectoryName($manifest.runDirectory)) `
+ -AuthConfigDir $manifest.authConfigDir -RunId $manifest.runId -Mode $Mode `
+ -OperationTimeoutSeconds $options.operationTimeoutSeconds -ObservationTimeoutSeconds $options.observationTimeoutSeconds `
+ -RunTimeoutMinutes $options.runTimeoutMinutes -CleanupTimeoutMinutes $options.cleanupTimeoutMinutes `
+ -MaxWorkersPerPlan $options.maxWorkersPerPlan -IncludeSkuMatrix $options.includeSkuMatrix `
+ -IncludeInvasiveDiagnostics $options.includeInvasiveDiagnostics -IncludeZoneRedundancy $options.includeZoneRedundancy
+ if ($context.ManifestPath -ine $path -or $manifest.runDirectory -ine $context.RunDirectory) {
+ Stop-Ab 'Manifest path does not match the recorded run directory.' Invalid
+ }
+ if ($manifest.sourceCommit -ne $context.Manifest.sourceCommit -and $Mode -eq 'Resume') {
+ Stop-Ab 'Resume requires the original source commit. Use ownership-checked Cleanup after a source change.' Invalid
+ }
+ if ($Mode -eq 'Resume') {
+ $savedHashes = Get-AbValue $manifest 'sourceHashes' @{}
+ foreach ($relative in $context.Manifest.sourceHashes.Keys) {
+ if ((Get-AbValue $savedHashes $relative) -ne $context.Manifest.sourceHashes[$relative]) {
+ Stop-Ab 'Resume refused changed local CLI or harness source files, including uncommitted edits.' Invalid
+ }
+ }
+ }
+ $seen = @()
+ foreach ($suite in $manifest.suites) {
+ if ($suite.operatingSystem -notin @('Windows', 'Linux') -or $suite.sku -notin @('P1V3', 'P1MV3', 'P1V4', 'P1MV4')) {
+ Stop-Ab 'Manifest contains an unrecognized OS or SKU.' Invalid
+ }
+ $expected = New-AbSuite $context $suite.operatingSystem $suite.sku
+ foreach ($field in @('key', 'group', 'groupId', 'plan', 'app', 'planId', 'appId', 'planSiloId', 'siteSiloId', 'planSilo', 'siteSilo', 'vnet')) {
+ if ($suite[$field] -cne $expected[$field]) { Stop-Ab 'Manifest resource names or IDs have been changed.' Invalid }
+ }
+ if ($suite.key -in $seen) { Stop-Ab 'Manifest contains duplicate suites.' Invalid }
+ $seen += $suite.key
+ foreach ($intent in $suite.intents) {
+ Assert-AbResourceId $context $intent $suite.group
+ $allowed = @($suite.groupId, $suite.planId, $suite.appId, $suite.planSiloId, $suite.siteSiloId,
+ ($suite.groupId + '/providers/Microsoft.Network/virtualNetworks/' + $suite.vnet))
+ if ($intent -notin $allowed) { Stop-Ab 'Manifest contains an undeclared resource intent.' Invalid }
+ }
+ }
+ $context.Manifest = $manifest
+ $context.ArmEndpoint = [string](Get-AbValue $manifest 'armEndpoint' 'https://management.azure.com/')
+ if ($context.ArmEndpoint -ne 'https://management.azure.com/') { Stop-Ab 'The saved ARM origin is not the reviewed public cloud.' Invalid }
+ return $context
+}
+
+function Complete-AbSiloOperation {
+ param([hashtable]$Context, [hashtable]$Suite, [string]$Family, [string]$ResourceId, $Result,
+ [string]$OperationApiKey, [switch]$Delete)
+ $payload = $Result.json
+ if (-not $Delete) {
+ Assert-Ab ($payload -is [Collections.IDictionary]) 'The silo mutation returned no resource object or operation receipt.'
+ }
+ if ((Get-AbValue $payload 'status') -eq 'Accepted') {
+ $operationId = [string](Get-AbValue $payload 'operationId')
+ Assert-Ab (-not [string]::IsNullOrWhiteSpace($operationId)) 'The no-wait receipt omitted the operation ID.'
+ Assert-Ab ((Get-AbValue $payload 'resourceId') -ieq $ResourceId) 'The no-wait receipt identifies the wrong resource.'
+ $Suite.receipts += @(@{ resourceId = $ResourceId; operationId = $operationId })
+ Save-AbManifest $Context
+ $show = (Invoke-AbAz $Context ($Family.Split(' ') + @('operation', 'show', '--ids', $ResourceId, '--operation-id', $operationId)) -ApiKeys @($OperationApiKey)).json
+ Assert-Ab ((Get-AbValue $show 'status') -in @('InProgress', 'Succeeded')) 'Operation inspection returned an invalid state.'
+ $timeout = $Context.Manifest.options.operationTimeoutSeconds
+ if ($Context.InCleanup) { $timeout = [int][Math]::Max(1, [Math]::Min($timeout, ($Context.CleanupDeadline - [DateTime]::UtcNow).TotalSeconds)) }
+ $wait = (Invoke-AbAz $Context ($Family.Split(' ') + @('wait', '--ids', $ResourceId, '--operation-id', $operationId, '--timeout', [string]$timeout)) -ApiKeys @($OperationApiKey)).json
+ Assert-Ab ((Get-AbValue $wait 'status') -eq 'Succeeded') 'Operation wait did not reach success.'
+ }
+ elseif (-not $Delete) { Assert-AbSilo $payload $ResourceId $Context.SiloRegion }
+}
+
+function Assert-AbSilo {
+ param($Resource, [string]$Id, [string]$Location)
+ Assert-Ab ((Get-AbValue $Resource 'id') -ieq $Id) 'Silo response identifies the wrong ARM resource.'
+ $type = $(if ($Id -match '/serverfarms/') { 'Microsoft.Web/serverfarms/silos' } else { 'Microsoft.Web/sites/silos' })
+ Assert-Ab ((Get-AbValue $Resource 'type') -ieq $type) 'Silo response has the wrong ARM resource type.'
+ Assert-Ab (([string](Get-AbValue $Resource 'location')).Replace(' ', '').ToLowerInvariant() -eq $Location) 'Silo response has the wrong target region.'
+ $properties = Get-AbValue $Resource 'properties'
+ Assert-Ab ($properties -is [Collections.IDictionary]) 'Silo response omitted its properties object.'
+ Assert-Ab ((Get-AbValue $properties 'provisioningState') -eq 'Succeeded') 'Silo API operation did not produce a Succeeded resource.'
+}
+
+function Invoke-AbReferenceGet {
+ param([hashtable]$Context, [string]$Id)
+ $url = $Id + '?api-version=' + $Context.ApiVersion
+ return (Invoke-AbAz $Context @('rest', '--method', 'get', '--url', $url) -TimeoutSeconds 90).json
+}
+
+function Get-AbMarker {
+ param([hashtable]$Context, [hashtable]$Suite, [string]$HostName)
+ if ($HostName -notin @($Suite.hosts) -or $HostName -notmatch '^[a-zA-Z0-9.-]+$') { Stop-Ab 'HTTP probe hostname is not a verified owned fixture hostname.' Invalid }
+ if ($Context.HttpExecutor) { return & $Context.HttpExecutor $Suite $HostName }
+ $remaining = ($Context.RunDeadline - [DateTime]::UtcNow).TotalSeconds
+ if ($remaining -le 0) { Stop-Ab 'Run deadline reached before the HTTP observation.' }
+ try {
+ return Invoke-RestMethod -Uri ('https://' + $HostName + '/version.json') -Method Get `
+ -TimeoutSec ([int][Math]::Max(1, [Math]::Min(30, $remaining))) -MaximumRedirection 0 -ErrorAction Stop
+ }
+ catch [System.Net.Http.HttpRequestException] { return $null }
+ catch [System.Threading.Tasks.TaskCanceledException] { return $null }
+}
+
+function Add-AbHost {
+ param([hashtable]$Context, [hashtable]$Suite, $Resource)
+ $properties = Get-AbValue $Resource 'properties' $Resource
+ foreach ($key in @('defaultHostName', 'globalHostName')) {
+ $hostName = [string](Get-AbValue $properties $key (Get-AbValue $Resource $key ''))
+ if ($hostName) {
+ if ($hostName -notmatch '^[a-zA-Z0-9.-]+$') { Stop-Ab 'Service returned an invalid fixture hostname.' }
+ if ($hostName -notin @($Suite.hosts)) { $Suite.hosts += @($hostName) }
+ }
+ }
+ Save-AbManifest $Context
+}
+
+function Wait-AbMarker {
+ param([hashtable]$Context, [hashtable]$Suite, [string]$HostName, [string]$Generation)
+ Wait-AbCondition $Context {
+ $marker = Get-AbMarker $Context $Suite $HostName
+ return (Get-AbValue $marker 'runId') -eq $Context.Manifest.runId -and (Get-AbValue $marker 'generation') -eq $Generation
+ } ("fixture content " + $Generation)
+}
+
+function New-AbFixture {
+ param([hashtable]$Context, [hashtable]$Suite, [string]$Generation)
+ $directory = Join-Path $Context.RunDirectory ('fixtures\' + $Suite.key + '-' + $Generation)
+ [IO.Directory]::CreateDirectory($directory) | Out-Null
+ Write-AbJson (Join-Path $directory 'version.json') @{
+ runId = $Context.Manifest.runId; generation = $Generation; fixture = 'abcli'
+ }
+ [IO.File]::WriteAllText((Join-Path $directory 'health.txt'), 'healthy', [Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText((Join-Path $directory 'index.html'), 'Active Backup CLI validation fixture', [Text.UTF8Encoding]::new($false))
+ if ($Suite.operatingSystem -eq 'Windows') {
+ $config = ''
+ [IO.File]::WriteAllText((Join-Path $directory 'web.config'), $config, [Text.UTF8Encoding]::new($false))
+ }
+ $zip = $directory + '.zip'
+ if (Test-Path -LiteralPath $zip) { [IO.File]::Delete($zip) }
+ [IO.Compression.ZipFile]::CreateFromDirectory($directory, $zip)
+ return $zip
+}
+
+function Get-AbProps {
+ param($Object)
+ return ,(Get-AbValue $Object 'properties' $Object)
+}
+
+function Get-AbItems {
+ param($Object)
+ $items = Get-AbValue $Object 'value' $Object
+ return $items
+}
+
+function Assert-AbContainer {
+ param($Object)
+ Assert-Ab ($Object -is [Collections.IDictionary] -or $Object -is [array]) 'Expected a JSON object or collection from the service.'
+}
+
+function Invoke-AbDeploy {
+ param([hashtable]$Context, [hashtable]$Suite, [string]$Generation)
+ $zip = New-AbFixture $Context $Suite $Generation
+ Invoke-AbAz $Context @('webapp', 'config', 'appsettings', 'set', '-g', $Suite.group, '-n', $Suite.app,
+ '--settings', ('ABCLI_RUN_ID=' + $Context.Manifest.runId), ('ABCLI_GENERATION=' + $Generation)) `
+ -Mutation -Replay Idempotent -IntentId $Suite.appId | Out-Null
+ Invoke-AbAz $Context @('webapp', 'deploy', '-g', $Suite.group, '-n', $Suite.app, '--src-path', $zip, '--type', 'zip') `
+ -Mutation -Replay NoReplay -IntentId $Suite.appId | Out-Null
+ Wait-AbMarker $Context $Suite $Suite.primaryHost $Generation
+}
+
+function Invoke-AbSmoke {
+ param([hashtable]$Context, [hashtable]$Suite, [switch]$ProvisionOnly)
+ $prefix = $Suite.key
+ Invoke-AbCase $Context "$prefix.primary" {
+ $exists = Invoke-AbAz $Context @('group', 'exists', '-n', $Suite.group) -TimeoutSeconds 60
+ if ($exists.json -eq $true) {
+ if ($Context.Mode -ne 'Resume' -or -not $Suite.groupCreateAttempted) { Stop-Ab 'Resource-group collision: existing resources will not be adopted.' Invalid }
+ Assert-Ab (Assert-AbOwnedGroup $Context $Suite) 'Cannot reconcile the owned group.'
+ }
+ else {
+ $Suite.groupCreateAttempted = $true
+ Register-AbIntent $Context $Suite $Suite.groupId
+ Invoke-AbAz $Context (@('group', 'create', '-n', $Suite.group, '--location', $Context.PrimaryRegion, '--tags') + (Get-AbTags $Context)) `
+ -Mutation -Replay Create -IntentId $Suite.groupId | Out-Null
+ }
+ $Suite.groupOwned = Assert-AbOwnedGroup $Context $Suite
+ $linux = $(if ($Suite.operatingSystem -eq 'Linux') { 'true' } else { 'false' })
+ $createPlan = $true
+ if ($Context.Mode -eq 'Resume') {
+ $existingPlan = Invoke-AbAz $Context @('appservice', 'plan', 'show', '-g', $Suite.group, '-n', $Suite.plan) -AllowFailure
+ if ($existingPlan.exitCode -eq 0) {
+ Assert-Ab ($Suite.planId -in @($Suite.intents) -and (Get-AbValue $existingPlan.json 'id') -ieq $Suite.planId) 'Existing primary plan cannot be reconciled to a recorded create intent.'
+ $createPlan = $false
+ }
+ elseif ($existingPlan.exitCode -ne 3) { Stop-Ab 'Cannot reconcile the primary plan before resuming.' }
+ }
+ if ($createPlan) {
+ Register-AbIntent $Context $Suite $Suite.planId
+ Invoke-AbAz $Context (@('appservice', 'plan', 'create', '-g', $Suite.group, '-n', $Suite.plan,
+ '--location', $Context.PrimaryRegion, '--sku', $Suite.sku, '--number-of-workers', '1', '--is-linux', $linux, '--tags') + (Get-AbTags $Context)) `
+ -Mutation -Replay Create -IntentId $Suite.planId | Out-Null
+ }
+ $arguments = @('webapp', 'create', '-g', $Suite.group, '-n', $Suite.app, '--plan', $Suite.planId)
+ if ($Suite.operatingSystem -eq 'Linux') {
+ $arguments += @('--runtime', 'PYTHON:3.12', '--startup-file', 'python -m http.server 8000 --bind 0.0.0.0 --directory /home/site/wwwroot')
+ }
+ $createApp = $true
+ if ($Context.Mode -eq 'Resume') {
+ $existingApp = Invoke-AbAz $Context @('webapp', 'show', '-g', $Suite.group, '-n', $Suite.app) -AllowFailure
+ if ($existingApp.exitCode -eq 0) {
+ Assert-Ab ($Suite.appId -in @($Suite.intents) -and (Get-AbValue $existingApp.json 'id') -ieq $Suite.appId) 'Existing primary app cannot be reconciled to a recorded create intent.'
+ $primary = $existingApp.json
+ $createApp = $false
+ }
+ elseif ($existingApp.exitCode -ne 3) { Stop-Ab 'Cannot reconcile the primary app before resuming.' }
+ }
+ if ($createApp) {
+ Register-AbIntent $Context $Suite $Suite.appId
+ $primary = (Invoke-AbAz $Context $arguments -Mutation -Replay Create -IntentId $Suite.appId).json
+ }
+ Add-AbHost $Context $Suite $primary
+ $Suite.primaryHost = [string](Get-AbValue (Get-AbProps $primary) 'defaultHostName' (Get-AbValue $primary 'defaultHostName'))
+ Assert-Ab (-not [string]::IsNullOrWhiteSpace($Suite.primaryHost)) 'Primary app omitted its hostname.'
+ Save-AbManifest $Context
+ }
+ if (-not $ProvisionOnly) {
+ Invoke-AbCase $Context "$prefix.deploy-v1" { Invoke-AbDeploy $Context $Suite 'v1' }
+ }
+ Invoke-AbCase $Context "$prefix.plan-create" {
+ if ($Context.Mode -eq 'Resume') {
+ $existing = Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'show', '--ids', $Suite.planSiloId) -AllowFailure -ApiKeys @('C02')
+ if ($existing.exitCode -eq 0) {
+ Assert-Ab ($Suite.planSiloId -in @($Suite.intents)) 'An existing plan silo has no previous run intent; adoption was refused.'
+ Wait-AbCondition $Context {
+ $observed = (Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'show', '--ids', $Suite.planSiloId) -ApiKeys @('C02')).json
+ return (Get-AbValue (Get-AbProps $observed) 'provisioningState') -eq 'Succeeded'
+ } 'reconciled plan silo readiness'
+ return
+ }
+ if ($existing.exitCode -ne 3) { Stop-Ab 'Cannot reconcile the previous plan-silo create.' }
+ }
+ Register-AbIntent $Context $Suite $Suite.planSiloId
+ $created = Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'create', '-g', $Suite.group, '-n', $Suite.plan,
+ '--silo', $Suite.planSilo, '--location', $Context.SiloRegion, '--primary-location', $Context.PrimaryRegion,
+ '--number-of-workers', '1', '--no-wait') -Mutation -Replay Create -IntentId $Suite.planSiloId -ApiKeys @('C03')
+ Complete-AbSiloOperation $Context $Suite 'appservice plan silo' $Suite.planSiloId $created 'C06'
+ }
+ Invoke-AbCase $Context "$prefix.plan-read" {
+ $plan = (Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'show', '-g', $Suite.group, '-n', $Suite.plan, '--silo', $Suite.planSilo) -ApiKeys @('C02')).json
+ Assert-AbSilo $plan $Suite.planSiloId $Context.SiloRegion
+ Assert-Ab (([string](Get-AbValue (Get-AbProps $plan) 'primaryLocation')).Replace(' ', '').ToLowerInvariant() -eq $Context.PrimaryRegion) 'Plan silo points at the wrong primary region.'
+ Wait-AbCondition $Context {
+ $items = (Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'list', '-g', $Suite.group, '-n', $Suite.plan) -ApiKeys @('C01')).json
+ $count = @($items | Where-Object { (Get-AbValue $_ 'id') -ieq $Suite.planSiloId }).Count
+ Assert-Ab ($count -le 1) 'Plan list contains duplicate silo identities.'
+ return $count -eq 1
+ } 'created plan silo appears in its parent collection'
+ Assert-AbSilo (Invoke-AbReferenceGet $Context $Suite.planSiloId) $Suite.planSiloId $Context.SiloRegion
+ }
+ Invoke-AbCase $Context "$prefix.site-create" {
+ if ($Context.Mode -eq 'Resume') {
+ $existing = Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $Suite.siteSiloId) -AllowFailure -ApiKeys @('C09')
+ if ($existing.exitCode -eq 0) {
+ Assert-Ab ($Suite.siteSiloId -in @($Suite.intents)) 'An existing site silo has no previous run intent; adoption was refused.'
+ Wait-AbCondition $Context {
+ $observed = (Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $Suite.siteSiloId) -ApiKeys @('C09')).json
+ return (Get-AbValue (Get-AbProps $observed) 'provisioningState') -eq 'Succeeded'
+ } 'reconciled site silo readiness'
+ return
+ }
+ if ($existing.exitCode -ne 3) { Stop-Ab 'Cannot reconcile the previous site-silo create.' }
+ }
+ Register-AbIntent $Context $Suite $Suite.siteSiloId
+ $created = Invoke-AbAz $Context @('webapp', 'silo', 'create', '-g', $Suite.group, '-n', $Suite.app, '--silo', $Suite.siteSilo,
+ '--location', $Context.SiloRegion, '--primary-location', $Context.PrimaryRegion, '--traffic-mode', 'Active',
+ '--follow-last-known-good', 'false', '--no-wait') -Mutation -Replay Create -IntentId $Suite.siteSiloId -ApiKeys @('C10')
+ Complete-AbSiloOperation $Context $Suite 'webapp silo' $Suite.siteSiloId $created 'C13'
+ }
+ if ($ProvisionOnly) { return }
+ Invoke-AbCase $Context "$prefix.site-read" {
+ $site = (Invoke-AbAz $Context @('webapp', 'silo', 'show', '-g', $Suite.group, '-n', $Suite.app, '--silo', $Suite.siteSilo) -ApiKeys @('C09')).json
+ Assert-AbSilo $site $Suite.siteSiloId $Context.SiloRegion
+ Add-AbHost $Context $Suite $site
+ $Suite.siloHost = [string](Get-AbValue (Get-AbProps $site) 'defaultHostName')
+ Assert-Ab (-not [string]::IsNullOrWhiteSpace($Suite.siloHost)) 'The site silo omitted its hostname.'
+ Assert-Ab ($Suite.siloHost -ine $Suite.primaryHost) 'The silo API returned the primary hostname instead of a distinct replica hostname.'
+ Assert-Ab (([string](Get-AbValue (Get-AbProps $site) 'primaryLocation')).Replace(' ', '').ToLowerInvariant() -eq $Context.PrimaryRegion) 'Site silo points at the wrong primary region.'
+ Wait-AbCondition $Context {
+ $items = (Invoke-AbAz $Context @('webapp', 'silo', 'list', '-g', $Suite.group, '-n', $Suite.app) -ApiKeys @('C08')).json
+ $children = @($items | Where-Object { (Get-AbValue $_ 'id') -ieq $Suite.siteSiloId }).Count
+ $origins = @($items | Where-Object { (Get-AbValue $_ 'name') -eq 'origin' }).Count
+ Assert-Ab ($children -le 1 -and $origins -le 1) 'Site list contains duplicate child/origin identities.'
+ return $children -eq 1 -and $origins -eq 1
+ } 'site silo and origin appear in the parent collection'
+ Assert-AbSilo (Invoke-AbReferenceGet $Context $Suite.siteSiloId) $Suite.siteSiloId $Context.SiloRegion
+ Save-AbManifest $Context
+ }
+ Invoke-AbCase $Context "$prefix.replication-v1" {
+ Wait-AbMarker $Context $Suite $Suite.siloHost 'v1'
+ Wait-AbCondition $Context {
+ $settings = Get-AbProps (Invoke-AbAz $Context @('webapp', 'silo', 'config', 'appsettings', 'list', '--ids', $Suite.siteSiloId) -ApiKeys @('P50')).json
+ return (Get-AbValue $settings 'ABCLI_RUN_ID') -eq $Context.Manifest.runId -and (Get-AbValue $settings 'ABCLI_GENERATION') -eq 'v1'
+ } 'replicated dummy app settings'
+ }
+ Invoke-AbCase $Context "$prefix.refresh-acceptance" {
+ $refresh = Invoke-AbAz $Context @('webapp', 'silo', 'refresh', '-g', $Suite.group, '-n', $Suite.app) `
+ -OutputShape Empty -Mutation -Replay NoReplay -IntentId $Suite.appId -ApiKeys @('C14')
+ Assert-Ab ($refresh.stderr -match 'Refresh request accepted') 'Refresh did not emit its acceptance notice.'
+ }
+}
+
+function Invoke-AbOriginVisibility {
+ param([hashtable]$Context, [hashtable]$Suite)
+ Invoke-AbCase $Context ($Suite.key + '.origin-visibility') {
+ $started = [DateTime]::UtcNow
+ $originId = $Suite.appId + '/silos/origin'
+ $observations = @{
+ startedUtc = $started.ToString('o'); windowSeconds = $Context.Manifest.options.observationTimeoutSeconds
+ firstVisibleUtc = $null; firstVisibleAfterSeconds = $null; completedUtc = $null; samples = @()
+ }
+ $Suite.originVisibility = $observations
+ Save-AbManifest $Context
+ try {
+ Wait-AbCondition $Context {
+ if ($observations.samples.Count -and -not $Context.Executor) {
+ $lastStart = [DateTime]::Parse($observations.samples[-1].startedUtc).ToUniversalTime()
+ if (([DateTime]::UtcNow - $lastStart).TotalSeconds -lt 30) { return $false }
+ }
+ $sampleStart = [DateTime]::UtcNow
+ $listed = (Invoke-AbAz $Context @('webapp', 'silo', 'list', '-g', $Suite.group, '-n', $Suite.app) `
+ -TimeoutSeconds 30 -ApiKeys @('C08')).json
+ $origins = @($listed | Where-Object { (Get-AbValue $_ 'name') -eq 'origin' })
+ Assert-Ab ($origins.Count -le 1) 'Origin listing contains duplicate identities.'
+ $point = Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $originId) `
+ -TimeoutSeconds 30 -AllowFailure -ApiKeys @('C09')
+ $raw = Invoke-AbAz $Context @('rest', '--method', 'get', '--url', ($originId + '?api-version=' + $Context.ApiVersion)) `
+ -TimeoutSeconds 30 -AllowFailure
+ $secondary = Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $Suite.siteSiloId) `
+ -TimeoutSeconds 30 -AllowFailure -ApiKeys @('C09')
+ foreach ($read in @($point, $raw, $secondary)) {
+ if ($read.exitCode -eq 0) { continue }
+ $missing = $(if ($read.command -eq 'rest') {
+ $read.serviceCode -in @('ResourceNotFound', 'NotFound') -or
+ $read.stderr -match '(?im)^ERROR:\s*Not Found(?:\(|\s|$)'
+ } else { $read.exitCode -eq 3 })
+ if (-not $missing -or $read.timedOut) {
+ Stop-Ab ("Origin observation failed: " + $read.command + "; exit " + $read.exitCode +
+ "; service code '" + $read.serviceCode + "'. Raw output was not persisted.")
+ }
+ }
+ foreach ($read in @($point, $raw)) {
+ if ($read.exitCode -eq 0) {
+ Assert-Ab ((Get-AbValue $read.json 'id') -ieq $originId) 'Origin read returned a different resource identity.'
+ }
+ }
+ if ($secondary.exitCode -eq 0) {
+ Assert-Ab ((Get-AbValue $secondary.json 'id') -ieq $Suite.siteSiloId) 'Secondary control read returned a different identity.'
+ }
+ $now = [DateTime]::UtcNow
+ $sample = @{
+ startedUtc = $sampleStart.ToString('o'); observedUtc = $now.ToString('o')
+ elapsedSeconds = [Math]::Round(($now - $started).TotalSeconds, 1)
+ originListed = $origins.Count -eq 1
+ cliExitCode = $point.exitCode; cliServiceCode = $point.serviceCode; cliRequestId = $point.requestId
+ armExitCode = $raw.exitCode; armServiceCode = $raw.serviceCode; armRequestId = $raw.requestId
+ secondaryExitCode = $secondary.exitCode
+ cli = Get-AbSiloResponseMetadata $Context $point.json
+ arm = Get-AbSiloResponseMetadata $Context $raw.json
+ secondary = Get-AbSiloResponseMetadata $Context $secondary.json
+ }
+ $observations.samples += @($sample)
+ Add-AbEvent $Context 'origin-observation' $sample
+ $visible = $point.exitCode -eq 0 -and $raw.exitCode -eq 0 -and $secondary.exitCode -eq 0
+ if ($visible) {
+ $observations.firstVisibleUtc = $now.ToString('o')
+ $observations.firstVisibleAfterSeconds = $sample.elapsedSeconds
+ }
+ Save-AbManifest $Context
+ Write-Host ("Origin +{0}s: listed={1}; CLI exit={2}; ARM exit={3}; secondary exit={4}" -f
+ $sample.elapsedSeconds, $sample.originListed, $point.exitCode, $raw.exitCode, $secondary.exitCode)
+ return $visible
+ } 'origin readable through CLI and raw ARM after completed creation' -IntervalSeconds 1
+ }
+ finally {
+ $observations.completedUtc = [DateTime]::UtcNow.ToString('o')
+ Save-AbManifest $Context
+ }
+ }
+}
+
+function Get-AbSiteArguments {
+ param([hashtable]$Suite, [string]$Command)
+ return $Command.Split(' ') + @('--ids', $Suite.siteSiloId)
+}
+
+function Invoke-AbSafeReads {
+ param([hashtable]$Context, [hashtable]$Suite)
+ $reads = @(
+ @{ command = 'webapp silo config show'; keys = @('P42') }
+ @{ command = 'webapp silo config list'; keys = @('P43') }
+ @{ command = 'webapp silo config snapshot list'; keys = @('P44') }
+ @{ command = 'webapp silo config connection-string show'; keys = @('P52') }
+ @{ command = 'webapp silo config storage-account list'; keys = @('P51') }
+ @{ command = 'webapp silo config metadata list'; keys = @('P60') }
+ @{ command = 'webapp silo auth show'; keys = @('P56') }
+ @{ command = 'webapp silo auth show'; keys = @('P55'); args = @('--metadata-only') }
+ @{ command = 'webapp silo auth v2 show'; keys = @('P58') }
+ @{ command = 'webapp silo auth v2 show'; keys = @('P57'); args = @('--legacy') }
+ @{ command = 'webapp silo log config show'; keys = @('P61') }
+ @{ command = 'webapp silo deployment list'; keys = @('P02') }
+ @{ command = 'webapp silo deployment status list'; keys = @('P82') }
+ @{ command = 'webapp silo config keyvault-reference list'; keys = @('P47') }
+ @{ command = 'webapp silo webjob list'; keys = @('P21') }
+ @{ command = 'webapp silo webjob continuous list'; keys = @('P23') }
+ @{ command = 'webapp silo webjob triggered list'; keys = @('P27') }
+ @{ command = 'webapp silo site-extension list'; keys = @('P32') }
+ @{ command = 'webapp silo premier-addon list'; keys = @('P62') }
+ @{ command = 'webapp silo metrics definition list'; keys = @('P65') }
+ )
+ if ($Context.Profile -eq 'Extended') {
+ $reads += @(
+ @{ command = 'webapp silo log php show'; keys = @('P01') }
+ @{ command = 'webapp silo log startup list'; keys = @('P16'); args = @('--latest', 'true') }
+ @{ command = 'webapp silo log startup show-summary'; keys = @('P17') }
+ @{ command = 'webapp silo log list'; keys = @('P19') }
+ @{ command = 'webapp silo runtime list-detected'; keys = @('P49') }
+ @{ command = 'webapp silo config push-settings show'; keys = @('P59') }
+ @{ command = 'webapp silo antivirus config show'; keys = @('P77') }
+ @{ command = 'webapp silo antivirus log list'; keys = @('P79') }
+ @{ command = 'webapp silo sitecontainers list'; keys = @('P83') }
+ )
+ }
+ foreach ($read in $reads) {
+ $definition = $read
+ $id = $Suite.key + '.read.' + $read.keys[0]
+ Invoke-AbCase $Context $id {
+ $extra = Get-AbValue $definition 'args' @()
+ $result = Invoke-AbAz $Context ((Get-AbSiteArguments $Suite $definition.command) + @($extra)) -ApiKeys $definition.keys
+ Assert-AbContainer $result.json
+ } -Required $false -EvidenceKind StructuralOnly
+ }
+ Invoke-AbCase $Context ($Suite.key + '.instances') {
+ $instances = (Invoke-AbAz $Context (Get-AbSiteArguments $Suite 'webapp silo instance list') -ApiKeys @('P40')).json
+ $items = @(Get-AbItems $instances)
+ if (-not $items.Count) { Stop-Ab 'The service returned no instance identifier for a point-read fixture.' BlockedFixture }
+ $instance = [string](Get-AbValue $items[0] 'name' (Get-AbValue $items[0] 'id'))
+ $instance = ($instance -split '/')[-1]
+ if (-not $instance) { Stop-Ab 'The instance collection omitted a usable identifier.' BlockedFixture }
+ $result = Invoke-AbAz $Context ((Get-AbSiteArguments $Suite 'webapp silo instance show') + @('--instance', $instance)) -ApiKeys @('P41')
+ Assert-AbContainer $result.json
+ $Suite.instance = $instance
+ Save-AbManifest $Context
+ } -Required $false
+ Invoke-AbCase $Context ($Suite.key + '.snapshot-point') {
+ $snapshots = (Invoke-AbAz $Context (Get-AbSiteArguments $Suite 'webapp silo config snapshot list') -ApiKeys @('P44')).json
+ $items = @(Get-AbItems $snapshots)
+ if (-not $items.Count) { Stop-Ab 'No configuration snapshot identifier is available yet.' BlockedFixture }
+ $snapshotValue = Get-AbValue (Get-AbProps $items[0]) 'snapshotId'
+ if ($null -eq $snapshotValue) { $snapshotValue = Get-AbValue $items[0] 'id' }
+ $snapshot = [string]$snapshotValue
+ $snapshot = ($snapshot -split '/')[-1]
+ if (-not $snapshot) { Stop-Ab 'The snapshot inventory has no usable identifier.' BlockedFixture }
+ foreach ($variant in @(@{ args = @(); key = 'P45' }, @{ args = @('--include-secrets'); key = 'P46' })) {
+ $result = Invoke-AbAz $Context ((Get-AbSiteArguments $Suite 'webapp silo config snapshot show') +
+ @('--snapshot-id', $snapshot) + $variant.args) -ApiKeys @($variant.key)
+ Assert-AbContainer $result.json
+ }
+ } -Required $false
+ Invoke-AbCase $Context ($Suite.key + '.deployment-point') {
+ $deployments = (Invoke-AbAz $Context (Get-AbSiteArguments $Suite 'webapp silo deployment list') -ApiKeys @('P02')).json
+ $items = @(Get-AbItems $deployments)
+ if (-not $items.Count) { Stop-Ab 'Silo deployment history has no point-read fixture. Primary history is not substituted.' BlockedFixture }
+ $deployment = [string](Get-AbValue $items[0] 'name' (Get-AbValue $items[0] 'id'))
+ $deployment = ($deployment -split '/')[-1]
+ foreach ($variant in @(@{ command = 'webapp silo deployment show'; key = 'P03' },
+ @{ command = 'webapp silo deployment log list'; key = 'P04' })) {
+ $result = Invoke-AbAz $Context ((Get-AbSiteArguments $Suite $variant.command) + @('--deployment-id', $deployment)) -ApiKeys @($variant.key)
+ Assert-AbContainer $result.json
+ }
+ } -Required $false
+}
+
+function Invoke-AbNetworking {
+ param([hashtable]$Context, [hashtable]$Suite)
+ Invoke-AbCase $Context ($Suite.key + '.networking') {
+ $vnetId = $Suite.groupId + '/providers/Microsoft.Network/virtualNetworks/' + $Suite.vnet
+ Register-AbIntent $Context $Suite $vnetId
+ Invoke-AbAz $Context (@('network', 'vnet', 'create', '-g', $Suite.group, '-n', $Suite.vnet,
+ '--location', $Context.SiloRegion, '--address-prefixes', '10.237.16.0/24', '--subnet-name', 'integration',
+ '--subnet-prefixes', '10.237.16.0/26', '--tags') + (Get-AbTags $Context)) `
+ -Mutation -Replay Create -IntentId $vnetId | Out-Null
+ Invoke-AbAz $Context @('network', 'vnet', 'subnet', 'update', '-g', $Suite.group, '--vnet-name', $Suite.vnet,
+ '-n', 'integration', '--delegations', 'Microsoft.Web/serverFarms') -Mutation -Replay Idempotent -IntentId $vnetId | Out-Null
+ $subnetId = $vnetId + '/subnets/integration'
+ Invoke-AbAz $Context ((Get-AbSiteArguments $Suite 'webapp silo vnet-integration add') + @('--subnet', $subnetId)) `
+ -Mutation -Replay Idempotent -IntentId $Suite.siteSiloId -ApiKeys @('C23') | Out-Null
+ try {
+ $integration = (Invoke-AbAz $Context (Get-AbSiteArguments $Suite 'webapp silo vnet-integration show') -ApiKeys @('C22')).json
+ Assert-Ab ((Get-AbValue (Get-AbProps $integration) 'subnetResourceId') -ieq $subnetId) 'Silo networking read returned the wrong subnet.'
+ Invoke-AbAz $Context ((Get-AbSiteArguments $Suite 'webapp silo vnet-integration update') + @('--subnet', $subnetId)) `
+ -Mutation -Replay Idempotent -IntentId $Suite.siteSiloId -ApiKeys @('C24') | Out-Null
+ $planIntegrations = (Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'vnet-integration', 'list',
+ '--ids', $Suite.planSiloId) -ApiKeys @('C07')).json
+ Assert-AbContainer $planIntegrations
+ Assert-Ab ((ConvertTo-Json -InputObject $planIntegrations -Depth 30) -like ('*' + $subnetId + '*')) 'Plan integration inventory omitted the owned subnet.'
+ }
+ finally {
+ Invoke-AbAz $Context (Get-AbSiteArguments $Suite 'webapp silo vnet-integration remove') `
+ -Mutation -Replay Delete -IntentId $Suite.siteSiloId -ApiKeys @('C25') | Out-Null
+ }
+ $disconnected = Invoke-AbAz $Context (Get-AbSiteArguments $Suite 'webapp silo vnet-integration show') -AllowFailure -ApiKeys @('C22')
+ if ($disconnected.exitCode -eq 0) {
+ Assert-Ab (-not (Get-AbValue (Get-AbProps $disconnected.json) 'subnetResourceId')) 'Silo integration remained connected after removal.'
+ }
+ elseif ($disconnected.exitCode -ne 3) { Stop-Ab 'Silo integration absence could not be verified.' }
+ }
+}
+
+function Invoke-AbCore {
+ param([hashtable]$Context, [hashtable]$Suite)
+ $prefix = $Suite.key
+ Invoke-AbCase $Context "$prefix.outputs-and-ids" {
+ $byId = (Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $Suite.siteSiloId) -ApiKeys @('C09')).json
+ Assert-AbSilo $byId $Suite.siteSiloId $Context.SiloRegion
+ foreach ($format in @('table', 'tsv')) {
+ $output = Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $Suite.siteSiloId, '--output', $format) -OutputShape Text -ApiKeys @('C09')
+ Assert-Ab (-not [string]::IsNullOrWhiteSpace($output.stdout)) 'Formatted silo output was empty.'
+ }
+ $rejected = $false
+ try { Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $Suite.siteSiloId, '-n', 'conflicting-name') -AllowFailure | Out-Null }
+ catch {
+ if ($_.Exception.Data['AbKind'] -ne 'Invalid') { throw }
+ $rejected = $true
+ }
+ Assert-Ab $rejected 'The runner did not reject conflicting ID/named identity arguments.'
+ $wrongType = Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $Suite.planSiloId) -AllowFailure
+ Assert-Ab ($wrongType.exitCode -ne 0) 'A plan ID was accepted by the site-silo command.'
+ }
+ Invoke-AbCase $Context "$prefix.plan-updates" {
+ $counts = $(if ($Context.Manifest.options.maxWorkersPerPlan -ge 2) { @(1, 2, 1) } else { @(1, 1) })
+ foreach ($count in $counts) {
+ $updated = (Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'update', '--ids', $Suite.planSiloId,
+ '--number-of-workers', [string]$count, '--zone-redundant', 'false') `
+ -Mutation -Replay Idempotent -IntentId $Suite.planSiloId -ApiKeys @('C04')).json
+ Assert-AbSilo $updated $Suite.planSiloId $Context.SiloRegion
+ Assert-Ab ((Get-AbValue (Get-AbProps $updated) 'instanceNumberOverride') -eq $count) 'Worker-count override did not match the request.'
+ Assert-Ab ((Get-AbValue (Get-AbProps $updated) 'zoneRedundantOverride') -eq $false) 'Explicit false zone override was not preserved.'
+ }
+ $replayed = (Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'create', '-g', $Suite.group, '-n', $Suite.plan,
+ '--silo', $Suite.planSilo, '--location', $Context.SiloRegion, '--primary-location', $Context.PrimaryRegion,
+ '--number-of-workers', '1') -Mutation -Replay Idempotent -IntentId $Suite.planSiloId -ApiKeys @('C03')).json
+ Assert-AbSilo $replayed $Suite.planSiloId $Context.SiloRegion
+ }
+ Invoke-AbCase $Context "$prefix.site-policy" {
+ $updated = (Invoke-AbAz $Context @('webapp', 'silo', 'update', '--ids', $Suite.siteSiloId,
+ '--follow-last-known-good', 'false', '--endpoint-status', 'Enabled') `
+ -Mutation -Replay Idempotent -IntentId $Suite.siteSiloId -ApiKeys @('C11')).json
+ Assert-AbSilo $updated $Suite.siteSiloId $Context.SiloRegion
+ Assert-Ab ((Get-AbValue (Get-AbProps $updated) 'followLastKnownGood') -eq $false) 'Explicit false revision policy was not preserved.'
+ }
+ Invoke-AbCase $Context "$prefix.traffic" {
+ $current = (Invoke-AbAz $Context @('webapp', 'managed-traffic', 'show', '-g', $Suite.group, '-n', $Suite.app) -ApiKeys @('C19')).json
+ Assert-AbContainer $current
+ $expanded = (Invoke-AbAz $Context @('webapp', 'managed-traffic', 'list', '-g', $Suite.group, '-n', $Suite.app, '--expand-endpoints') -ApiKeys @('C18')).json
+ Assert-Ab (@($expanded).Count -eq 1) 'Managed traffic collection is not a singleton.'
+ $endpoints = @(Get-AbValue (Get-AbProps $expanded[0]) 'endpoints' @())
+ foreach ($region in @($Context.PrimaryRegion, $Context.SiloRegion)) {
+ Assert-Ab (@($endpoints | Where-Object { (Get-AbValue $_ 'region') -eq $region }).Count -eq 1) 'Traffic config omitted a fixture region.'
+ }
+ $input = Join-Path $Context.RunDirectory ('inputs\' + $prefix + '-traffic.json')
+ Write-AbJson $input @(@{ region = $Context.SiloRegion; weight = 2 })
+ $active = (Invoke-AbAz $Context @('webapp', 'managed-traffic', 'update', '-g', $Suite.group, '-n', $Suite.app,
+ '--traffic-mode', 'Active', '--endpoints', ('@' + $input)) -Mutation -Replay Idempotent -IntentId $Suite.appId -ApiKeys @('C20')).json
+ Assert-Ab ((Get-AbValue (Get-AbProps $active) 'trafficMode') -eq 'Active') 'Active traffic mode was not applied.'
+ Write-AbJson $input @(@{ region = $Context.PrimaryRegion; priority = 1 }, @{ region = $Context.SiloRegion; priority = 2 })
+ $passive = (Invoke-AbAz $Context @('webapp', 'managed-traffic', 'update', '-g', $Suite.group, '-n', $Suite.app,
+ '--traffic-mode', 'Passive', '--endpoints', ('@' + $input)) -Mutation -Replay Idempotent -IntentId $Suite.appId -ApiKeys @('C20')).json
+ Assert-Ab ((Get-AbValue (Get-AbProps $passive) 'trafficMode') -eq 'Passive') 'Passive traffic mode was not applied.'
+ $final = (Invoke-AbAz $Context @('webapp', 'managed-traffic', 'list', '-g', $Suite.group, '-n', $Suite.app, '--expand-endpoints') -ApiKeys @('C18')).json
+ $values = @(Get-AbValue (Get-AbProps $final[0]) 'endpoints' @())
+ foreach ($entry in @(@{ region = $Context.PrimaryRegion; priority = 1 }, @{ region = $Context.SiloRegion; priority = 2 })) {
+ $match = @($values | Where-Object { (Get-AbValue $_ 'region') -eq $entry.region })
+ Assert-Ab ($match.Count -eq 1 -and (Get-AbValue $match[0] 'priority') -eq $entry.priority) 'A requested endpoint priority was not applied.'
+ }
+ $secondary = @($values | Where-Object { (Get-AbValue $_ 'region') -eq $Context.SiloRegion })[0]
+ Assert-Ab ((Get-AbValue $secondary 'weight') -eq 2) 'Mode-only/partial traffic updates lost the explicit weight.'
+ }
+ Invoke-AbCase $Context "$prefix.origin-control" {
+ Wait-AbMarker $Context $Suite $Suite.siloHost 'v1'
+ $originId = $Suite.appId + '/silos/origin'
+ $before = (Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $originId) -ApiKeys @('C09')).json
+ $status = [string](Get-AbValue (Get-AbProps $before) 'endpointStatus')
+ Assert-Ab ($status -in @('Enabled', 'Disabled')) 'Origin endpoint state is not recognized.'
+ try {
+ Invoke-AbAz $Context @('webapp', 'silo', 'update', '--ids', $originId, '--endpoint-status', 'Disabled') `
+ -Mutation -Replay Idempotent -IntentId $Suite.appId -ApiKeys @('C11') | Out-Null
+ $disabled = (Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $originId) -ApiKeys @('C09')).json
+ Assert-Ab ((Get-AbValue (Get-AbProps $disabled) 'endpointStatus') -eq 'Disabled') 'Origin endpoint was not disabled.'
+ Wait-AbMarker $Context $Suite $Suite.siloHost 'v1'
+ }
+ finally {
+ Invoke-AbAz $Context @('webapp', 'silo', 'update', '--ids', $originId, '--endpoint-status', $status) `
+ -Mutation -Replay Idempotent -IntentId $Suite.appId -ApiKeys @('C11') | Out-Null
+ }
+ }
+ Invoke-AbCase $Context "$prefix.dummy-connection-string" {
+ $dummy = 'abcli-dummy-' + $Context.Manifest.runId
+ Invoke-AbAz $Context @('webapp', 'config', 'connection-string', 'set', '--ids', $Suite.appId,
+ '--connection-string-type', 'Custom', '--settings', ('ABCLI_DUMMY=' + $dummy)) `
+ -Mutation -Replay Idempotent -IntentId $Suite.appId | Out-Null
+ Wait-AbCondition $Context {
+ $bag = Get-AbProps (Invoke-AbAz $Context (Get-AbSiteArguments $Suite 'webapp silo config connection-string list') -ApiKeys @('P53')).json
+ return (Get-AbValue (Get-AbValue $bag 'ABCLI_DUMMY' @{}) 'value') -eq $dummy
+ } 'replicated dummy connection-string marker'
+ $point = (Invoke-AbAz $Context ((Get-AbSiteArguments $Suite 'webapp silo config connection-string show') +
+ @('--key', 'ABCLI_DUMMY')) -ApiKeys @('P54')).json
+ Assert-Ab ((Get-AbValue (Get-AbProps $point) 'value') -eq $dummy) 'Connection-string point read did not return the dummy fixture value.'
+ }
+ Invoke-AbSafeReads $Context $Suite
+ Invoke-AbNetworking $Context $Suite
+ Invoke-AbCase $Context "$prefix.latest-replication" {
+ Invoke-AbDeploy $Context $Suite 'v2'
+ Wait-AbMarker $Context $Suite $Suite.siloHost 'v2'
+ Wait-AbCondition $Context {
+ $settings = Get-AbProps (Invoke-AbAz $Context (Get-AbSiteArguments $Suite 'webapp silo config appsettings list') -ApiKeys @('P50')).json
+ return (Get-AbValue $settings 'ABCLI_GENERATION') -eq 'v2'
+ } 'latest config marker v2'
+ }
+ Invoke-AbCase $Context "$prefix.lkg-baseline" {
+ $lkg = (Invoke-AbAz $Context @('webapp', 'last-known-good', 'mark', '-g', $Suite.group, '-n', $Suite.app) `
+ -Mutation -Replay NoReplay -IntentId $Suite.appId -ApiKeys @('C17')).json
+ $Suite.lkgRevision = Get-AbValue (Get-AbProps $lkg) 'revisionNumber'
+ Assert-Ab ($null -ne $Suite.lkgRevision) 'LKG mark did not return an actual ready revision.'
+ $read = (Invoke-AbAz $Context @('webapp', 'last-known-good', 'show', '-g', $Suite.group, '-n', $Suite.app) -ApiKeys @('C16')).json
+ Assert-Ab ((Get-AbValue (Get-AbProps $read) 'revisionNumber') -eq $Suite.lkgRevision) 'LKG point read differs from the marked revision.'
+ $list = (Invoke-AbAz $Context @('webapp', 'last-known-good', 'list', '-g', $Suite.group, '-n', $Suite.app) -ApiKeys @('C15')).json
+ Assert-Ab (@($list).Count -eq 1) 'LKG list is not a singleton.'
+ Invoke-AbAz $Context @('webapp', 'silo', 'update', '--ids', $Suite.siteSiloId, '--follow-last-known-good', 'true') `
+ -Mutation -Replay Idempotent -IntentId $Suite.siteSiloId -ApiKeys @('C11') | Out-Null
+ Save-AbManifest $Context
+ }
+ Invoke-AbCase $Context "$prefix.lkg-pin" {
+ Invoke-AbDeploy $Context $Suite 'v3'
+ $accepted = Invoke-AbAz $Context @('webapp', 'silo', 'refresh', '-g', $Suite.group, '-n', $Suite.app) `
+ -Mutation -Replay NoReplay -IntentId $Suite.appId -OutputShape Empty -ApiKeys @('C14')
+ Assert-Ab ($accepted.stderr -match 'Refresh request accepted') 'Refresh acceptance notice was not present.'
+ for ($sample = 0; $sample -lt 3; $sample++) {
+ $marker = Get-AbMarker $Context $Suite $Suite.siloHost
+ Assert-Ab ((Get-AbValue $marker 'runId') -eq $Context.Manifest.runId -and (Get-AbValue $marker 'generation') -eq 'v2') 'LKG-following silo did not retain the verified baseline marker.'
+ if (-not $Context.Executor) { Start-Sleep -Seconds 10 }
+ }
+ }
+ Invoke-AbCase $Context "$prefix.lkg-advance" {
+ $deadline = [DateTime]::UtcNow.AddSeconds($Context.Manifest.options.observationTimeoutSeconds)
+ $newRevision = $null
+ do {
+ $marked = (Invoke-AbAz $Context @('webapp', 'last-known-good', 'mark', '-g', $Suite.group, '-n', $Suite.app) `
+ -Mutation -Replay NoReplay -IntentId $Suite.appId -ApiKeys @('C17')).json
+ $newRevision = Get-AbValue (Get-AbProps $marked) 'revisionNumber'
+ if ($null -ne $newRevision -and [long]$newRevision -gt [long]$Suite.lkgRevision) { break }
+ if (-not $Context.Executor) { Start-Sleep -Seconds 15 }
+ } while ([DateTime]::UtcNow -lt $deadline)
+ Assert-Ab ($null -ne $newRevision -and [long]$newRevision -gt [long]$Suite.lkgRevision) 'No new ready revision was returned by a later explicit LKG mark.'
+ Wait-AbMarker $Context $Suite $Suite.siloHost 'v3'
+ Wait-AbCondition $Context {
+ $site = (Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $Suite.siteSiloId) -ApiKeys @('C09')).json
+ return (Get-AbValue (Get-AbProps $site) 'revisionNumber') -eq $newRevision
+ } 'silo reaches the actual newly marked revision'
+ $Suite.lkgRevision = $newRevision
+ Save-AbManifest $Context
+ }
+ Invoke-AbCase $Context "$prefix.runtime" {
+ foreach ($entry in @(@{ verb = 'stop'; key = 'P69' }, @{ verb = 'start'; key = 'P68' }, @{ verb = 'restart'; key = 'P67' })) {
+ $arguments = @('webapp', 'silo', $entry.verb, '--ids', $Suite.siteSiloId)
+ if ($entry.verb -eq 'restart') { $arguments += @('--soft-restart', 'false', '--synchronous', 'true') }
+ Invoke-AbAz $Context $arguments -Mutation -Replay Idempotent -IntentId $Suite.siteSiloId -ApiKeys @($entry.key) | Out-Null
+ }
+ Wait-AbMarker $Context $Suite $Suite.siloHost 'v3'
+ }
+ Invoke-AbCase $Context "$prefix.metrics" {
+ $start = [DateTime]::UtcNow.AddHours(-1).ToString('yyyy-MM-ddTHH:mm:ssZ')
+ $end = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
+ $traffic = (Invoke-AbAz $Context @('webapp', 'managed-traffic', 'metrics', 'list', '-g', $Suite.group, '-n', $Suite.app,
+ '--start-time', $start, '--end-time', $end, '--time-grain', 'PT1M') -ApiKeys @('C21')).json
+ Assert-AbContainer $traffic
+ $metrics = (Invoke-AbAz $Context ((Get-AbSiteArguments $Suite 'webapp silo metrics list') +
+ @('--start-time', $start, '--end-time', $end, '--time-grain', 'PT1H')) -ApiKeys @('P64')).json
+ Assert-AbContainer $metrics
+ } -Required $false -EvidenceKind StructuralOnly
+ Invoke-AbNegatives $Context $Suite
+ if ($Context.Profile -eq 'Extended') { Invoke-AbExtended $Context $Suite }
+}
+
+function Invoke-AbNegatives {
+ param([hashtable]$Context, [hashtable]$Suite)
+ Invoke-AbCase $Context ($Suite.key + '.not-found') {
+ $missing = $Suite.appId + '/silos/missing-' + $Context.Manifest.runId.Substring(15)
+ $result = Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $missing) -ExpectedExitCodes @(3)
+ Assert-Ab ($result.exitCode -eq 3) 'A nonexistent silo did not return the show not-found exit code.'
+ } -EvidenceKind NegativeOnly
+ Invoke-AbCase $Context ($Suite.key + '.invalid-workers') {
+ foreach ($count in @('0', '31')) {
+ $result = Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'update', '--ids', $Suite.planSiloId,
+ '--number-of-workers', $count) -AllowFailure -Mutation -Replay Idempotent -IntentId $Suite.planSiloId
+ Assert-Ab ($result.exitCode -eq 1 -and $result.stderr -match 'number-of-workers must be between 1 and 30') 'Worker boundary validation did not produce the specific expected error.'
+ $Context.Manifest.pendingMutation = $null
+ Save-AbManifest $Context
+ }
+ $unchanged = (Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'show', '--ids', $Suite.planSiloId) -ApiKeys @('C02')).json
+ Assert-Ab ((Get-AbValue (Get-AbProps $unchanged) 'instanceNumberOverride') -eq 1) 'Rejected worker counts changed the plan override.'
+ } -EvidenceKind NegativeOnly
+ Invoke-AbCase $Context ($Suite.key + '.origin-delete-rejected') {
+ $result = Invoke-AbAz $Context @('webapp', 'silo', 'delete', '--ids', ($Suite.appId + '/silos/origin'), '--yes') `
+ -Mutation -Replay Delete -IntentId $Suite.appId -AllowFailure -ApiKeys @('C12')
+ Assert-Ab ($result.exitCode -eq 1 -and $result.stderr -match '(?i)origin.+cannot be deleted') 'Origin delete did not produce the specific expected rejection.'
+ $Context.Manifest.pendingMutation = $null
+ Save-AbManifest $Context
+ } -EvidenceKind NegativeOnly
+ Invoke-AbCase $Context ($Suite.key + '.dependency-delete-rejected') {
+ $result = Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'delete', '--ids', $Suite.planSiloId, '--yes') `
+ -Mutation -Replay Delete -IntentId $Suite.planSiloId -AllowFailure -ApiKeys @('C05')
+ Assert-Ab ($result.exitCode -eq 1 -and $result.stderr -match '(?i)(site silos|site silo).+(associated|first)|associated.+site silos') 'Plan dependency delete did not produce the specific expected conflict.'
+ $Context.Manifest.pendingMutation = $null
+ Save-AbManifest $Context
+ Assert-AbSilo (Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $Suite.siteSiloId) -ApiKeys @('C09')).json $Suite.siteSiloId $Context.SiloRegion
+ } -EvidenceKind NegativeOnly
+}
+
+function Invoke-AbExtended {
+ param([hashtable]$Context, [hashtable]$Suite)
+ foreach ($test in @(
+ @{ name = 'dns'; key = 'P35'; body = @{ properties = @{ dnsName = $Suite.primaryHost } } },
+ @{ name = 'tcp-ping'; key = 'P36'; body = @{ properties = @{ host = $Suite.primaryHost; port = '443' } } })) {
+ $definition = $test
+ Invoke-AbCase $Context ($Suite.key + '.network-check.' + $test.name) {
+ $input = Join-Path $Context.RunDirectory ('inputs\' + $Suite.key + '-' + $definition.name + '.json')
+ Write-AbJson $input $definition.body
+ $result = Invoke-AbAz $Context ((Get-AbSiteArguments $Suite ('webapp silo network-check ' + $definition.name)) +
+ @('--body', ('@' + $input))) -ApiKeys @($definition.key)
+ Assert-AbContainer $result.json
+ } -Required $false -EvidenceKind StructuralOnly
+ }
+ Invoke-AbCase $Context ($Suite.key + '.keyvault-reference-refresh') {
+ Invoke-AbAz $Context ((Get-AbSiteArguments $Suite 'webapp silo config keyvault-reference refresh') +
+ @('--force-restart', 'false')) -Mutation -Replay NoReplay -IntentId $Suite.siteSiloId -ApiKeys @('P48') | Out-Null
+ } -Required $false -EvidenceKind StructuralOnly
+ if ($Context.Manifest.options.includeZoneRedundancy) {
+ Invoke-AbCase $Context ($Suite.key + '.zone-redundancy') {
+ if ($Context.Manifest.options.maxWorkersPerPlan -lt 2) { Stop-Ab 'Zone testing requires authorization for two workers.' BlockedFixture }
+ try {
+ $updated = (Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'update', '--ids', $Suite.planSiloId,
+ '--number-of-workers', '2', '--zone-redundant', 'true') -Mutation -Replay Idempotent -IntentId $Suite.planSiloId -ApiKeys @('C04')).json
+ Assert-Ab ((Get-AbValue (Get-AbProps $updated) 'zoneRedundantOverride') -eq $true) 'Zone override was not enabled.'
+ }
+ finally {
+ Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'update', '--ids', $Suite.planSiloId,
+ '--zone-redundant', 'false', '--number-of-workers', '1') -Mutation -Replay Idempotent -IntentId $Suite.planSiloId -ApiKeys @('C04') | Out-Null
+ }
+ }
+ }
+ if ($Context.Manifest.options.includeInvasiveDiagnostics) {
+ Invoke-AbCase $Context ($Suite.key + '.invasive-fixtures') {
+ Stop-Ab 'No reviewed disposable process/container/trace-storage fixture is configured. Invasive actions were not sent.' BlockedFixture
+ } -Required $false
+ }
+}
+
+function Remove-AbSilo {
+ param([hashtable]$Context, [hashtable]$Suite, [string]$Family, [string]$Id, [string]$DeleteKey, [string]$OperationKey)
+ $current = Invoke-AbAz $Context ($Family.Split(' ') + @('show', '--ids', $Id)) -AllowFailure -TimeoutSeconds 60
+ if ($current.exitCode -eq 3) { return }
+ if ($current.exitCode -ne 0) { Stop-Ab 'Cannot verify a recorded silo before deletion; the group was retained.' Cleanup }
+ Assert-Ab ((Get-AbValue $current.json 'id') -ieq $Id) 'A cleanup read returned the wrong silo identity.'
+ $deleted = Invoke-AbAz $Context ($Family.Split(' ') + @('delete', '--ids', $Id, '--yes', '--no-wait')) `
+ -Mutation -Replay Delete -IntentId $Id -ApiKeys @($DeleteKey)
+ Complete-AbSiloOperation $Context $Suite $Family $Id $deleted $OperationKey -Delete
+ Wait-AbCondition $Context {
+ $read = Invoke-AbAz $Context ($Family.Split(' ') + @('show', '--ids', $Id)) -AllowFailure -TimeoutSeconds 60
+ if ($read.exitCode -notin @(0, 3)) { Stop-Ab 'Cannot verify silo absence after deletion.' Cleanup }
+ return $read.exitCode -eq 3
+ } 'owned silo deletion' -TimeoutSeconds ($Context.Manifest.options.cleanupTimeoutMinutes * 60)
+ $again = Invoke-AbAz $Context ($Family.Split(' ') + @('delete', '--ids', $Id, '--yes', '--no-wait')) `
+ -Mutation -Replay Delete -IntentId $Id -ApiKeys @($DeleteKey)
+ Complete-AbSiloOperation $Context $Suite $Family $Id $again $OperationKey -Delete
+}
+
+function Remove-AbOwnedResources {
+ param([hashtable]$Context, [hashtable]$Suite)
+ $previous = $Context.InCleanup
+ $Context.InCleanup = $true
+ if ($null -eq $Context.CleanupDeadline -or -not $previous) {
+ $Context.CleanupDeadline = [DateTime]::UtcNow.AddMinutes($Context.Manifest.options.cleanupTimeoutMinutes)
+ }
+ $targets = $(if ($null -ne $Suite) { @($Suite) } else { @($Context.Manifest.suites) })
+ $Context.Manifest.cleanup.status = 'Running'
+ Save-AbManifest $Context
+ try {
+ foreach ($item in $targets) {
+ if ($item.cleanupComplete -or -not $item.groupCreateAttempted) { continue }
+ Invoke-AbCase $Context ($item.key + '.cleanup') {
+ if (-not (Assert-AbOwnedGroup $Context $item)) {
+ $item.cleanupComplete = $true
+ return
+ }
+ if ($item.siteSiloId -in @($item.intents)) {
+ Remove-AbSilo $Context $item 'webapp silo' $item.siteSiloId 'C12' 'C13'
+ }
+ if ($item.planSiloId -in @($item.intents)) {
+ Remove-AbSilo $Context $item 'appservice plan silo' $item.planSiloId 'C05' 'C06'
+ }
+ foreach ($parent in @(@{ family = 'webapp silo'; name = $item.app; id = $item.appId; child = $item.siteSiloId },
+ @{ family = 'appservice plan silo'; name = $item.plan; id = $item.planId; child = $item.planSiloId })) {
+ if ($parent.child -notin @($item.intents)) { continue }
+ $listed = Invoke-AbAz $Context ($parent.family.Split(' ') + @('list', '-g', $item.group, '-n', $parent.name)) -AllowFailure
+ if ($listed.exitCode -eq 3) { continue }
+ if ($listed.exitCode -ne 0) { Stop-Ab 'Cannot inspect the remaining silo dependencies; group deletion was refused.' Cleanup }
+ $remaining = @($listed.json | Where-Object { (Get-AbValue $_ 'name') -ne 'origin' })
+ if ($remaining.Count) { Stop-Ab 'Silo dependencies remain; group deletion was refused.' Cleanup }
+ }
+ $resources = (Invoke-AbAz $Context @('resource', 'list', '-g', $item.group) -TimeoutSeconds 90).json
+ foreach ($resource in $resources) {
+ $id = [string](Get-AbValue $resource 'id')
+ if ($id -notin @($item.intents)) { Stop-Ab 'The group contains a resource not recorded by this run; deletion was refused.' Cleanup }
+ }
+ if (-not (Assert-AbOwnedGroup $Context $item)) { $item.cleanupComplete = $true; return }
+ Invoke-AbAz $Context @('group', 'delete', '--name', $item.group, '--yes', '--no-wait') `
+ -OutputShape Empty -Mutation -Replay Delete -IntentId $item.groupId | Out-Null
+ Wait-AbCondition $Context {
+ $exists = (Invoke-AbAz $Context @('group', 'exists', '-n', $item.group) -TimeoutSeconds 60).json
+ return $exists -eq $false
+ } 'owned resource-group deletion' -TimeoutSeconds ($Context.Manifest.options.cleanupTimeoutMinutes * 60)
+ $item.cleanupComplete = $true
+ Save-AbManifest $Context
+ }
+ }
+ $Context.Manifest.cleanup.remaining = @($Context.Manifest.suites | Where-Object { $_.groupCreateAttempted -and -not $_.cleanupComplete } | ForEach-Object { $_.groupId })
+ $Context.Manifest.cleanup.status = $(if ($Context.Manifest.cleanup.remaining.Count) { 'Incomplete' } else { 'Complete' })
+ $Context.Manifest.pendingMutation = $null
+ Save-AbManifest $Context
+ }
+ catch {
+ $Context.Manifest.cleanup.status = 'Failed'
+ $Context.Manifest.cleanup.remaining = @($Context.Manifest.suites | Where-Object { $_.groupCreateAttempted -and -not $_.cleanupComplete } | ForEach-Object { $_.groupId })
+ Save-AbManifest $Context
+ Stop-Ab 'Cleanup did not complete. Inspect the manifest and remaining resource IDs; no unsafe group fallback was used.' Cleanup -InnerException $_.Exception
+ }
+ finally { $Context.InCleanup = $previous }
+}
+
+function Get-AbUnavailableReason {
+ param([string]$Command)
+ if ($Command -match 'process|container restart|extension invoke|network-trace|antivirus scan|antivirus config update') {
+ return 'Requires a reviewed disposable process/container/extension or trace fixture and diagnostic authorization.'
+ }
+ if ($Command -match 'webjob.+(show|start|stop|run|history)|premier-addon show|site-extension show|sitecontainers show') {
+ return 'Requires a real owned fixture identifier; an empty list does not establish point/action coverage.'
+ }
+ if ($Command -match 'log (tail|container download|startup show)|deployment (status show|log show)|runtime-operation') {
+ return 'Requires returned service log/deployment/operation identifiers and a bounded file/stream fixture.'
+ }
+ return 'Not selected or no successful semantic assertion was completed for this command.'
+}
+
+function Get-AbLatestCases {
+ param([hashtable]$Context)
+ return @($Context.Manifest.cases | Group-Object id | ForEach-Object { $_.Group[-1] })
+}
+
+function Get-AbCoverage {
+ param([hashtable]$Context)
+ $commands = @()
+ foreach ($entry in $Context.Catalog.commands) {
+ $hits = @()
+ foreach ($case in @(Get-AbLatestCases $Context)) {
+ foreach ($call in $case.calls) {
+ if ($call.command -eq $entry.command) { $hits += @(@{ case = $case; call = $call }) }
+ }
+ }
+ $positive = @($hits | Where-Object { $_.case.status -eq 'Pass' -and $_.call.exitCode -eq 0 -and $_.case.evidenceKind -eq 'Behavioral' })
+ $structural = @($hits | Where-Object { $_.case.status -eq 'Pass' -and $_.case.evidenceKind -eq 'StructuralOnly' })
+ $negative = @($hits | Where-Object { $_.case.status -eq 'Pass' -and $_.case.evidenceKind -eq 'NegativeOnly' })
+ $status = $(if ($positive.Count) { 'Pass' } elseif ($structural.Count) { 'StructuralOnly' } elseif ($negative.Count) { 'NegativeOnly' } elseif ($hits.Count) { 'Incomplete' } else { 'NotRun' })
+ $commands += @{ command = $entry.command; status = $status; caseIds = @($hits | ForEach-Object { $_.case.id } | Select-Object -Unique)
+ reason = $(if ($status -eq 'NotRun') { Get-AbUnavailableReason $entry.command } else { '' }) }
+ }
+ $variants = @()
+ foreach ($variant in $Context.Catalog.variants) {
+ $positive = @(Get-AbLatestCases $Context | Where-Object {
+ $_.status -eq 'Pass' -and $_.evidenceKind -eq 'Behavioral' -and
+ @($_.calls | Where-Object { $_.exitCode -eq 0 -and $variant.key -in $_.apiKeys }).Count
+ })
+ $structural = @(Get-AbLatestCases $Context | Where-Object {
+ $_.status -eq 'Pass' -and $_.evidenceKind -eq 'StructuralOnly' -and
+ @($_.calls | Where-Object { $_.exitCode -eq 0 -and $variant.key -in $_.apiKeys }).Count
+ })
+ $mappedCommands = @($Context.Catalog.commands | Where-Object handler -eq $variant.handler | ForEach-Object command)
+ $variants += @{ key = $variant.key; commands = $mappedCommands
+ status = $(if ($positive.Count) { 'Pass' } elseif ($structural.Count) { 'StructuralOnly' } else { 'NotVerified' })
+ reason = $(if (-not $positive.Count -and -not $structural.Count) { 'No successful assertion for this exact API/flag variant; see command dispositions and fixture blockers.' } else { '' }) }
+ }
+ return @{
+ commands = $commands; apiVariants = $variants; totalCommands = $commands.Count
+ successfulPaths = @($commands | Where-Object status -eq 'Pass').Count
+ structuralOnly = @($commands | Where-Object status -eq 'StructuralOnly').Count
+ negativeOnly = @($commands | Where-Object status -eq 'NegativeOnly').Count
+ exhaustiveLiveCoverage = @($commands | Where-Object status -ne 'Pass').Count -eq 0 -and @($variants | Where-Object status -ne 'Pass').Count -eq 0
+ }
+}
+
+function Get-AbExitCode {
+ param([hashtable]$Context)
+ if ($Context.Manifest.cleanup.status -in @('Failed', 'Incomplete', 'Retained')) { return 3 }
+ $rootFailure = Get-AbValue $Context.Manifest 'failure' @{}
+ $rootKind = Get-AbValue $rootFailure 'kind' ''
+ if ($rootKind -eq 'Cleanup') { return 3 }
+ if ($rootKind -eq 'Invalid') { return 4 }
+ if ($rootKind -eq 'Fail') { return 1 }
+ if ($rootKind -in @('BlockedEnvironment', 'BlockedFixture')) { return 2 }
+ if ($Context.Mode -eq 'Cleanup' -and $Context.Manifest.cleanup.status -eq 'Complete') { return 0 }
+ $latest = @(Get-AbLatestCases $Context)
+ if ($rootKind -eq 'Fail' -or @($latest | Where-Object { $_.status -in @('Fail', 'Cleanup') }).Count) { return 1 }
+ if ($rootKind -in @('BlockedEnvironment', 'BlockedFixture') -or @($latest | Where-Object { $_.required -and $_.status -in @('BlockedEnvironment', 'BlockedFixture', 'Running') }).Count) { return 2 }
+ if (@($latest | Where-Object status -eq 'Invalid').Count) { return 4 }
+ if ($Context.Manifest.options.includeInvasiveDiagnostics -and
+ @($latest | Where-Object { $_.id -like '*.invasive-fixtures' -and $_.status -eq 'BlockedFixture' }).Count) { return 2 }
+ return 0
+}
+
+function Write-AbReport {
+ param([hashtable]$Context)
+ $coverage = Get-AbCoverage $Context
+ $exitCode = Get-AbExitCode $Context
+ $report = @{
+ schemaVersion = 1; runId = $Context.Manifest.runId; sourceCommit = $Context.Manifest.sourceCommit
+ subscription = $Context.Subscription; primaryRegion = $Context.PrimaryRegion; siloRegion = $Context.SiloRegion
+ mode = $Context.Mode; profile = $Context.Profile; operatingSystem = $Context.OperatingSystem; exitCode = $exitCode
+ cases = @(Get-AbLatestCases $Context); caseHistory = @($Context.Manifest.cases)
+ caseHistoryCount = $Context.Manifest.cases.Count
+ suites = @($Context.Manifest.suites | ForEach-Object {
+ @{ key = $_.key; operatingSystem = $_.operatingSystem; sku = $_.sku; cleanupComplete = $_.cleanupComplete
+ allocated = $_.groupCreateAttempted; originVisibility = Get-AbValue $_ 'originVisibility' }
+ })
+ cleanup = $Context.Manifest.cleanup; coverage = $coverage; failure = (Get-AbValue $Context.Manifest 'failure' @{})
+ generatedUtc = [DateTime]::UtcNow.ToString('o')
+ }
+ Write-AbJson (Join-Path $Context.RunDirectory 'results.json') $report
+ $summary = @(
+ '# Active Backup CLI validation'
+ ''
+ "Run: $($Context.Manifest.runId)"
+ "Mode: $($Context.Mode)"
+ "Profile: $($Context.Profile); OS: $($Context.OperatingSystem)"
+ "Target: $($Context.PrimaryRegion) -> $($Context.SiloRegion)"
+ "Source: $($Context.Manifest.sourceCommit)"
+ "Exit code: $exitCode"
+ "Cleanup: $($Context.Manifest.cleanup.status)"
+ "Successful command paths: $($coverage.successfulPaths)/$($coverage.totalCommands)"
+ "Structural-only: $($coverage.structuralOnly); negative-only: $($coverage.negativeOnly)"
+ "Exhaustive live coverage: $($coverage.exhaustiveLiveCoverage)"
+ ''
+ '| Case | Outcome | Reason |'
+ '|---|---|---|'
+ )
+ foreach ($case in @(Get-AbLatestCases $Context)) { $summary += "| $($case.id) | $($case.status) | $($case.reason) |" }
+ foreach ($suite in $Context.Manifest.suites) {
+ $observation = Get-AbValue $suite 'originVisibility'
+ if ($null -eq $observation) { continue }
+ $summary += @('', "## Origin visibility ($($suite.key))", '',
+ "Observation starts after creation completes: $($observation.startedUtc)",
+ "Window: $($observation.windowSeconds) seconds; first readable after: $($observation.firstVisibleAfterSeconds)",
+ '', '| UTC | Elapsed seconds | Origin listed | CLI exit | ARM exit | Secondary exit |',
+ '|---|---|---|---|---|---|')
+ foreach ($sample in $observation.samples) {
+ $summary += "| $($sample.observedUtc) | $($sample.elapsedSeconds) | $($sample.originListed) | $($sample.cliExitCode) | $($sample.armExitCode) | $($sample.secondaryExitCode) |"
+ }
+ }
+ $previousFailures = @($Context.Manifest.cases | Where-Object { $_.status -in @('Fail', 'Cleanup', 'Invalid') })
+ if ($Context.Mode -eq 'Cleanup' -and $previousFailures.Count) {
+ $summary += @('', '## Previous failed attempts', '',
+ 'The exit code above is for cleanup only; it does not erase earlier failures.',
+ '', '| Case | Outcome | Reason |', '|---|---|---|')
+ foreach ($case in $previousFailures) { $summary += "| $($case.id) | $($case.status) | $($case.reason) |" }
+ }
+ $failure = Get-AbValue $Context.Manifest 'failure' @{}
+ if (Get-AbValue $failure 'kind') { $summary += @('', 'Failure: ' + (Get-AbValue $failure 'reason')) }
+ if (@($Context.Manifest.cleanup.remaining).Count) {
+ $summary += @('', '## Remaining owned resources')
+ $summary += @($Context.Manifest.cleanup.remaining | ForEach-Object { '- ' + $_ })
+ }
+ [IO.File]::WriteAllText((Join-Path $Context.RunDirectory 'summary.md'), ($summary -join [Environment]::NewLine), [Text.UTF8Encoding]::new($false))
+ Save-AbManifest $Context
+ return $report
+}
+
+function Invoke-AbRun {
+ param([hashtable]$Context, [switch]$KeepResourcesOnFailure)
+ if ($Context.Mode -eq 'Resume') {
+ $unsafeCases = @(Get-AbLatestCases $Context | Where-Object {
+ $_.status -ne 'Pass' -and @($_.calls | Where-Object { (Get-AbValue $_ 'mutationReplay') -eq 'NoReplay' }).Count
+ })
+ if ($unsafeCases.Count) { Stop-Ab 'A non-idempotent action in an incomplete case was already attempted. It was not replayed; inspect or clean up this run.' BlockedFixture }
+ if ($null -ne $Context.Manifest.pendingMutation) {
+ foreach ($suite in $Context.Manifest.suites) {
+ if ($suite.groupCreateAttempted -and -not $suite.cleanupComplete) { Assert-AbOwnedGroup $Context $suite | Out-Null }
+ }
+ if ($Context.Manifest.pendingMutation.replay -eq 'NoReplay') {
+ Stop-Ab 'A previous non-idempotent action has an uncertain outcome. It was not replayed. Use ownership-checked Cleanup or investigate the recorded case.' BlockedFixture
+ }
+ }
+ if ($Context.Manifest.suites.Count -and @($Context.Manifest.suites | Where-Object { -not $_.cleanupComplete }).Count -eq 0) {
+ Stop-Ab 'Resume will not recreate already cleaned fixtures. Start a new run for additional coverage.' Invalid
+ }
+ }
+ if (-not $Context.Manifest.suites.Count) {
+ $systems = $(if ($Context.OperatingSystem -eq 'Both') { @('Windows', 'Linux') } else { @($Context.OperatingSystem) })
+ $skus = $(if ($Context.Manifest.options.includeSkuMatrix) { @('P1V3', 'P1MV3', 'P1V4', 'P1MV4') } else { @('P1V3') })
+ foreach ($os in $systems) { foreach ($sku in $skus) { $Context.Manifest.suites += @(New-AbSuite $Context $os $sku) } }
+ Save-AbManifest $Context
+ }
+ $Context.Manifest.state = 'Running'
+ Save-AbManifest $Context
+ foreach ($suite in $Context.Manifest.suites) {
+ if ($suite.cleanupComplete) { continue }
+ $failed = $false
+ try {
+ if ($Context.Profile -eq 'OriginVisibility') {
+ Invoke-AbSmoke $Context $suite -ProvisionOnly
+ Invoke-AbOriginVisibility $Context $suite
+ }
+ else {
+ Invoke-AbSmoke $Context $suite
+ if ($Context.Profile -ne 'Smoke') { Invoke-AbCore $Context $suite }
+ }
+ }
+ catch {
+ $failed = $true
+ Write-Warning $(if ($_.Exception.Data.Contains('AbSafeMessage')) { $_.Exception.Data['AbSafeMessage'] } else { 'A local operation failed; raw output was withheld.' })
+ }
+ finally {
+ $anyFailure = $failed -or @(Get-AbLatestCases $Context | Where-Object { $_.status -in @('Fail', 'BlockedEnvironment', 'Invalid') }).Count -gt 0
+ if ($KeepResourcesOnFailure -and $anyFailure) {
+ $Context.Manifest.cleanup.status = 'Retained'
+ $Context.Manifest.cleanup.remaining = @($Context.Manifest.suites | Where-Object { $_.groupCreateAttempted -and -not $_.cleanupComplete } | ForEach-Object groupId)
+ Save-AbManifest $Context
+ }
+ else { Remove-AbOwnedResources $Context $suite }
+ }
+ Write-AbReport $Context | Out-Null
+ if ($failed -or $Context.Manifest.cleanup.status -eq 'Retained') { break }
+ }
+ $Context.Manifest.state = $(if ((Get-AbExitCode $Context) -eq 0) { 'Complete' } else { 'Incomplete' })
+ Save-AbManifest $Context
+ return Write-AbReport $Context
+}
+
+Export-ModuleMember -Function *-Ab*
diff --git a/scripts/appservice/README.md b/scripts/appservice/README.md
new file mode 100644
index 00000000000..cbfa87ca176
--- /dev/null
+++ b/scripts/appservice/README.md
@@ -0,0 +1,311 @@
+# Test the local Active Backup CLI
+
+This PowerShell 7.4+ driver runs the **CLI from this checkout**, not the `az` found
+on PATH. It generates disposable app content, records exact resource ownership,
+and tests the new commands against a selected subscription.
+
+**Nothing runs live by default.** `Plan` does not contact Azure or create runtime
+files. `Preflight` is read-only. `Run` needs explicit creation/cleanup flags and
+interactive confirmation. No provider/feature registration, account switching,
+automatic login, package installation, or PR update is performed.
+
+## What is implemented
+
+| Profile | Coverage |
+|---|---|
+| Smoke | Primary fixture, plan/site silo creation, no-wait operation inspection/wait, show/list, reference ARM reads, actual HTTPS/config markers, accepted refresh, and ordered teardown. |
+| Core | Smoke plus output/ID checks, worker/zone-false overrides, traffic/endpoint control, networking, LKG/content propagation, runtime actions, safe operational reads, and specific error cases. |
+| Extended | Core plus additional service-availability reads, network checks and reference refresh; optional serial SKU and zone testing. |
+| OriginVisibility | Only primary/plan-silo/site-silo provisioning, followed by bounded origin-read sampling and owned cleanup. No deployment, refresh, traffic updates, or other Core mutations. |
+
+All current command and API-variant identifiers appear in the report. Some point
+reads need a real returned fixture identifier. Invasive process/container/trace
+tests do **not** run against an arbitrary worker. `-IncludeInvasiveDiagnostics`
+currently reports a fixture blocker rather than sending those actions. Passing
+Core is not a claim that every CLI path has been exercised live.
+
+This script has offline fake-service tests. The target subscription, API rollout,
+capacity and actual live behavior still require an authorized run.
+
+## 1. Select an interpreter
+
+Run from the repository root in PowerShell 7.4 or newer (.NET 8+):
+
+```powershell
+pwsh -NoProfile
+Set-Location C:\path\to\azure-cli
+
+$subscription = ''
+$python = (Resolve-Path .\env\Scripts\python.exe).Path
+```
+
+Use an existing repository CLI development environment prepared according to
+[`doc\configuring_your_machine.md`](../../doc/configuring_your_machine.md).
+The harness does not prepare one automatically.
+
+On a machine with the Windows Azure CLI installed, its embedded interpreter can
+be selected explicitly as a local-development fallback:
+
+```powershell
+$python = 'C:\Program Files\Microsoft SDKs\Azure\CLI2\python.exe'
+if (-not (Test-Path -LiteralPath $python)) {
+ throw 'Select a Python executable that already has Azure CLI dependencies.'
+}
+```
+
+The driver puts repository CLI/core sources before installed packages and checks
+the actual import paths and every new command's help. It refuses an interpreter
+that loads a different CLI or cannot import the required dependencies. This
+checks source selection, not that an installed CLI's dependency versions exactly
+match the repository requirements; a configured development venv is preferable
+for release-style validation. Plain system Python usually lacks CLI dependencies.
+
+## 2. Check the harness offline and inspect the plan
+
+```powershell
+pwsh -NoProfile -File .\scripts\appservice\tests\Test-ActiveBackupHarness.ps1
+
+pwsh -NoProfile -File .\scripts\appservice\test_active_backup.ps1 `
+ -Mode Plan -Subscription $subscription `
+ -PrimaryRegion eastus2euap -SiloRegion centraluseuap
+```
+
+The offline tests use synthetic service responses and child Python processes.
+They do not contact Azure. `-WhatIf` also forces Plan behavior, even with `-Mode Run`.
+To include a real local CLI import/help probe without Azure access, add
+`-CliPythonExe $python` to the offline test command.
+
+Defaults are East US 2 EUAP (`eastus2euap`) as primary and Central US EUAP
+(`centraluseuap`) as secondary. Supply the intended subscription explicitly;
+it is not hard-coded in the checked-in script.
+
+## 3. Log in using an isolated local CLI cache
+
+This is a manual operator step. The following block changes only temporary
+environment values, restores them afterward, and writes authentication data only
+under the ignored `.x\active-backup\auth` directory. It does not change your normal
+CLI account selection or copy tokens from another account.
+
+```powershell
+Import-Module .\scripts\appservice\ActiveBackupTestHarness.psm1 -Force -DisableNameChecking
+$repo = (Get-Location).Path
+$auth = Assert-AbLocalPath $repo '.x\active-backup\auth' -RequireIgnored
+$extensions = Assert-AbLocalPath $repo '.x\active-backup\login-extensions' -RequireIgnored
+$temp = Assert-AbLocalPath $repo '.x\active-backup\login-temp' -RequireIgnored
+foreach ($directory in @($auth, $extensions, $temp)) {
+ Protect-AbDirectory $directory
+}
+
+$changes = @{
+ PYTHONPATH = "$repo\src\azure-cli;$repo\src\azure-cli-core"
+ PYTHONDONTWRITEBYTECODE = '1'
+ AZURE_CONFIG_DIR = $auth
+ AZURE_EXTENSION_DIR = $extensions
+ AZURE_EXTENSION_USE_DYNAMIC_INSTALL = 'no'
+ AZURE_CORE_COLLECT_TELEMETRY = 'false'
+ TEMP = $temp
+ TMP = $temp
+}
+$old = @{}
+try {
+ foreach ($key in $changes.Keys) {
+ $old[$key] = [Environment]::GetEnvironmentVariable($key, 'Process')
+ [Environment]::SetEnvironmentVariable($key, $changes[$key], 'Process')
+ }
+ & $python -m azure.cli login
+ if ($LASTEXITCODE -ne 0) { throw 'Azure login failed.' }
+ & $python -m azure.cli account show --subscription $subscription `
+ --query '{id:id,name:name,state:state,tenantId:tenantId}' --output json
+ if ($LASTEXITCODE -ne 0) { throw 'The account cannot access the requested subscription.' }
+}
+finally {
+ foreach ($key in $old.Keys) {
+ if ($null -eq $old[$key]) {
+ if (Test-Path -LiteralPath "Env:\$key") {
+ Remove-Item -LiteralPath "Env:\$key"
+ }
+ }
+ else {
+ [Environment]::SetEnvironmentVariable($key, $old[$key], 'Process')
+ }
+ }
+}
+```
+
+If tenant selection is necessary, add your tenant to the manual login command.
+Do not paste access tokens into parameters, logs, or GitHub. GitHub authentication
+for the PR is separate from Azure authentication for these tests.
+
+## 4. Run read-only preflight
+
+```powershell
+pwsh -NoProfile -File .\scripts\appservice\test_active_backup.ps1 `
+ -Mode Preflight -PythonExe $python -Subscription $subscription `
+ -PrimaryRegion eastus2euap -SiloRegion centraluseuap `
+ -OperatingSystem Windows -Profile Smoke
+```
+
+Preflight checks source provenance/help, the exact account/cloud/regions,
+Microsoft.Web registration, new resource API metadata for `2026-08-01`, and
+regional P1V3 discovery. Core also checks Microsoft.Network registration; Linux
+checks the `PYTHON:3.12` fixture runtime.
+
+You need permission to create/delete resource groups, App Service plans/apps and
+silos, deploy the fixture, and optionally create/update the owned VNet/subnet.
+Regional discovery does not guarantee allocation capacity or Active Backup
+enablement. If API/feature/region/SKU metadata is missing, resolve it with the
+service/subscription owner; the driver does not downgrade or enable it for you.
+
+An unauthenticated cache or unavailable prerequisite returns nonzero before any
+resource allocation. Review `summary.md`/`results.json` at the printed path.
+
+## 5. Start with a Windows Smoke run
+
+**This allocates billable resources.** Confirm the subscription and regions
+shown before agreeing to the prompt:
+
+```powershell
+pwsh -NoProfile -File .\scripts\appservice\test_active_backup.ps1 `
+ -Mode Run -PythonExe $python -Subscription $subscription `
+ -PrimaryRegion eastus2euap -SiloRegion centraluseuap `
+ -Profile Smoke -OperatingSystem Windows `
+ -AllowResourceCreation -AllowCleanup
+```
+
+The baseline is one P1V3 primary worker and one secondary worker. Groups and app
+names are unique to the run; existing groups are never adopted. Cleanup deletes
+site silos before plan silos and refuses group deletion if ownership or remaining
+dependencies cannot be verified.
+
+For explicitly approved automation, `-Confirm:$false` suppresses the interactive
+prompt; it does not bypass the creation/cleanup flags or ownership checks.
+
+## 6. Expand to Core, then optional profiles
+
+After Smoke and its cleanup succeed:
+
+```powershell
+pwsh -NoProfile -File .\scripts\appservice\test_active_backup.ps1 `
+ -Mode Run -PythonExe $python -Subscription $subscription `
+ -PrimaryRegion eastus2euap -SiloRegion centraluseuap `
+ -Profile Core -OperatingSystem Both `
+ -AllowResourceCreation -AllowCleanup
+```
+
+Windows and Linux run sequentially, with cleanup between suites. Core can
+temporarily increase secondary workers from one to two. Linux needs service-side
+enablement; the client is not changed to hide unsupported calls.
+
+The Windows fixture includes an IIS `web.config` mapping for `.json` so its
+version marker does not depend on the worker's default MIME mappings.
+The Linux fixture serves generated static files using `PYTHON:3.12` and a stdlib
+HTTP server on port 8000. No package restore, external sample repository, real
+database, registry credential or Key Vault is needed for the baseline.
+
+Use `-Profile Extended -IncludeSkuMatrix` only after approving the additional
+serial P1V3/P1MV3/P1V4/P1MV4 allocations. `-IncludeZoneRedundancy` also requires
+Extended and a two-worker budget. Zone availability remains service-enforced.
+The script does not claim that its runtime/diagnostic inventory is exhaustive.
+
+## Isolate origin visibility after creation
+
+Use this profile to measure whether origin becomes readable after the create
+operation succeeds, without first changing workers, traffic, or site policy:
+
+```powershell
+pwsh -NoProfile -File .\scripts\appservice\test_active_backup.ps1 `
+ -Mode Run -PythonExe $python -Subscription $subscription `
+ -PrimaryRegion eastus2euap -SiloRegion centraluseuap `
+ -Profile OriginVisibility -OperatingSystem Windows -MaxWorkersPerPlan 1 `
+ -ObservationTimeoutSeconds 900 -AllowResourceCreation -AllowCleanup
+```
+
+It allocates one Windows P1V3 primary worker and one secondary worker.
+The observation window starts after successful silo creation. Samples are
+scheduled about 30 seconds apart and compare the origin collection entry,
+CLI point GET, raw public ARM point GET at the same API version, and secondary
+point GET. Only expected not-found responses are observed again; other errors
+fail explicitly. The first successful CLI/ARM/control sample ends the probe.
+The JSON report and summary retain sample times and allowlisted identities/states.
+Not appearing within the selected window does not prove origin will never appear.
+No production CLI retry or waiting behavior is changed.
+
+## Results and recovery
+
+Each run writes protected local files:
+
+```text
+.x\active-backup\runs\\
+ manifest.json
+ events.jsonl
+ results.json
+ summary.md
+ fixtures\
+ inputs\
+ downloads\
+```
+
+Reports contain case outcomes and a command/API coverage ledger, not raw
+credential-bearing response bodies. Empty list reads are structural coverage;
+missing point-read fixtures are blockers, not passes. Refresh acceptance is
+tested separately from later HTTPS/config/revision observations.
+Create/update journal entries retain only allowlisted CLI-result identity and
+state fields, including null IDs; they do not record raw HTTP bodies or headers.
+Command events also record `originCollectionFallback` when the CLI emits its
+origin-list compatibility warning. The OriginVisibility profile still requires
+the raw ARM point read to succeed; a CLI collection fallback alone is not a pass.
+Recovery reports retain the full case history. A successful Cleanup exit code
+only confirms cleanup; earlier failures are also shown in the summary.
+
+| Exit | Meaning |
+|---|---|
+| 0 | Selected required checks and normal cleanup completed, or explicit Cleanup completed. Read coverage separately. |
+| 1 | An executed assertion/command failed. |
+| 2 | A required environment/fixture prerequisite is blocked. |
+| 3 | Cleanup failed, resources were retained, or ownership/dependencies could not be resolved. |
+| 4 | Invalid arguments, source/target mismatch, corrupt manifest, or local setup error. |
+
+The script prints an exact cleanup command naming the manifest. Use it after
+an interrupted/retained run, for example:
+
+```powershell
+pwsh -NoProfile -File .\scripts\appservice\test_active_backup.ps1 `
+ -Mode Cleanup -PythonExe $python -Subscription $subscription `
+ -Manifest '.x\active-backup\runs\\manifest.json' -AllowCleanup
+```
+
+Replace `` with the printed run; never delete groups by a name
+prefix. Cleanup revalidates the actual group tags, subscription and resource IDs
+and waits for absence. A source change prevents Resume but does not require
+using a stale/broken interpreter for ownership-checked Cleanup.
+Some enterprise subscriptions add policy-generated NRMS NSGs during VNet creation.
+If these lack the run's tags/journal entries, automatic cleanup intentionally
+refuses group deletion. Review exact IDs and attachments and obtain explicit
+operator approval for those resources; do not bypass the guard by accepting an
+`NRMS-*` prefix or adopting every resource in the group.
+Resume/Cleanup select an explicit `-PythonExe` or a repository venv; an executable
+path embedded in a manifest is never automatically trusted for execution.
+
+`-KeepResourcesOnFailure` explicitly retains potentially billable resources for
+diagnosis. It does not produce a passing cleanup result. Resume requires the same
+Git commit and CLI/harness source fingerprints:
+
+```powershell
+pwsh -NoProfile -File .\scripts\appservice\test_active_backup.ps1 `
+ -Mode Resume -PythonExe $python -Subscription $subscription `
+ -Manifest '.x\active-backup\runs\\manifest.json' `
+ -AllowResourceCreation -AllowCleanup
+```
+
+Resume does not recreate cleaned fixtures or blindly replay uncertain deployment,
+refresh, mark-LKG, or other non-idempotent actions. In those cases, investigate or
+clean up and start a fresh run.
+
+The operation, observation, run and cleanup timeout parameters bound orchestration,
+not billing. A host/power failure can leave resources until the recovery command
+is run; expiry tags are not an automatic reaper.
+
+Do not commit or automatically upload the auth cache, dumps, downloaded logs,
+generated bodies or raw local output. Review reports before sharing. This driver
+does not generate VCR recordings or replace the feature PR's full `azdev` release
+validation.
diff --git a/scripts/appservice/test_active_backup.ps1 b/scripts/appservice/test_active_backup.ps1
new file mode 100644
index 00000000000..58fd21b2c13
--- /dev/null
+++ b/scripts/appservice/test_active_backup.ps1
@@ -0,0 +1,150 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#requires -Version 7.4
+
+[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
+param(
+ [ValidateSet('Plan', 'Preflight', 'Run', 'Resume', 'Cleanup')][string]$Mode = 'Plan',
+ [Parameter(Mandatory)][string]$Subscription,
+ [string]$PrimaryRegion = 'eastus2euap',
+ [string]$SiloRegion = 'centraluseuap',
+ [string]$PythonExe,
+ [ValidateSet('Windows', 'Linux', 'Both')][string]$OperatingSystem = 'Both',
+ [ValidateSet('Smoke', 'Core', 'Extended', 'OriginVisibility')][string]$Profile = 'Core',
+ [string]$RunId,
+ [string]$Manifest,
+ [string]$OutputRoot = '.x\active-backup\runs',
+ [string]$AuthConfigDir = '.x\active-backup\auth',
+ [switch]$AllowResourceCreation,
+ [switch]$AllowCleanup,
+ [switch]$KeepResourcesOnFailure,
+ [switch]$IncludeInvasiveDiagnostics,
+ [switch]$IncludeSkuMatrix,
+ [switch]$IncludeZoneRedundancy,
+ [ValidateRange(1, 7200)][int]$OperationTimeoutSeconds = 1800,
+ [ValidateRange(1, 7200)][int]$ObservationTimeoutSeconds = 900,
+ [ValidateRange(1, 480)][int]$RunTimeoutMinutes = 120,
+ [ValidateRange(1, 120)][int]$CleanupTimeoutMinutes = 30,
+ [ValidateRange(1, 2)][int]$MaxWorkersPerPlan = 2
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+Import-Module (Join-Path $PSScriptRoot 'ActiveBackupTestHarness.psm1') -Force -DisableNameChecking
+$context = $null
+$exitCode = 0
+$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
+try {
+ if ($WhatIfPreference) { $Mode = 'Plan' }
+ if ($Mode -in @('Run', 'Resume') -and (-not $AllowResourceCreation -or -not $AllowCleanup)) {
+ Stop-Ab 'Run/Resume requires both -AllowResourceCreation and -AllowCleanup. Plan/Preflight do not allocate resources.' Invalid
+ }
+ if ($Mode -eq 'Cleanup' -and -not $AllowCleanup) { Stop-Ab 'Cleanup requires -AllowCleanup and the owned run manifest.' Invalid }
+ if (($IncludeSkuMatrix -or $IncludeInvasiveDiagnostics -or $IncludeZoneRedundancy) -and $Profile -ne 'Extended') {
+ Stop-Ab 'Extra SKU/zone/diagnostic authorization is only supported with -Profile Extended.' Invalid
+ }
+ if ($IncludeZoneRedundancy -and $MaxWorkersPerPlan -lt 2) { Stop-Ab 'Zone testing requires a two-worker budget.' Invalid }
+ if ($Mode -in @('Resume', 'Cleanup')) {
+ if (-not $Manifest) { Stop-Ab 'Resume/Cleanup requires -Manifest pointing to the exact owned run.' Invalid }
+ $context = Read-AbManifest $repo $Manifest $Subscription $Mode
+ $context.PythonExe = Resolve-AbPython $repo $PythonExe
+ foreach ($name in @('PrimaryRegion', 'SiloRegion', 'Profile', 'OperatingSystem')) {
+ if ($PSBoundParameters.ContainsKey($name) -and (Get-Variable -Name $name -ValueOnly) -ne $context[$name]) {
+ Stop-Ab 'Resume/Cleanup target and profile must match the original manifest.' Invalid
+ }
+ }
+ }
+ else {
+ $context = New-AbContext -RepoRoot $repo -Subscription $Subscription -PrimaryRegion $PrimaryRegion `
+ -SiloRegion $SiloRegion -PythonExe $PythonExe -Profile $Profile -OperatingSystem $OperatingSystem `
+ -OutputRoot $OutputRoot -AuthConfigDir $AuthConfigDir -RunId $RunId -Mode $Mode `
+ -OperationTimeoutSeconds $OperationTimeoutSeconds -ObservationTimeoutSeconds $ObservationTimeoutSeconds `
+ -RunTimeoutMinutes $RunTimeoutMinutes -CleanupTimeoutMinutes $CleanupTimeoutMinutes -MaxWorkersPerPlan $MaxWorkersPerPlan `
+ -IncludeSkuMatrix ([bool]$IncludeSkuMatrix) -IncludeInvasiveDiagnostics ([bool]$IncludeInvasiveDiagnostics) `
+ -IncludeZoneRedundancy ([bool]$IncludeZoneRedundancy)
+ }
+ Write-Host "Mode: $Mode; profile: $($context.Profile); OS: $($context.OperatingSystem)"
+ Write-Host "Subscription: $($context.Subscription)"
+ Write-Host "Primary: $($context.PrimaryRegion); silo: $($context.SiloRegion); API: $($context.ApiVersion)"
+ Write-Host "Source commit: $($context.Manifest.sourceCommit)"
+ Write-Host "Command inventory: $($context.Catalog.commands.Count); API variants: $($context.Catalog.variants.Count)"
+ Write-Host 'Baseline: one P1V3 primary worker plus one secondary worker; OS/SKU suites are serial.'
+ Write-Host "Capacity ceiling per plan: $($context.Manifest.options.maxWorkersPerPlan); additional SKU matrix: $($context.Manifest.options.includeSkuMatrix)"
+ Write-Host 'EUAP is not assumed free. Run timeout does not stop Azure billing after a host failure.'
+ if ($Mode -eq 'Plan') {
+ Write-Host "Planned manifest: $($context.ManifestPath)"
+ $systems = $(if ($context.OperatingSystem -eq 'Both') { @('Windows', 'Linux') } else { @($context.OperatingSystem) })
+ $skus = $(if ($context.Manifest.options.includeSkuMatrix) { @('P1V3', 'P1MV3', 'P1V4', 'P1MV4') } else { @('P1V3') })
+ foreach ($system in $systems) {
+ foreach ($sku in $skus) {
+ $suite = New-AbSuite $context $system $sku
+ Write-Host "Planned suite: $($suite.key); group: $($suite.group); plan: $($suite.plan); app: $($suite.app)"
+ }
+ }
+ Write-Host 'Plan makes no Azure calls and creates no runtime files. Prepare the CLI interpreter/auth cache before Preflight.'
+ if ($context.Profile -eq 'OriginVisibility') {
+ Write-Host "OriginVisibility: provision primary/plan silo/site silo, then observe origin for $($context.Manifest.options.observationTimeoutSeconds) seconds after creation completes."
+ Write-Host 'Compare list, CLI origin GET, raw ARM origin GET and secondary GET. No deployment, refresh, traffic or Core mutations.'
+ }
+ else {
+ Write-Host 'Smoke: primary fixture -> plan silo -> site silo -> real marker/config checks -> accepted refresh -> owned cleanup.'
+ Write-Host 'Core: Smoke + outputs/IDs, overrides, traffic/LKG, networking, safe reads, replication and negative cases.'
+ Write-Host 'Extended: Core + optional reads/network checks. Unconfigured point/invasive fixtures remain explicitly incomplete.'
+ }
+ }
+ else {
+ if ($Mode -eq 'Cleanup' -and -not $PSCmdlet.ShouldProcess($context.Subscription, 'Delete only manifest-owned Active Backup test resources')) { return }
+ Initialize-AbLocalState $context -Existing:($Mode -in @('Resume', 'Cleanup'))
+ Test-AbSource $context
+ $context.Manifest.Remove('failure')
+ Save-AbManifest $context
+ if ($Mode -eq 'Cleanup') {
+ Invoke-AbTargetCheck $context
+ Remove-AbOwnedResources $context
+ Write-AbReport $context | Out-Null
+ }
+ else {
+ Invoke-AbPreflight $context
+ if ($Mode -eq 'Preflight') {
+ $context.Manifest.state = 'PreflightComplete'
+ Write-AbReport $context | Out-Null
+ }
+ elseif ($PSCmdlet.ShouldProcess($context.Subscription, 'Allocate billable disposable fixtures and clean up their exact owned resource groups')) {
+ Invoke-AbRun $context -KeepResourcesOnFailure:$KeepResourcesOnFailure | Out-Null
+ }
+ }
+ $exitCode = Get-AbExitCode $context
+ Write-Host "Results: $(Join-Path $context.RunDirectory 'results.json')"
+ Write-Host "Summary: $(Join-Path $context.RunDirectory 'summary.md')"
+ }
+}
+catch {
+ $kind = $(if ($_.Exception.Data.Contains('AbKind')) { $_.Exception.Data['AbKind'] } else { 'Invalid' })
+ $exitCode = switch ($kind) { 'Cleanup' { 3 } 'BlockedEnvironment' { 2 } 'BlockedFixture' { 2 } 'Fail' { 1 } default { 4 } }
+ Write-Warning $(if ($_.Exception.Data.Contains('AbSafeMessage')) { $_.Exception.Data['AbSafeMessage'] } else { 'Unexpected local failure. Raw output was withheld to avoid persisting credentials.' })
+ if ($context -and (Test-Path -LiteralPath $context.ManifestPath)) {
+ $context.Manifest.failure = @{
+ kind = $kind
+ reason = $(if ($_.Exception.Data.Contains('AbSafeMessage')) { $_.Exception.Data['AbSafeMessage'] } else { 'Unexpected local failure; raw details withheld.' })
+ }
+ try {
+ $report = Write-AbReport $context
+ $exitCode = $report.exitCode
+ }
+ catch { Write-Warning 'Unable to finalize the local report; preserve the existing manifest.' }
+ }
+}
+finally {
+ if ($context) {
+ if ($context.Lock) { $context.Lock.Dispose() }
+ if ($context.Mode -ne 'Plan' -and (Test-Path -LiteralPath $context.ManifestPath)) {
+ $relative = [IO.Path]::GetRelativePath($repo, $context.ManifestPath)
+ Write-Host "Recovery manifest: $relative"
+ $scriptPath = (Join-Path $PSScriptRoot 'test_active_backup.ps1').Replace("'", "''")
+ $manifestPath = $context.ManifestPath.Replace("'", "''")
+ $interpreter = ([string]$context.PythonExe).Replace("'", "''")
+ Write-Host ("Cleanup: pwsh -NoProfile -File '{0}' -Mode Cleanup -PythonExe '{1}' -Subscription {2} -Manifest '{3}' -AllowCleanup" -f $scriptPath, $interpreter, $context.Subscription, $manifestPath)
+ }
+ }
+}
+exit $exitCode
diff --git a/scripts/appservice/tests/Test-ActiveBackupHarness.ps1 b/scripts/appservice/tests/Test-ActiveBackupHarness.ps1
new file mode 100644
index 00000000000..bb260d9de85
--- /dev/null
+++ b/scripts/appservice/tests/Test-ActiveBackupHarness.ps1
@@ -0,0 +1,991 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#requires -Version 7.4
+
+param([string]$CliPythonExe)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+Import-Module (Join-Path $PSScriptRoot '..\ActiveBackupTestHarness.psm1') -Force -DisableNameChecking
+$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..'))
+$subscription = '00000000-0000-0000-0000-000000000001'
+$python = (Get-Command python -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
+$testId = [guid]::NewGuid().ToString('N')
+$testRoot = Join-Path $repo ('.x\active-backup\tests\' + $testId)
+$contexts = [Collections.Generic.List[object]]::new()
+$script:passed = 0
+$script:failed = 0
+
+function Test-Case {
+ param([string]$Name, [scriptblock]$Body)
+ try {
+ & $Body
+ $script:passed++
+ Write-Host "PASS $Name"
+ }
+ catch {
+ $script:failed++
+ Write-Host "FAIL $Name"
+ Write-Host $_.Exception.Message
+ if ($_.Exception.InnerException) { Write-Host $_.Exception.InnerException.Message }
+ Write-Host $_.ScriptStackTrace
+ }
+}
+
+function Assert-Test {
+ param([bool]$Condition, [string]$Message = 'Assertion failed.')
+ if (-not $Condition) { throw $Message }
+}
+
+function Assert-Throws {
+ param([scriptblock]$Body, [string]$Pattern = '.')
+ $caught = $false
+ try { & $Body | Out-Null } catch {
+ $caught = $true
+ if ($_.Exception.Message -notmatch $Pattern) { throw "Wrong error: $($_.Exception.Message)" }
+ }
+ if (-not $caught) { throw 'Expected an exception.' }
+}
+
+function New-TestContext {
+ param([string]$Profile = 'Smoke', [string]$Mode = 'Run')
+ $context = New-AbContext -RepoRoot $repo -Subscription $subscription -PythonExe $python `
+ -Profile $Profile -OperatingSystem Windows -Mode $Mode `
+ -OutputRoot (Join-Path $testRoot ([guid]::NewGuid().ToString('N'))) -AuthConfigDir (Join-Path $testRoot 'auth') `
+ -OperationTimeoutSeconds 10 -ObservationTimeoutSeconds 2
+ $contexts.Add($context)
+ Initialize-AbLocalState $context
+ return $context
+}
+
+function New-FakeResponse {
+ param($Payload = $null, [int]$ExitCode = 0, [string]$ErrorText = '')
+ return @{
+ exitCode = $ExitCode; stdout = $(if ($null -ne $Payload) { ConvertTo-Json -InputObject $Payload -Depth 40 -Compress } else { '' })
+ stderr = $ErrorText; elapsedMs = 1; timedOut = $false
+ }
+}
+
+function Get-FakeArgument {
+ param([string[]]$Arguments, [string]$Flag, $Default = $null)
+ $index = [array]::IndexOf($Arguments, $Flag)
+ if ($index -ge 0 -and $index + 1 -lt $Arguments.Count) { return $Arguments[$index + 1] }
+ return $Default
+}
+
+function Set-FakeAzure {
+ param([hashtable]$Context, [hashtable]$Suite)
+ $state = @{
+ groupExists = $false; planExists = $false; appExists = $false; planSilo = $false; siteSilo = $false
+ primaryGeneration = 'v1'; siloGeneration = 'v1'; followLkg = $false; revision = 1; lkg = 1
+ workers = 1; zone = $false; endpoint = 'Enabled'; origin = 'Enabled'; subnet = ''
+ mode = 'Active'; resources = @(); calls = @(); callsAfterDelete = @(); dummyConnection = ''
+ endpoints = @(@{ region = $Context.PrimaryRegion; weight = 1; priority = 1 },
+ @{ region = $Context.SiloRegion; weight = 1; priority = 2 })
+ }
+ $ctx = $Context
+ $s = $Suite
+ $Context.Executor = {
+ param([string[]]$Arguments)
+ $joined = $Arguments -join ' '
+ $state.calls += @($joined)
+ $id = Get-FakeArgument $Arguments '--ids'
+ $groupTags = @{
+ abcliRunId = $ctx.Manifest.runId; abcliOwner = $ctx.Manifest.owner
+ abcliPurpose = 'local-cli-validation'; abcliCommit = $ctx.Manifest.sourceCommit
+ }
+ $planResource = @{
+ id = $s.planSiloId; name = $s.planSilo; type = 'Microsoft.Web/serverfarms/silos'; location = $ctx.SiloRegion
+ properties = @{ provisioningState = 'Succeeded'; primaryLocation = $ctx.PrimaryRegion
+ instanceNumberOverride = $state.workers; zoneRedundantOverride = $state.zone }
+ }
+ $siteResource = @{
+ id = $s.siteSiloId; name = $s.siteSilo; type = 'Microsoft.Web/sites/silos'; location = $ctx.SiloRegion
+ properties = @{ provisioningState = 'Succeeded'; primaryLocation = $ctx.PrimaryRegion
+ revisionNumber = $state.revision; followLastKnownGood = $state.followLkg
+ endpointStatus = $state.endpoint; defaultHostName = 'silo.example.test'; monitorStatus = 'Healthy' }
+ }
+ $originResource = @{
+ id = $s.appId + '/silos/origin'; name = 'origin'; type = 'Microsoft.Web/sites/silos'; location = $ctx.PrimaryRegion
+ properties = @{ endpointStatus = $state.origin; provisioningState = 'Succeeded' }
+ }
+ if ($joined.StartsWith('account show ')) { return New-FakeResponse @{ id = $ctx.Subscription; state = 'Enabled'; tenantId = 'test-tenant' } }
+ if ($joined.StartsWith('cloud show ')) {
+ if ($Arguments -contains '--subscription') { return New-FakeResponse -ExitCode 2 -ErrorText 'ERROR: unrecognized arguments: --subscription' }
+ return New-FakeResponse @{ name = 'AzureCloud'; endpoints = @{ resourceManager = 'https://management.azure.com/' } }
+ }
+ if ($joined.StartsWith('provider show ')) {
+ return New-FakeResponse @{ registrationState = 'Registered'; resourceTypes = @(
+ @('serverFarms/silos', 'sites/silos', 'sites/managedTrafficConfig', 'sites/lastKnownGood') | ForEach-Object {
+ @{ resourceType = $_; apiVersions = @('2026-08-01') }
+ }) }
+ }
+ if ($joined.StartsWith('appservice list-locations ')) { return New-FakeResponse @(@{ name = $ctx.PrimaryRegion }, @{ name = $ctx.SiloRegion }) }
+ if ($joined.StartsWith('webapp list-runtimes ')) { return New-FakeResponse -Payload @('PYTHON:3.12') }
+ if ($joined.StartsWith('group exists ')) { return New-FakeResponse $state.groupExists }
+ if ($joined.StartsWith('group create ')) { $state.groupExists = $true; return New-FakeResponse @{ id = $s.groupId; tags = $groupTags } }
+ if ($joined.StartsWith('group show ')) {
+ if (-not $state.groupExists) { return New-FakeResponse -ExitCode 3 -ErrorText 'ERROR: (ResourceNotFound) Not found.' }
+ return New-FakeResponse @{ id = $s.groupId; tags = $groupTags }
+ }
+ if ($joined.StartsWith('group delete ')) { $state.groupExists = $false; return New-FakeResponse }
+ if ($joined.StartsWith('appservice plan create ')) {
+ $state.planExists = $true; $state.resources += @(@{ id = $s.planId })
+ return New-FakeResponse @{ id = $s.planId }
+ }
+ if ($joined.StartsWith('webapp create ')) {
+ $state.appExists = $true; $state.resources += @(@{ id = $s.appId })
+ return New-FakeResponse @{ id = $s.appId; defaultHostName = 'primary.example.test' }
+ }
+ if ($joined.StartsWith('webapp config appsettings set ')) { return New-FakeResponse @{ properties = @{} } }
+ if ($joined.StartsWith('webapp config connection-string set ')) {
+ $state.dummyConnection = (Get-FakeArgument $Arguments '--settings').Substring('ABCLI_DUMMY='.Length)
+ return New-FakeResponse @{ properties = @{} }
+ }
+ if ($joined.StartsWith('webapp deploy ')) {
+ $zip = [IO.Compression.ZipFile]::OpenRead((Get-FakeArgument $Arguments '--src-path'))
+ try {
+ $reader = [IO.StreamReader]::new($zip.GetEntry('version.json').Open())
+ try { $marker = ConvertFrom-Json -InputObject $reader.ReadToEnd() -AsHashtable } finally { $reader.Dispose() }
+ }
+ finally { $zip.Dispose() }
+ $state.primaryGeneration = $marker.generation
+ if (-not $state.followLkg) { $state.siloGeneration = $marker.generation; $state.revision++ }
+ return New-FakeResponse @{ status = 'Succeeded' }
+ }
+ if ($joined.StartsWith('appservice plan silo create ') -or $joined.StartsWith('appservice plan silo update ')) {
+ $workers = Get-FakeArgument $Arguments '--number-of-workers' '1'
+ if ($workers -in @('0', '31')) { return New-FakeResponse -ExitCode 1 -ErrorText 'ERROR: --number-of-workers must be between 1 and 30.' }
+ $state.workers = [int]$workers; $state.planSilo = $true
+ $state.zone = (Get-FakeArgument $Arguments '--zone-redundant' 'false') -eq 'true'
+ if ($Arguments -contains '--no-wait') {
+ return New-FakeResponse @{ resourceId = $s.planSiloId; operationId = 'plan-op'; status = 'Accepted' }
+ }
+ $planResource.properties.instanceNumberOverride = $state.workers
+ $planResource.properties.zoneRedundantOverride = $state.zone
+ return New-FakeResponse $planResource
+ }
+ if ($joined.StartsWith('appservice plan silo show ')) {
+ if (-not $state.planSilo) { return New-FakeResponse -ExitCode 3 -ErrorText 'ERROR: (ResourceNotFound) Missing.' }
+ return New-FakeResponse $planResource
+ }
+ if ($joined.StartsWith('appservice plan silo list ')) {
+ if ($state.planSilo) { return New-FakeResponse -Payload @($planResource) }
+ return New-FakeResponse -Payload @()
+ }
+ if ($joined.StartsWith('appservice plan silo delete ')) {
+ if ($state.siteSilo) { return New-FakeResponse -ExitCode 1 -ErrorText "ERROR: (Conflict) The silo has site silos associated with it. Delete site silos first." }
+ if (-not $state.planSilo) { return New-FakeResponse }
+ $state.planSilo = $false
+ return New-FakeResponse @{ resourceId = $s.planSiloId; operationId = 'plan-delete'; status = 'Accepted' }
+ }
+ if ($joined.StartsWith('webapp silo create ')) {
+ $state.siteSilo = $true
+ return New-FakeResponse @{ resourceId = $s.siteSiloId; operationId = 'site-op'; status = 'Accepted' }
+ }
+ if ($joined.StartsWith('webapp silo show ')) {
+ if ($id -and $id.Contains('/serverfarms/')) { return New-FakeResponse -ExitCode 1 -ErrorText 'ERROR: Provide a Microsoft.Web/sites/silos resource ID.' }
+ if ($id -like '*/silos/origin') {
+ return New-FakeResponse $originResource
+ }
+ if (-not $state.siteSilo -or $id -like '*/silos/missing-*') { return New-FakeResponse -ExitCode 3 -ErrorText 'ERROR: (ResourceNotFound) Missing.' }
+ if ((Get-FakeArgument $Arguments '--output') -in @('table', 'tsv')) { return @{ exitCode = 0; stdout = 'fixture output'; stderr = ''; elapsedMs = 1; timedOut = $false } }
+ return New-FakeResponse $siteResource
+ }
+ if ($joined.StartsWith('webapp silo list ')) {
+ if ($state.siteSilo) { return New-FakeResponse -Payload @($siteResource, @{ name = 'origin' }) }
+ return New-FakeResponse -Payload @()
+ }
+ if ($joined.StartsWith('webapp silo delete ')) {
+ if ($id -like '*/silos/origin') { return New-FakeResponse -ExitCode 1 -ErrorText "ERROR: (BadRequest) The origin silo cannot be deleted directly." }
+ if (-not $state.siteSilo) { return New-FakeResponse }
+ $state.siteSilo = $false
+ return New-FakeResponse @{ resourceId = $s.siteSiloId; operationId = 'site-delete'; status = 'Accepted' }
+ }
+ if ($joined -match '^(appservice plan silo|webapp silo) (operation show|wait) ') { return New-FakeResponse @{ status = 'Succeeded' } }
+ if ($joined.StartsWith('webapp silo config appsettings list ')) {
+ return New-FakeResponse @{ properties = @{ ABCLI_RUN_ID = $ctx.Manifest.runId; ABCLI_GENERATION = $state.siloGeneration; PLATFORM_SECRET = 'synthetic-do-not-persist' } }
+ }
+ if ($joined.StartsWith('webapp silo config connection-string list ')) {
+ return New-FakeResponse @{ properties = @{ ABCLI_DUMMY = @{ value = $state.dummyConnection; type = 'Custom' } } }
+ }
+ if ($joined.StartsWith('webapp silo config connection-string show ') -and $Arguments -contains '--key') {
+ return New-FakeResponse @{ properties = @{ value = $state.dummyConnection; type = 'Custom' } }
+ }
+ if ($joined.StartsWith('webapp silo refresh ')) { return New-FakeResponse -ErrorText 'WARNING: Refresh request accepted. Revision propagation continues asynchronously.' }
+ if ($joined.StartsWith('rest ')) {
+ $url = Get-FakeArgument $Arguments '--url'
+ if ($url -eq ($s.appId + '/silos/origin?api-version=' + $ctx.ApiVersion)) {
+ return New-FakeResponse $originResource
+ }
+ if ($url -eq "/subscriptions/$($ctx.Subscription)/locations?api-version=2022-12-01") {
+ return New-FakeResponse @{ value = @(@{ name = $ctx.PrimaryRegion }, @{ name = $ctx.SiloRegion }) }
+ }
+ if ($url -like ($s.planSiloId + '*')) { return New-FakeResponse $planResource }
+ return New-FakeResponse $siteResource
+ }
+ if ($joined.StartsWith('resource list ')) { return New-FakeResponse @($state.resources) }
+ if ($joined.StartsWith('webapp silo update ')) {
+ if ($id -like '*/silos/origin') { $state.origin = Get-FakeArgument $Arguments '--endpoint-status'; return New-FakeResponse @{ properties = @{ endpointStatus = $state.origin } } }
+ $state.followLkg = (Get-FakeArgument $Arguments '--follow-last-known-good' 'false') -eq 'true'
+ $state.endpoint = Get-FakeArgument $Arguments '--endpoint-status' $state.endpoint
+ $siteResource.properties.followLastKnownGood = $state.followLkg
+ return New-FakeResponse $siteResource
+ }
+ if ($joined.StartsWith('webapp managed-traffic ')) {
+ if ($Arguments -contains 'update') {
+ $state.mode = Get-FakeArgument $Arguments '--traffic-mode' $state.mode
+ $input = Get-FakeArgument $Arguments '--endpoints'
+ if ($input) {
+ $entries = ConvertFrom-Json -InputObject ([IO.File]::ReadAllText($input.Substring(1))) -AsHashtable -NoEnumerate
+ foreach ($entry in $entries) {
+ $target = @($state.endpoints | Where-Object region -eq $entry.region)[0]
+ foreach ($key in @('priority', 'weight')) { if ($entry.Contains($key)) { $target[$key] = $entry[$key] } }
+ }
+ }
+ }
+ $traffic = @{ id = $s.appId + '/managedTrafficConfig/default'; properties = @{ trafficMode = $state.mode; endpoints = $state.endpoints } }
+ if ($Arguments[2] -eq 'list') { return New-FakeResponse -Payload @($traffic) }
+ return New-FakeResponse $traffic
+ }
+ if ($joined.StartsWith('network vnet create ')) {
+ $state.resources += @(@{ id = $s.groupId + '/providers/Microsoft.Network/virtualNetworks/' + $s.vnet })
+ return New-FakeResponse @{ newVNet = @{} }
+ }
+ if ($joined.StartsWith('network vnet subnet update ')) { return New-FakeResponse @{ properties = @{} } }
+ if ($joined.StartsWith('webapp silo vnet-integration ')) {
+ if ($Arguments[3] -in @('add', 'update')) { $state.subnet = Get-FakeArgument $Arguments '--subnet' }
+ if ($Arguments[3] -eq 'remove') { $state.subnet = ''; return New-FakeResponse }
+ return New-FakeResponse @{ properties = @{ subnetResourceId = $state.subnet } }
+ }
+ if ($joined.StartsWith('appservice plan silo vnet-integration list ')) { return New-FakeResponse @(@{ properties = @{ subnetResourceId = $state.subnet } }) }
+ if ($joined.StartsWith('webapp last-known-good ')) {
+ if ($Arguments[2] -eq 'mark') {
+ $state.lkg = [int]$state.primaryGeneration.Substring(1)
+ $state.revision = $state.lkg
+ $state.siloGeneration = $state.primaryGeneration
+ }
+ $lkg = @{ properties = @{ revisionNumber = $state.lkg } }
+ if ($Arguments[2] -eq 'list') { return New-FakeResponse -Payload @($lkg) }
+ return New-FakeResponse $lkg
+ }
+ if ($joined.StartsWith('webapp silo instance list ')) { return New-FakeResponse @(@{ name = 'worker' }) }
+ if ($joined.StartsWith('webapp silo config snapshot list ') -or $joined.StartsWith('webapp silo deployment list ')) { return New-FakeResponse @() }
+ return New-FakeResponse @{ properties = @{}; value = @() }
+ }.GetNewClosure()
+ $Context.HttpExecutor = {
+ param($suite, $hostname)
+ return @{ runId = $ctx.Manifest.runId; generation = $(if ($hostname -eq 'primary.example.test') { $state.primaryGeneration } else { $state.siloGeneration }) }
+ }.GetNewClosure()
+ return $state
+}
+
+try {
+ Test-Case 'command inventory and 109 API variants match source' {
+ $catalog = Get-AbCommandCatalog $repo
+ Assert-Test ($catalog.commands.Count -eq 104)
+ Assert-Test ($catalog.variants.Count -eq 109)
+ Assert-Test (@($catalog.commands.command | Select-Object -Unique).Count -eq 104)
+ }
+ Test-Case 'default Plan needs neither interpreter nor Azure access and creates no manifest' {
+ $script = Join-Path $repo 'scripts\appservice\test_active_backup.ps1'
+ $process = Invoke-AbProcess (Get-Command pwsh -CommandType Application).Source @('-NoProfile', '-File', $script,
+ '-Subscription', $subscription, '-Mode', 'Plan') $repo @{} 30
+ Assert-Test ($process.exitCode -eq 0) $process.stderr
+ Assert-Test ($process.stdout -match 'Plan makes no Azure calls')
+ }
+ Test-Case 'WhatIf converts even Run into mutation-free Plan' {
+ $script = Join-Path $repo 'scripts\appservice\test_active_backup.ps1'
+ $process = Invoke-AbProcess (Get-Command pwsh -CommandType Application).Source @('-NoProfile', '-File', $script,
+ '-Subscription', $subscription, '-Mode', 'Run', '-WhatIf') $repo @{} 30
+ Assert-Test ($process.exitCode -eq 0)
+ Assert-Test ($process.stdout -match 'Mode: Plan')
+ }
+ Test-Case 'Run requires resource and cleanup authorization before interpreter resolution' {
+ $script = Join-Path $repo 'scripts\appservice\test_active_backup.ps1'
+ $process = Invoke-AbProcess (Get-Command pwsh -CommandType Application).Source @('-NoProfile', '-File', $script,
+ '-Subscription', $subscription, '-Mode', 'Run') $repo @{} 30
+ Assert-Test ($process.exitCode -eq 4)
+ }
+ Test-Case 'path and region isolation' {
+ Assert-Throws { Assert-AbLocalPath $repo '..\outside' } 'inside the repository'
+ Assert-Throws { Assert-AbLocalPath $repo 'README.md' -RequireIgnored } 'ignored'
+ Assert-Throws { New-AbContext $repo $subscription 'eastus2euap' 'eastus2euap' } 'distinct'
+ Assert-Throws { New-AbContext $repo 'not-a-guid' } 'GUID'
+ }
+ Test-Case 'private cache ACLs do not grant access to unrelated users' {
+ if (-not $IsWindows) { return }
+ $c = New-TestContext
+ $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
+ $acl = [IO.FileSystemAclExtensions]::GetAccessControl([IO.DirectoryInfo]::new($c.AuthConfigDir),
+ [Security.AccessControl.AccessControlSections]::Access)
+ foreach ($rule in $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) {
+ if ($rule.AccessControlType -eq 'Allow') {
+ Assert-Test ($rule.IdentityReference.Value -in @($sid, 'S-1-5-18'))
+ }
+ }
+ Test-Case 'reparse-point output paths are rejected before writing through them' {
+ if (-not $IsWindows) { return }
+ $c = New-TestContext
+ $target = Join-Path $c.RunDirectory 'junction-target'
+ $link = Join-Path $c.RunDirectory 'junction'
+ [IO.Directory]::CreateDirectory($target) | Out-Null
+ [IO.File]::WriteAllText((Join-Path $target 'keep.txt'), 'kept')
+ try {
+ New-Item -ItemType Junction -Path $link -Target $target | Out-Null
+ Assert-Throws { Assert-AbLocalPath $repo (Join-Path $link 'child') -RequireIgnored } 'reparse'
+ }
+ finally { if (Test-Path -LiteralPath $link) { [IO.Directory]::Delete($link) } }
+ Assert-Test ([IO.File]::ReadAllText((Join-Path $target 'keep.txt')) -eq 'kept')
+ }
+ }
+ Test-Case 'per-run lock prevents another writer from changing a live manifest' {
+ $c = New-TestContext
+ $before = [IO.File]::ReadAllText($c.ManifestPath)
+ $other = Read-AbManifest $repo $c.ManifestPath $subscription
+ $contexts.Add($other)
+ Assert-Throws { Initialize-AbLocalState $other -Existing }
+ Assert-Test ([IO.File]::ReadAllText($c.ManifestPath) -ceq $before)
+ }
+ Test-Case 'native arguments preserve quotes and whitespace, warnings stay distinct' {
+ $code = 'import json,sys; print(json.dumps(sys.argv[1:])); print("preview warning",file=sys.stderr)'
+ $result = Invoke-AbProcess $python @('-c', $code, 'a path\with spaces', '{"x":"quoted value"}') $repo @{ PYTHONDONTWRITEBYTECODE = '1' } 30
+ $values = ConvertFrom-Json -InputObject $result.stdout
+ Assert-Test ($result.exitCode -eq 0 -and $values[0] -eq 'a path\with spaces' -and $values[1] -eq '{"x":"quoted value"}')
+ Assert-Test ($result.stderr -match 'preview warning')
+ }
+ Test-Case 'native stdout and stderr are drained concurrently' {
+ $result = Invoke-AbProcess $python @('-c', 'import sys; print("x"*100000); print("y"*100000,file=sys.stderr)') $repo @{} 30
+ Assert-Test ($result.exitCode -eq 0 -and $result.stdout.Length -ge 100000 -and $result.stderr.Length -ge 100000)
+ }
+ Test-Case 'native timeout terminates only the launched child' {
+ $result = Invoke-AbProcess $python @('-c', 'import time; time.sleep(30)') $repo @{} 1
+ Assert-Test ($result.exitCode -eq 124 -and $result.timedOut)
+ }
+ Test-Case 'source provenance refuses installed modules and incomplete inventories' {
+ $c = New-TestContext
+ $provenance = @{
+ modules = @{
+ 'azure.cli' = Join-Path $repo 'src\azure-cli\azure\cli\__init__.py'
+ 'azure.cli.core' = Join-Path $repo 'src\azure-cli-core\azure\cli\core\__init__.py'
+ 'azure.cli.command_modules.appservice' = Join-Path $repo 'src\azure-cli\azure\cli\command_modules\appservice\__init__.py'
+ 'azure.cli.command_modules.appservice.active_backup' = Join-Path $repo 'src\azure-cli\azure\cli\command_modules\appservice\active_backup.py'
+ 'azure.cli.command_modules.appservice._active_backup_client' = Join-Path $repo 'src\azure-cli\azure\cli\command_modules\appservice\_active_backup_client.py'
+ }
+ apiVersion = '2026-08-01'; helpFailures = @(); commands = $c.Catalog.commands.command
+ }
+ Assert-AbProvenance $c $provenance
+ $provenance.modules['azure.cli.core'] = Join-Path $repo 'installed\azure\cli\core\__init__.py'
+ Assert-Throws { Assert-AbProvenance $c $provenance } 'provenance'
+ $provenance.modules['azure.cli.core'] = Join-Path $repo 'src\azure-cli-core\azure\cli\core\__init__.py'
+ $provenance.helpFailures = @('webapp silo show')
+ Assert-Throws { Assert-AbProvenance $c $provenance } 'help'
+ }
+ if ($CliPythonExe) {
+ Test-Case 'actual local CLI imports and every help entry load without Azure access' {
+ $c = New-TestContext
+ $c.PythonExe = Resolve-AbPython $repo $CliPythonExe
+ Test-AbSource $c
+ Assert-Test ($c.Manifest.provenance.commands.Count -eq 104)
+ Assert-Test ($c.Manifest.provenance.helpFailures.Count -eq 0)
+ }
+ Test-Case 'actual local cloud metadata loads without subscription arguments or Azure access' {
+ $c = New-TestContext
+ $c.PythonExe = Resolve-AbPython $repo $CliPythonExe
+ $cloud = (Invoke-AbAz $c @('cloud', 'show')).json
+ Assert-Test ($cloud.name -eq 'AzureCloud')
+ Assert-Test ($cloud.endpoints.resourceManager -eq 'https://management.azure.com/')
+ }
+ }
+ Test-Case 'local cloud metadata does not receive resource identity arguments' {
+ $c = New-TestContext -Mode Preflight
+ $script:observed = @()
+ $c.Executor = { param($arguments) $script:observed = $arguments; New-FakeResponse @{ name = 'AzureCloud' } }
+ Invoke-AbAz $c @('cloud', 'show') | Out-Null
+ Assert-Test ($script:observed -notcontains '--subscription')
+ Assert-Test ($script:observed -notcontains '--ids')
+ Assert-Throws { Invoke-AbAz $c @('cloud', 'show', '--subscription', $subscription) } 'Local cloud metadata'
+ Assert-Throws { Invoke-AbAz $c @('cloud', 'show', '--ids', "/subscriptions/$subscription/resourceGroups/group") } 'Local cloud metadata'
+ }
+ Test-Case 'named subscription and ID scoping' {
+ $c = New-TestContext
+ $script:observed = @()
+ $c.Executor = { param($arguments) $script:observed = $arguments; New-FakeResponse @{ id = 'ok' } }
+ Invoke-AbAz $c @('webapp', 'silo', 'show', '-g', 'group', '-n', 'app', '--silo', 'silo') | Out-Null
+ Assert-Test ($script:observed -contains '--subscription')
+ $id = "/subscriptions/$subscription/resourceGroups/group/providers/Microsoft.Web/sites/app/silos/silo"
+ Invoke-AbAz $c @('webapp', 'silo', 'show', '--ids', $id) | Out-Null
+ Assert-Test ($script:observed -notcontains '--subscription')
+ Assert-Throws { Invoke-AbAz $c @('webapp', 'silo', 'show', '--ids', $id, '--subscription', $subscription) } 'combine'
+ Assert-Throws { Invoke-AbAz $c @('webapp', 'silo', 'show', '--ids', $id.Replace($subscription, '00000000-0000-0000-0000-000000000002')) } 'outside'
+ Assert-Throws { Invoke-AbAz $c @('webapp', 'silo', 'show', '--ids', $id,
+ $id.Replace($subscription, '00000000-0000-0000-0000-000000000002')) } 'outside'
+ Assert-Throws { Invoke-AbAz $c @('account', 'show', '--subscription', $subscription, '--subscription', 'another') } 'Duplicate'
+ Assert-Throws { Invoke-AbAz $c @('webapp', 'silo', 'show', ('--ids=' + $id)) } 'separate identity'
+ }
+ Test-Case 'preflight denies undeclared or mutating commands' {
+ $c = New-TestContext -Mode Preflight
+ $c.Executor = { throw 'Must not execute' }
+ Assert-Throws { Invoke-AbAz $c @('group', 'create', '-n', 'unsafe') -Mutation } 'cannot mutate'
+ Assert-Throws { Invoke-AbAz $c @('group', 'list') } 'undeclared'
+ Assert-Throws { Invoke-AbAz $c @('account', 'show', '--debug') } 'Debug'
+ }
+ Test-Case 'preflight locations REST is scoped to the exact subscription, origin, method and API' {
+ $c = New-TestContext -Mode Preflight
+ $script:observed = @()
+ $c.Executor = { param($arguments) $script:observed = $arguments; New-FakeResponse @{ value = @() } }
+ $url = "/subscriptions/$subscription/locations?api-version=2022-12-01"
+ Invoke-AbAz $c @('rest', '--method', 'get', '--url', $url) | Out-Null
+ Assert-Test ((Get-FakeArgument $script:observed '--subscription') -eq $subscription)
+ Assert-Throws { Invoke-AbAz $c @('rest', '--method', 'post', '--url', $url) } 'scoped GETs'
+ Assert-Throws { Invoke-AbAz $c @('rest', '--method', 'get', '--url') } 'scoped GETs'
+ Assert-Throws { Invoke-AbAz $c @('rest', '--method', 'get', '--url', ('https://example.test' + $url)) } 'origin mismatch'
+ Assert-Throws { Invoke-AbAz $c @('rest', '--method', 'get', '--url',
+ $url.Replace($subscription, '00000000-0000-0000-0000-000000000002')) } 'exact selected-subscription'
+ Assert-Throws { Invoke-AbAz $c @('rest', '--method', 'get', '--url', $url.Replace('2022-12-01', '2026-08-01')) } 'exact selected-subscription'
+ Assert-Throws { Invoke-AbAz $c @('rest', '--method', 'get', '--url',
+ "/subscriptions/$subscription/resourceGroups/group?api-version=2022-12-01") } 'exact selected-subscription'
+ }
+ Test-Case 'no mutation can be sent without a write-ahead ownership intent' {
+ $c = New-TestContext
+ $c.Executor = { throw 'A mutation must not reach the executor.' }
+ Assert-Throws {
+ Invoke-AbAz $c @('webapp', 'silo', 'delete', '--ids',
+ "/subscriptions/$subscription/resourceGroups/group/providers/Microsoft.Web/sites/app/silos/silo") `
+ -Mutation -Replay Delete -IntentId "/subscriptions/$subscription/resourceGroups/group"
+ } 'not journaled'
+ }
+ Test-Case 'read-only preflight succeeds on fake metadata' {
+ $c = New-TestContext -Mode Preflight
+ $suite = New-AbSuite $c Windows
+ $state = Set-FakeAzure $c $suite
+ Invoke-AbPreflight $c
+ Assert-Test (@($c.Manifest.cases | Where-Object status -ne 'Pass').Count -eq 0)
+ Assert-Test (-not $state.groupExists)
+ }
+ Test-Case 'missing advertised API blocks preflight without registration or allocation' {
+ $c = New-TestContext -Mode Preflight
+ $suite = New-AbSuite $c Windows
+ $state = Set-FakeAzure $c $suite
+ $original = $c.Executor
+ $c.Executor = {
+ param($arguments)
+ if (($arguments -join ' ').StartsWith('provider show ')) {
+ return New-FakeResponse @{ registrationState = 'Registered'; resourceTypes = @() }
+ }
+ return & $original $arguments
+ }.GetNewClosure()
+ Assert-Throws { Invoke-AbPreflight $c } 'does not advertise'
+ Assert-Test (-not $state.groupExists)
+ Assert-Test (@($state.calls | Where-Object { $_ -match 'register|group create' }).Count -eq 0)
+ }
+ Test-Case 'invalid JSON, expected empty output, and normal service errors fail explicitly' {
+ $c = New-TestContext
+ $c.Executor = { param($arguments) @{ exitCode = 0; stdout = 'not json'; stderr = 'warning'; elapsedMs = 1; timedOut = $false } }
+ Assert-Throws { Invoke-AbAz $c @('webapp', 'silo', 'list') } 'expected JSON'
+ Assert-Throws { Invoke-AbAz $c @('webapp', 'silo', 'refresh') -OutputShape Empty } 'empty'
+ $c.Executor = { param($arguments) New-FakeResponse -ExitCode 3 -ErrorText 'ERROR: (ResourceNotFound) synthetic-secret' }
+ Assert-Throws { Invoke-AbAz $c @('webapp', 'silo', 'show') } 'ResourceNotFound'
+ Assert-Test (-not ([IO.File]::ReadAllText((Join-Path $c.RunDirectory 'events.jsonl'))).Contains('synthetic-secret'))
+ }
+ Test-Case 'recovery accepts only the exact generated VNet intent, not sibling resources' {
+ $c = New-TestContext -Profile Core
+ $suite = New-AbSuite $c Windows
+ $vnetId = $suite.groupId + '/providers/Microsoft.Network/virtualNetworks/' + $suite.vnet
+ $suite.intents = @($suite.groupId, $suite.planId, $suite.appId, $suite.planSiloId, $suite.siteSiloId, $vnetId)
+ $c.Manifest.suites = @($suite)
+ Save-AbManifest $c
+ $read = Read-AbManifest $repo $c.ManifestPath $subscription Cleanup
+ Assert-Test ($vnetId -in $read.Manifest.suites[0].intents)
+ $suite.intents += @($vnetId + '-unrecorded')
+ Save-AbManifest $c
+ Assert-Throws { Read-AbManifest $repo $c.ManifestPath $subscription Cleanup } 'undeclared resource intent'
+ }
+ Test-Case 'manifest names and target cannot be changed to adopt other groups' {
+ $c = New-TestContext
+ $c.Manifest.suites = @(New-AbSuite $c Windows)
+ Save-AbManifest $c
+ $read = Read-AbManifest $repo $c.ManifestPath $subscription
+ Assert-Test ($read.Manifest.runId -eq $c.Manifest.runId)
+ $c.Manifest.suites[0].group = 'production'
+ Save-AbManifest $c
+ Assert-Throws { Read-AbManifest $repo $c.ManifestPath $subscription } 'changed'
+ }
+ Test-Case 'a manifest owned by another local operator is not adopted' {
+ $c = New-TestContext
+ $c.Manifest.owner = 'another-operator'
+ Save-AbManifest $c
+ Assert-Throws { Read-AbManifest $repo $c.ManifestPath $subscription } 'does not match'
+ }
+ Test-Case 'resume source fingerprints reject even uncommitted CLI changes' {
+ $c = New-TestContext
+ $key = @($c.Manifest.sourceHashes.Keys)[0]
+ $c.Manifest.sourceHashes[$key] = 'incorrect'
+ Save-AbManifest $c
+ Assert-Throws { Read-AbManifest $repo $c.ManifestPath $subscription Resume } 'changed local CLI or harness source'
+ }
+ Test-Case 'cleanup checks actual tags before deleting anything' {
+ $c = New-TestContext
+ $suite = New-AbSuite $c Windows
+ $suite.groupCreateAttempted = $true
+ $c.Manifest.suites = @($suite)
+ $script:deleted = $false
+ $c.Executor = {
+ param($arguments)
+ if ($arguments -contains 'delete') { $script:deleted = $true }
+ New-FakeResponse @{ id = 'wrong'; tags = @{} }
+ }
+ Assert-Throws { Remove-AbOwnedResources $c $suite } 'Cleanup did not complete'
+ Assert-Test (-not $script:deleted)
+ Assert-Test ((Get-AbExitCode $c) -eq 3)
+ }
+ Test-Case 'unexpected resources block group deletion' {
+ $c = New-TestContext
+ $suite = New-AbSuite $c Windows
+ $suite.groupCreateAttempted = $true
+ $c.Manifest.suites = @($suite)
+ $state = Set-FakeAzure $c $suite
+ $state.groupExists = $true
+ $state.resources = @(@{ id = $suite.groupId + '/providers/Microsoft.Storage/storageAccounts/unrecorded' })
+ Assert-Throws { Remove-AbOwnedResources $c $suite } 'Cleanup did not complete'
+ Assert-Test ($state.groupExists)
+ }
+ Test-Case 'unresolved site-silo deletion prevents plan and group deletion' {
+ $c = New-TestContext
+ $suite = New-AbSuite $c Windows
+ $suite.groupCreateAttempted = $true
+ $suite.intents = @($suite.groupId, $suite.planId, $suite.appId, $suite.planSiloId, $suite.siteSiloId)
+ $c.Manifest.suites = @($suite)
+ $state = Set-FakeAzure $c $suite
+ $state.groupExists = $true; $state.planSilo = $true; $state.siteSilo = $true
+ $original = $c.Executor
+ $c.Executor = {
+ param($arguments)
+ if (($arguments -join ' ').StartsWith('webapp silo delete ')) {
+ return New-FakeResponse -ExitCode 1 -ErrorText 'ERROR: (Conflict) Synthetic deletion conflict.'
+ }
+ return & $original $arguments
+ }.GetNewClosure()
+ Assert-Throws { Remove-AbOwnedResources $c $suite } 'Cleanup did not complete'
+ Assert-Test ($state.groupExists -and $state.planSilo -and $state.siteSilo)
+ Assert-Test (@($state.calls | Where-Object { $_ -match '^(appservice plan silo delete|group delete)' }).Count -eq 0)
+ }
+ Test-Case 'silo result diagnostics retain only allowlisted identity and state metadata' {
+ $c = New-TestContext
+ $suite = New-AbSuite $c Windows
+ $c.Manifest.suites = @($suite)
+ Register-AbIntent $c $suite $suite.planSiloId
+ $c.Executor = {
+ param($arguments)
+ New-FakeResponse @{ id = $null; status = 'Succeeded'; properties = @{ password = 'never-record-this' }
+ accessToken = 'never-record-this' }
+ }
+ Invoke-AbAz $c @('appservice', 'plan', 'silo', 'update', '--ids', $suite.planSiloId) `
+ -Mutation -Replay Idempotent -IntentId $suite.planSiloId | Out-Null
+ $events = [IO.File]::ReadAllText((Join-Path $c.RunDirectory 'events.jsonl'))
+ Assert-Test (-not $events.Contains('never-record-this'))
+ $entry = @(($events -split '\r?\n' | Where-Object { $_ } | ForEach-Object { ConvertFrom-Json -InputObject $_ }) |
+ Where-Object kind -eq 'silo-response')[0]
+ Assert-Test ($entry.data.targetResourceId -eq $suite.planSiloId)
+ Assert-Test ($entry.data.response.shape -eq 'object' -and $null -eq $entry.data.response.id)
+ Assert-Test ($entry.data.response.status -eq 'Succeeded')
+ $safe = Get-AbSiloResponseMetadata $c @{ id = $suite.planSiloId; resourceId = $suite.planSiloId
+ type = 'Microsoft.Web/serverfarms/silos'; location = $c.SiloRegion
+ properties = @{ provisioningState = 'Succeeded'; token = 'never-record-this' } }
+ Assert-Test ($safe.id -eq $suite.planSiloId -and $safe.provisioningState -eq 'Succeeded')
+ $redacted = Get-AbSiloResponseMetadata $c @{ id = 'https://example.test/?token=never-record-this'
+ status = 'never-record-this'; type = 'never-record-this'; location = 'never-record-this' }
+ Assert-Test (-not (ConvertTo-Json -InputObject $redacted).Contains('never-record-this'))
+ Assert-Test ($redacted.id -eq '[omitted]')
+ Assert-Test ((Get-AbSiloResponseMetadata $c $null).shape -eq 'null')
+ Assert-Test ((Get-AbSiloResponseMetadata $c @()).shape -eq 'array')
+ }
+ Test-Case 'successful cleanup preserves earlier failure history in both reports' {
+ $c = New-TestContext
+ Assert-Throws { Invoke-AbCase $c 'win-p1v3.cleanup' { Stop-Ab 'Delete operation polling returned ResourceNotFound.' } } 'ResourceNotFound'
+ Invoke-AbCase $c 'win-p1v3.cleanup' {}
+ $c.Mode = 'Cleanup'
+ $c.Manifest.cleanup.status = 'Complete'
+ $report = Write-AbReport $c
+ Assert-Test ($report.exitCode -eq 0 -and $report.caseHistory.Count -eq 2)
+ Assert-Test ($report.caseHistory[0].status -eq 'Fail' -and $report.cases[0].status -eq 'Pass')
+ $summary = [IO.File]::ReadAllText((Join-Path $c.RunDirectory 'summary.md'))
+ Assert-Test ($summary.Contains('Previous failed attempts') -and $summary.Contains('ResourceNotFound'))
+ Assert-Test ($summary.Contains('cleanup only; it does not erase earlier failures'))
+ }
+ Test-Case 'an empty successful create result is not mislabeled as a resource identity mismatch' {
+ $c = New-TestContext
+ $suite = New-AbSuite $c Windows
+ Assert-Throws {
+ Complete-AbSiloOperation $c $suite 'appservice plan silo' $suite.planSiloId @{ json = $null } 'C06'
+ } 'returned no resource object or operation receipt'
+ }
+ Test-Case 'fixture archives serve Windows JSON explicitly without changing Linux content' {
+ $c = New-TestContext
+ foreach ($system in @('Windows', 'Linux')) {
+ $suite = New-AbSuite $c $system
+ $archive = [IO.Compression.ZipFile]::OpenRead((New-AbFixture $c $suite 'v1'))
+ try {
+ $reader = [IO.StreamReader]::new($archive.GetEntry('version.json').Open())
+ try { $marker = ConvertFrom-Json -InputObject $reader.ReadToEnd() } finally { $reader.Dispose() }
+ Assert-Test ($marker.runId -eq $c.Manifest.runId -and $marker.generation -eq 'v1')
+ $configEntry = $archive.GetEntry('web.config')
+ if ($system -eq 'Windows') {
+ Assert-Test ($null -ne $configEntry)
+ $reader = [IO.StreamReader]::new($configEntry.Open())
+ try { $config = [xml]$reader.ReadToEnd() } finally { $reader.Dispose() }
+ $staticContent = $config.configuration.'system.webServer'.staticContent
+ Assert-Test ($staticContent.remove.fileExtension -eq '.json')
+ Assert-Test ($staticContent.mimeMap.fileExtension -eq '.json' -and $staticContent.mimeMap.mimeType -eq 'application/json')
+ }
+ else { Assert-Test ($null -eq $configEntry) }
+ }
+ finally { $archive.Dispose() }
+ }
+ }
+ Test-Case 'isolated origin profile skips unrelated mutations and reports immediate visibility' {
+ $c = New-TestContext -Profile OriginVisibility
+ $suite = New-AbSuite $c Windows
+ $c.Manifest.suites = @($suite)
+ $state = Set-FakeAzure $c $suite
+ Invoke-AbPreflight $c
+ $report = Invoke-AbRun $c
+ Assert-Test ($report.exitCode -eq 0 -and $report.cleanup.status -eq 'Complete')
+ Assert-Test ($suite.originVisibility.samples.Count -eq 1 -and $suite.originVisibility.firstVisibleUtc)
+ Assert-Test ($suite.originVisibility.samples[0].originListed)
+ Assert-Test (@($state.calls | Where-Object {
+ $_ -match '^webapp (deploy|config appsettings set|silo refresh|managed-traffic update)|^network |^appservice plan silo update|--namespace Microsoft.Network'
+ }).Count -eq 0)
+ $summary = [IO.File]::ReadAllText((Join-Path $c.RunDirectory 'summary.md'))
+ Assert-Test ($summary.Contains('Origin visibility') -and $summary.Contains('CLI exit'))
+ $plan = Invoke-AbProcess (Get-Command pwsh -CommandType Application).Source @(
+ '-NoProfile', '-File', (Join-Path $repo 'scripts\appservice\test_active_backup.ps1'),
+ '-Mode', 'Plan', '-Subscription', $subscription, '-Profile', 'OriginVisibility', '-OperatingSystem', 'Windows') $repo @{} 30
+ Assert-Test ($plan.exitCode -eq 0 -and $plan.stdout.Contains('No deployment, refresh, traffic or Core mutations'))
+ }
+ Test-Case 'origin polling records not-found samples before delayed visibility' {
+ $c = New-TestContext -Profile OriginVisibility
+ $suite = New-AbSuite $c Windows
+ $c.Manifest.suites = @($suite)
+ $state = Set-FakeAzure $c $suite
+ $state.originReads = 0
+ $original = $c.Executor
+ $originId = $suite.appId + '/silos/origin'
+ $c.Executor = {
+ param($arguments)
+ if (($arguments -join ' ').StartsWith('webapp silo show ') -and (Get-FakeArgument $arguments '--ids') -eq $originId) {
+ $state.originReads++
+ if ($state.originReads -lt 2) { return New-FakeResponse -ExitCode 3 -ErrorText 'ERROR: (ResourceNotFound) Missing.' }
+ }
+ if ($arguments[0] -eq 'rest' -and (Get-FakeArgument $arguments '--url') -like ($originId + '?*') -and $state.originReads -lt 2) {
+ return New-FakeResponse -ExitCode 1 -ErrorText 'ERROR: Not Found({"Code":"ResourceNotFound"})'
+ }
+ return & $original $arguments
+ }.GetNewClosure()
+ $report = Invoke-AbRun $c
+ Assert-Test ($report.exitCode -eq 0 -and $suite.originVisibility.samples.Count -eq 2)
+ Assert-Test ($suite.originVisibility.samples[0].cliExitCode -eq 3 -and $suite.originVisibility.samples[0].armExitCode -eq 1)
+ Assert-Test ($suite.originVisibility.samples[0].secondaryExitCode -eq 0)
+ Assert-Test ($suite.originVisibility.samples[1].cliExitCode -eq 0 -and $suite.originVisibility.firstVisibleUtc)
+ }
+ Test-Case 'persistent origin not-found times out without a fabricated pass and still cleans up' {
+ $c = New-TestContext -Profile OriginVisibility
+ $c.Manifest.options.observationTimeoutSeconds = 1
+ $suite = New-AbSuite $c Windows
+ $c.Manifest.suites = @($suite)
+ $state = Set-FakeAzure $c $suite
+ $original = $c.Executor
+ $originId = $suite.appId + '/silos/origin'
+ $c.Executor = {
+ param($arguments)
+ if (($arguments -join ' ').StartsWith('webapp silo show ') -and (Get-FakeArgument $arguments '--ids') -eq $originId) {
+ return New-FakeResponse -ExitCode 3 -ErrorText 'ERROR: (ResourceNotFound) Missing.'
+ }
+ if ($arguments[0] -eq 'rest' -and (Get-FakeArgument $arguments '--url') -like ($originId + '?*')) {
+ return New-FakeResponse -ExitCode 1 -ErrorText 'ERROR: Not Found({"Code":"ResourceNotFound"})'
+ }
+ return & $original $arguments
+ }.GetNewClosure()
+ $report = Invoke-AbRun $c
+ Assert-Test ($report.exitCode -eq 1 -and $report.cleanup.status -eq 'Complete')
+ Assert-Test ($null -eq $suite.originVisibility.firstVisibleUtc -and $suite.originVisibility.samples.Count -ge 1)
+ Assert-Test (-not $state.groupExists)
+ }
+ Test-Case 'CLI origin collection compatibility never makes the raw ARM visibility probe pass' {
+ $c = New-TestContext -Profile OriginVisibility
+ $c.Manifest.options.observationTimeoutSeconds = 1
+ $suite = New-AbSuite $c Windows
+ $c.Manifest.suites = @($suite)
+ $state = Set-FakeAzure $c $suite
+ $original = $c.Executor
+ $originId = $suite.appId + '/silos/origin'
+ $c.Executor = {
+ param($arguments)
+ if (($arguments -join ' ').StartsWith('webapp silo show ') -and (Get-FakeArgument $arguments '--ids') -eq $originId) {
+ $r = & $original $arguments
+ $r.stderr = 'WARNING: Origin point GET returned HTTP 404; using the matching origin from the silo collection.'
+ return $r
+ }
+ if ($arguments[0] -eq 'rest' -and (Get-FakeArgument $arguments '--url') -like ($originId + '?*')) {
+ return New-FakeResponse -ExitCode 1 -ErrorText 'ERROR: Not Found({"Code":"ResourceNotFound"})'
+ }
+ return & $original $arguments
+ }.GetNewClosure()
+ $report = Invoke-AbRun $c
+ Assert-Test ($report.exitCode -eq 1 -and $report.cleanup.status -eq 'Complete')
+ Assert-Test ($null -eq $suite.originVisibility.firstVisibleUtc)
+ Assert-Test ($suite.originVisibility.samples[0].cliExitCode -eq 0 -and $suite.originVisibility.samples[0].armExitCode -eq 1)
+ Assert-Test (@($report.caseHistory.calls | Where-Object originCollectionFallback -eq $true).Count -ge 1)
+ Assert-Test ([IO.File]::ReadAllText((Join-Path $c.RunDirectory 'events.jsonl')).Contains('"originCollectionFallback":true'))
+ }
+ Test-Case 'origin probe does not retry authorization failures or accept a different resource' {
+ foreach ($badResponse in @(
+ (New-FakeResponse -ExitCode 1 -ErrorText 'ERROR: (Forbidden) Denied.'),
+ (New-FakeResponse @{ id = 'wrong-resource' }))) {
+ $c = New-TestContext -Profile OriginVisibility
+ $suite = New-AbSuite $c Windows
+ $c.Manifest.suites = @($suite)
+ $state = Set-FakeAzure $c $suite
+ $original = $c.Executor
+ $originId = $suite.appId + '/silos/origin'
+ $c.Executor = {
+ param($arguments)
+ if (($arguments -join ' ').StartsWith('webapp silo show ') -and (Get-FakeArgument $arguments '--ids') -eq $originId) {
+ return $badResponse
+ }
+ return & $original $arguments
+ }.GetNewClosure()
+ $report = Invoke-AbRun $c
+ Assert-Test ($report.exitCode -eq 1 -and $report.cleanup.status -eq 'Complete')
+ Assert-Test ($null -eq $suite.originVisibility.firstVisibleUtc -and -not $state.groupExists)
+ }
+ }
+ Test-Case 'Smoke fake flow validates markers, async receipts and cleanup order' {
+ $c = New-TestContext
+ $suite = New-AbSuite $c Windows
+ $c.Manifest.suites = @($suite)
+ $state = Set-FakeAzure $c $suite
+ $report = Invoke-AbRun $c
+ Assert-Test ($report.exitCode -eq 0)
+ Assert-Test ($report.cleanup.status -eq 'Complete' -and -not $state.groupExists)
+ $deleteCalls = @($state.calls | Where-Object { $_ -match '^(webapp silo delete|appservice plan silo delete|group delete)' })
+ Assert-Test ($deleteCalls[0].StartsWith('webapp silo delete')) ('Fake cleanup ordering: ' + ($deleteCalls -join '; '))
+ Assert-Test ($deleteCalls[-1].StartsWith('group delete'))
+ Assert-Test (-not $report.coverage.exhaustiveLiveCoverage)
+ $evidence = [IO.File]::ReadAllText((Join-Path $c.RunDirectory 'results.json')) + [IO.File]::ReadAllText((Join-Path $c.RunDirectory 'events.jsonl'))
+ Assert-Test (-not $evidence.Contains('synthetic-do-not-persist'))
+ }
+ Test-Case 'snapshot point reads use returned identifiers rather than a resource-type name' {
+ foreach ($useProperty in @($true, $false)) {
+ $c = New-TestContext -Profile Core
+ $suite = New-AbSuite $c Windows
+ $c.Manifest.suites = @($suite)
+ $state = Set-FakeAzure $c $suite
+ $state.snapshotIds = @()
+ $entry = @{ id = ($suite.siteSiloId + '/config/snapshot/22190800')
+ name = 'Microsoft.Web/sites/config/snapshot'; properties = @{} }
+ if ($useProperty) { $entry.id = '22190800'; $entry.properties.snapshotId = 22190800 }
+ $original = $c.Executor
+ $c.Executor = {
+ param($arguments)
+ $joined = $arguments -join ' '
+ if ($joined.StartsWith('webapp silo config snapshot list ')) {
+ return New-FakeResponse @{ value = @($entry) }
+ }
+ if ($joined.StartsWith('webapp silo config snapshot show ')) {
+ $state.snapshotIds += @(Get-FakeArgument $arguments '--snapshot-id')
+ return New-FakeResponse @{ properties = @{} }
+ }
+ return & $original $arguments
+ }.GetNewClosure()
+ Invoke-AbSafeReads $c $suite
+ Assert-Test (@($c.Manifest.cases | Where-Object { $_.id -like '*.snapshot-point' -and $_.status -eq 'Pass' }).Count -eq 1)
+ Assert-Test ($state.snapshotIds.Count -eq 2 -and @($state.snapshotIds | Where-Object { $_ -ne '22190800' }).Count -eq 0)
+ }
+ }
+ Test-Case 'Core fake flow checks live behavior without contacting Azure' {
+ $c = New-TestContext -Profile Core
+ $suite = New-AbSuite $c Windows
+ $c.Manifest.suites = @($suite)
+ $state = Set-FakeAzure $c $suite
+ $report = Invoke-AbRun $c
+ Assert-Test ($report.exitCode -eq 0) ("Core cases: " + (ConvertTo-Json -InputObject @($c.Manifest.cases | Where-Object status -eq 'Fail') -Depth 10))
+ Assert-Test ($state.primaryGeneration -eq 'v3' -and $state.siloGeneration -eq 'v3')
+ Assert-Test ($state.origin -eq 'Enabled' -and -not $state.groupExists)
+ }
+ Test-Case 'a primary hostname returned as a silo cannot produce a false green run' {
+ $c = New-TestContext
+ $suite = New-AbSuite $c Windows
+ $c.Manifest.suites = @($suite)
+ $state = Set-FakeAzure $c $suite
+ $original = $c.Executor
+ $c.Executor = {
+ param($arguments)
+ $result = & $original $arguments
+ if (($arguments -join ' ').StartsWith('webapp silo show ') -and $result.exitCode -eq 0) {
+ $wrong = ConvertFrom-Json -InputObject $result.stdout -AsHashtable
+ $wrong.properties.defaultHostName = 'primary.example.test'
+ return New-FakeResponse $wrong
+ }
+ return $result
+ }.GetNewClosure()
+ $report = Invoke-AbRun $c
+ Assert-Test ($report.exitCode -eq 1 -and $report.cleanup.status -eq 'Complete')
+ Assert-Test (@($report.cases | Where-Object { $_.id -like '*.site-read' -and $_.status -eq 'Fail' }).Count -eq 1)
+ Assert-Test (-not $state.groupExists)
+ }
+ Test-Case 'service flavor rejection is an explicit environment blocker, never a fallback' {
+ $c = New-TestContext
+ $c.OperatingSystem = 'Linux'; $c.Manifest.operatingSystem = 'Linux'
+ $suite = New-AbSuite $c Linux
+ $c.Manifest.suites = @($suite)
+ $state = Set-FakeAzure $c $suite
+ $original = $c.Executor
+ $c.Executor = {
+ param($arguments)
+ if (($arguments -join ' ').StartsWith('appservice plan silo create ')) {
+ return New-FakeResponse -ExitCode 1 -ErrorText "ERROR: (BadRequest) The base App Service plan app flavor 'Linux' is not supported for Active Backup silos."
+ }
+ return & $original $arguments
+ }.GetNewClosure()
+ $report = Invoke-AbRun $c
+ Assert-Test ($report.exitCode -eq 2 -and $report.cleanup.status -eq 'Complete')
+ Assert-Test (@($report.cases | Where-Object status -eq 'BlockedEnvironment').Count -eq 1)
+ Assert-Test (-not $state.groupExists)
+ Assert-Test (@($state.calls | Where-Object { $_ -match 'webapp silo create' }).Count -eq 0)
+ }
+ Test-Case 'Windows and Linux suites clean up serially before the next allocation' {
+ $c = New-TestContext
+ $c.OperatingSystem = 'Both'; $c.Manifest.operatingSystem = 'Both'
+ $win = New-AbSuite $c Windows
+ $linux = New-AbSuite $c Linux
+ $c.Manifest.suites = @($win, $linux)
+ $winState = Set-FakeAzure $c $win
+ $winExecutor = $c.Executor
+ $winHttp = $c.HttpExecutor
+ $linuxState = Set-FakeAzure $c $linux
+ $linuxExecutor = $c.Executor
+ $linuxHttp = $c.HttpExecutor
+ $c.Executor = {
+ param($arguments)
+ if (($arguments -join ' ').Contains($linux.group)) {
+ if ($winState.groupExists) { throw 'Linux started before Windows cleanup.' }
+ return & $linuxExecutor $arguments
+ }
+ return & $winExecutor $arguments
+ }.GetNewClosure()
+ $c.HttpExecutor = {
+ param($suite, $hostname)
+ if ($suite.key -eq $linux.key) { return & $linuxHttp $suite $hostname }
+ return & $winHttp $suite $hostname
+ }.GetNewClosure()
+ $report = Invoke-AbRun $c
+ Assert-Test ($report.exitCode -eq 0 -and -not $winState.groupExists -and -not $linuxState.groupExists)
+ Assert-Test (@($report.suites | Where-Object cleanupComplete).Count -eq 2)
+ Assert-Test (@($linuxState.calls | Where-Object { $_ -match 'webapp create.+--runtime PYTHON:3.12.+--startup-file' }).Count -eq 1)
+ }
+ Test-Case 'Extended zone testing honors the explicit two-worker envelope and restores it' {
+ $c = New-TestContext -Profile Extended
+ $c.Manifest.options.includeZoneRedundancy = $true
+ $suite = New-AbSuite $c Windows
+ $c.Manifest.suites = @($suite)
+ $state = Set-FakeAzure $c $suite
+ $report = Invoke-AbRun $c
+ Assert-Test ($report.exitCode -eq 0)
+ Assert-Test ($state.workers -eq 1 -and -not $state.zone -and -not $state.groupExists)
+ Assert-Test (@($c.Manifest.cases | Where-Object { $_.id -like '*.zone-redundancy' -and $_.status -eq 'Pass' }).Count -eq 1)
+ }
+ Test-Case 'explicit SKU variants remain serial and clean every prior pair' {
+ $c = New-TestContext
+ $c.Manifest.options.includeSkuMatrix = $true
+ $fixtures = @{}
+ foreach ($sku in @('P1V3', 'P1MV3', 'P1V4', 'P1MV4')) {
+ $suite = New-AbSuite $c Windows $sku
+ $c.Manifest.suites += @($suite)
+ $state = Set-FakeAzure $c $suite
+ $fixtures[$suite.key] = @{ suite = $suite; state = $state; executor = $c.Executor; http = $c.HttpExecutor }
+ }
+ $c.Executor = {
+ param($arguments)
+ $joined = $arguments -join ' '
+ $selected = @($fixtures.Values | Where-Object { $joined.Contains($_.suite.group) })
+ if ($selected.Count -ne 1) { throw 'The fake command did not select exactly one SKU fixture.' }
+ if ($joined.StartsWith('group create ')) {
+ if (@($fixtures.Values | Where-Object { $_.state.groupExists }).Count) { throw 'A new SKU started before previous cleanup.' }
+ }
+ return & $selected[0].executor $arguments
+ }.GetNewClosure()
+ $c.HttpExecutor = {
+ param($suite, $hostname)
+ return & $fixtures[$suite.key].http $suite $hostname
+ }.GetNewClosure()
+ $report = Invoke-AbRun $c
+ Assert-Test ($report.exitCode -eq 0 -and $report.suites.Count -eq 4)
+ Assert-Test (@($fixtures.Values | Where-Object { $_.state.groupExists }).Count -eq 0)
+ }
+ Test-Case 'resume refuses an uncertain non-idempotent action' {
+ $c = New-TestContext -Mode Resume
+ $c.Manifest.pendingMutation = @{ replay = 'NoReplay'; case = 'refresh'; resourceId = ''; command = 'webapp silo refresh' }
+ Assert-Throws { Invoke-AbRun $c } 'not replayed'
+ }
+ Test-Case 'resume never repeats a successful action in a later-failed case' {
+ $c = New-TestContext -Mode Resume
+ $c.Manifest.cases = @(@{
+ id = 'refresh'; required = $true; status = 'Fail'; evidenceKind = 'Behavioral'
+ calls = @(@{ command = 'webapp silo refresh'; exitCode = 0; apiKeys = @('C14'); mutationReplay = 'NoReplay' })
+ reason = 'later assertion failed'
+ })
+ Assert-Throws { Invoke-AbRun $c } 'not replayed'
+ }
+ Test-Case 'resume reconciles failed creates without repeating passed deployment actions' {
+ $c = New-TestContext
+ $suite = New-AbSuite $c Windows
+ $c.Manifest.suites = @($suite)
+ $state = Set-FakeAzure $c $suite
+ $original = $c.Executor
+ $gate = @{ reject = $true }
+ $c.Executor = {
+ param($arguments)
+ if (($arguments -join ' ').StartsWith('webapp silo create ') -and $gate.reject) {
+ return New-FakeResponse -ExitCode 1 -ErrorText 'ERROR: (TemporaryFailure) Synthetic create failure.'
+ }
+ return & $original $arguments
+ }.GetNewClosure()
+ $retained = Invoke-AbRun $c -KeepResourcesOnFailure
+ Assert-Test ($retained.exitCode -eq 3 -and $state.groupExists)
+ $deployments = @($state.calls | Where-Object { $_.StartsWith('webapp deploy ') }).Count
+ $gate.reject = $false
+ $c.Mode = 'Resume'
+ $resumed = Invoke-AbRun $c
+ Assert-Test ($resumed.exitCode -eq 0 -and -not $state.groupExists)
+ Assert-Test (@($state.calls | Where-Object { $_.StartsWith('webapp deploy ') }).Count -eq $deployments)
+ Assert-Test ($resumed.caseHistoryCount -gt $resumed.cases.Count)
+ }
+ Test-Case 'completed cleanup returns success without erasing original test failures' {
+ $c = New-TestContext
+ $c.Manifest.cases = @(@{ id = 'failure'; status = 'Fail'; required = $true; evidenceKind = 'Behavioral'; calls = @(); reason = 'test' })
+ $c.Mode = 'Cleanup'
+ $c.Manifest.cleanup.status = 'Complete'
+ $report = Write-AbReport $c
+ Assert-Test ($report.exitCode -eq 0 -and @($report.cases | Where-Object status -eq 'Fail').Count -eq 1)
+ }
+ Test-Case 'cleanup failure has precedence over a test failure' {
+ $c = New-TestContext
+ $c.Manifest.cases = @(@{ id = 'failure'; status = 'Fail'; required = $true; evidenceKind = 'Behavioral'; calls = @(); reason = 'test' })
+ $c.Manifest.cleanup.status = 'Failed'
+ Assert-Test ((Get-AbExitCode $c) -eq 3)
+ $report = Write-AbReport $c
+ Assert-Test ($report.exitCode -eq 3 -and -not $report.coverage.exhaustiveLiveCoverage)
+ }
+}
+finally {
+ foreach ($context in $contexts) { if ($context.Lock) { $context.Lock.Dispose() } }
+ if (Test-Path -LiteralPath $testRoot) {
+ $verified = Assert-AbLocalPath $repo $testRoot -RequireIgnored
+ if ([IO.Path]::GetFileName($verified) -ne $testId) { throw 'Test cleanup ownership mismatch.' }
+ Remove-Item -LiteralPath $verified -Recurse -Force
+ }
+}
+
+Write-Host "Offline harness tests: $script:passed passed; $script:failed failed. No Azure requests were made."
+if ($script:failed) { exit 1 }
From d9e85cf592cdf0c6d874f05cd6fc8fcf43854b3c Mon Sep 17 00:00:00 2001
From: Your Name
Date: Tue, 20 Oct 2026 10:41:21 -0500
Subject: [PATCH 4/6] Fix Active Backup defaults, pagination, and mutation
journals
Preserve silo regions despite configured location defaults, paginate actual ARM collections without flattening property bags, and clear completed mutation journal state regardless of validated output shape. Add parser/output/pagination and safe-resume regressions; keep backend errors and delete-404 policy unchanged.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 224abf0a-0923-4347-a5ea-5c4a1b4e4824
---
doc/active_backup_preview.md | 10 ++
.../appservice/ActiveBackupTestHarness.psm1 | 8 +-
scripts/appservice/README.md | 6 +
.../tests/Test-ActiveBackupHarness.ps1 | 54 +++++++
.../appservice/_active_backup_commands.py | 4 +-
.../appservice/active_backup.py | 41 +++---
.../test_active_backup_commands_thru_mock.py | 139 +++++++++++++++++-
7 files changed, 235 insertions(+), 27 deletions(-)
diff --git a/doc/active_backup_preview.md b/doc/active_backup_preview.md
index 79fd2760353..7969fac8358 100644
--- a/doc/active_backup_preview.md
+++ b/doc/active_backup_preview.md
@@ -59,6 +59,10 @@ non-create commands also accept a complete nested silo ARM ID through `--ids`;
do not combine it with named resource arguments. No internal replica app or plan
name is required.
+Create requires an explicit target `--location`; a configured default location
+does not choose the silo region. Update preserves the existing silo location when
+`--location` is omitted, even if a different CLI default location is configured.
+
Create/update/delete wait for their API operation, not future content/configuration
revision, probe-health, or DNS convergence. `--no-wait` returns an accepted-operation
receipt when the service starts an asynchronous operation.
@@ -162,6 +166,12 @@ extension invocation, network checks/traces, metrics, antivirus, and site-contai
reads. There is **no CLI-side OS/SKU/flavor/provisioning filter**: the requested silo
route is called directly, and service errors such as 404 are surfaced normally.
+ARM collection list commands follow `nextLink` and return the item array, so
+`--query "[].name"` and table output include all pages. Property-bag responses,
+such as app settings, connection strings, storage accounts and metadata, retain
+their service shape. The startup-log response also retains its file/latest-log
+wrapper rather than being flattened into an ARM collection.
+
```azurecli
az webapp silo config show -g MyGroup -n MyApp --silo WestApp
az webapp silo config appsettings list -g MyGroup -n MyApp --silo WestApp
diff --git a/scripts/appservice/ActiveBackupTestHarness.psm1 b/scripts/appservice/ActiveBackupTestHarness.psm1
index 69537485303..5c92ab68a60 100644
--- a/scripts/appservice/ActiveBackupTestHarness.psm1
+++ b/scripts/appservice/ActiveBackupTestHarness.psm1
@@ -519,10 +519,10 @@ function Invoke-AbAz {
response = Get-AbSiloResponseMetadata $Context $result.json
}
}
- if ($Mutation -and -not $result.timedOut -and $result.exitCode -eq 0) {
- $Context.Manifest.pendingMutation = $null
- Save-AbManifest $Context
- }
+ }
+ if ($Mutation -and -not $result.timedOut -and $result.exitCode -eq 0) {
+ $Context.Manifest.pendingMutation = $null
+ Save-AbManifest $Context
}
return $result
}
diff --git a/scripts/appservice/README.md b/scripts/appservice/README.md
index cbfa87ca176..95b15b5a75d 100644
--- a/scripts/appservice/README.md
+++ b/scripts/appservice/README.md
@@ -301,6 +301,12 @@ Resume does not recreate cleaned fixtures or blindly replay uncertain deployment
refresh, mark-LKG, or other non-idempotent actions. In those cases, investigate or
clean up and start a fresh run.
+Successful commands clear their pending-mutation journal entry after output
+validation, including empty refresh results and text output. Failed, timed-out,
+or invalid-output mutations remain uncertain. A completed refresh case can be
+resumed without issuing refresh again; an incomplete case containing a
+non-idempotent action still requires investigation or cleanup.
+
The operation, observation, run and cleanup timeout parameters bound orchestration,
not billing. A host/power failure can leave resources until the recovery command
is run; expiry tags are not an automatic reaper.
diff --git a/scripts/appservice/tests/Test-ActiveBackupHarness.ps1 b/scripts/appservice/tests/Test-ActiveBackupHarness.ps1
index bb260d9de85..8b93f0a0fcc 100644
--- a/scripts/appservice/tests/Test-ActiveBackupHarness.ps1
+++ b/scripts/appservice/tests/Test-ActiveBackupHarness.ps1
@@ -488,6 +488,48 @@ try {
Assert-Throws { Invoke-AbAz $c @('webapp', 'silo', 'show') } 'ResourceNotFound'
Assert-Test (-not ([IO.File]::ReadAllText((Join-Path $c.RunDirectory 'events.jsonl'))).Contains('synthetic-secret'))
}
+ Test-Case 'successful validated mutations clear pending state for every output shape' {
+ foreach ($shape in @('Empty', 'Text', 'Json')) {
+ $c = New-TestContext
+ $suite = New-AbSuite $c Windows
+ $suite.intents = @($suite.appId)
+ $c.Manifest.suites = @($suite)
+ $stdout = $(if ($shape -eq 'Text') { 'accepted' } elseif ($shape -eq 'Json') { '{"accepted":true}' } else { '' })
+ $c.Executor = {
+ param($arguments)
+ return @{ exitCode = 0; stdout = $stdout; stderr = ''; elapsedMs = 1; timedOut = $false }
+ }.GetNewClosure()
+ Invoke-AbAz $c @('webapp', 'silo', 'refresh', '--ids', $suite.appId) -OutputShape $shape `
+ -Mutation -Replay NoReplay -IntentId $suite.appId | Out-Null
+ Assert-Test ($null -eq $c.Manifest.pendingMutation) "$shape success left an uncertain mutation."
+ $saved = ConvertFrom-Json -InputObject ([IO.File]::ReadAllText($c.ManifestPath)) -AsHashtable
+ Assert-Test ($null -eq $saved.pendingMutation) "$shape success did not persist its completed state."
+ }
+ }
+ Test-Case 'failed timed-out and invalid-output mutations retain uncertain state' {
+ foreach ($sample in @(
+ @{ shape = 'Empty'; exitCode = 1; stdout = ''; timedOut = $false },
+ @{ shape = 'Empty'; exitCode = 124; stdout = ''; timedOut = $true },
+ @{ shape = 'Empty'; exitCode = 0; stdout = 'unexpected output'; timedOut = $false },
+ @{ shape = 'Json'; exitCode = 0; stdout = 'invalid JSON'; timedOut = $false })) {
+ $c = New-TestContext
+ $suite = New-AbSuite $c Windows
+ $suite.intents = @($suite.appId)
+ $c.Manifest.suites = @($suite)
+ $c.Executor = {
+ param($arguments)
+ return @{ exitCode = $sample.exitCode; stdout = $sample.stdout; stderr = ''
+ elapsedMs = 1; timedOut = $sample.timedOut }
+ }.GetNewClosure()
+ Assert-Throws {
+ Invoke-AbAz $c @('webapp', 'silo', 'refresh', '--ids', $suite.appId) -OutputShape $sample.shape `
+ -Mutation -Replay NoReplay -IntentId $suite.appId
+ }
+ Assert-Test ($c.Manifest.pendingMutation.command -eq 'webapp silo refresh')
+ $saved = ConvertFrom-Json -InputObject ([IO.File]::ReadAllText($c.ManifestPath)) -AsHashtable
+ Assert-Test ($saved.pendingMutation.replay -eq 'NoReplay')
+ }
+ }
Test-Case 'recovery accepts only the exact generated VNet intent, not sibling resources' {
$c = New-TestContext -Profile Core
$suite = New-AbSuite $c Windows
@@ -928,6 +970,18 @@ try {
$c.Manifest.pendingMutation = @{ replay = 'NoReplay'; case = 'refresh'; resourceId = ''; command = 'webapp silo refresh' }
Assert-Throws { Invoke-AbRun $c } 'not replayed'
}
+ Test-Case 'resume accepts a completed empty refresh without replaying it' {
+ $c = New-TestContext
+ $suite = New-AbSuite $c Windows
+ $c.Manifest.suites = @($suite)
+ $state = Set-FakeAzure $c $suite
+ Invoke-AbSmoke $c $suite
+ Assert-Test ($null -eq $c.Manifest.pendingMutation)
+ $c.Mode = 'Resume'
+ $report = Invoke-AbRun $c
+ Assert-Test ($report.exitCode -eq 0)
+ Assert-Test (@($state.calls | Where-Object { $_.StartsWith('webapp silo refresh ') }).Count -eq 1)
+ }
Test-Case 'resume never repeats a successful action in a later-failed case' {
$c = New-TestContext -Mode Resume
$c.Manifest.cases = @(@{
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py b/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py
index cabd587922e..9c71ae88b14 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py
@@ -167,7 +167,9 @@ def load_arguments(loader, command):
if argument == 'number_of_workers':
settings.update(default=None, help='Silo worker-count override, from 1 to 30. Omit to preserve inheritance.')
if argument == 'location':
- settings['help'] = 'Target silo region. Required on create; never inferred from the resource group.'
+ settings.update(
+ configured_default=None, local_context_attribute=None,
+ help='Target silo region. Required on create; preserved from the existing silo on update.')
if argument == 'primary_location':
settings['help'] = 'Primary region. On create, resolved from the primary resource when omitted.'
if argument == 'timeout':
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/active_backup.py b/src/azure-cli/azure/cli/command_modules/appservice/active_backup.py
index 70d7de45e52..7e782f2a7e2 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/active_backup.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/active_backup.py
@@ -264,9 +264,11 @@ def remove_silo_vnet_integration(cmd, resource_group_name, name, silo):
def _site(cmd, resource_group_name, name, silo, suffix='', method='GET', query=None, body=None,
- filename=None, overwrite=False, tail=False, content_type=None, no_wait=False, timeout=None):
+ filename=None, overwrite=False, tail=False, content_type=None, no_wait=False, timeout=None, collection=False):
client = _client(cmd, resource_group_name, name)
path = client.resource(silo) + suffix
+ if collection:
+ return client.list(path, query)
if filename is not None or tail:
return client.consume(method, path, filename, overwrite, query, body, content_type, tail)
if timeout is not None:
@@ -287,7 +289,7 @@ def show_silo_php_logging(cmd, resource_group_name, name, silo):
def list_silo_deployments(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/deployments')
+ return _site(cmd, resource_group_name, name, silo, '/deployments', collection=True)
def show_silo_deployment(cmd, resource_group_name, name, silo, deployment_id):
@@ -316,7 +318,7 @@ def show_silo_process(cmd, resource_group_name, name, silo, process_id, instance
def list_silo_process_threads(cmd, resource_group_name, name, silo, process_id, instance=None):
- return _site(cmd, resource_group_name, name, silo, _process(process_id, instance) + '/threads')
+ return _site(cmd, resource_group_name, name, silo, _process(process_id, instance) + '/threads', collection=True)
def show_silo_process_thread(cmd, resource_group_name, name, silo, process_id, thread_id, instance=None):
@@ -325,7 +327,7 @@ def show_silo_process_thread(cmd, resource_group_name, name, silo, process_id, t
def list_silo_process_modules(cmd, resource_group_name, name, silo, process_id, instance=None):
- return _site(cmd, resource_group_name, name, silo, _process(process_id, instance) + '/modules')
+ return _site(cmd, resource_group_name, name, silo, _process(process_id, instance) + '/modules', collection=True)
def show_silo_process_module(cmd, resource_group_name, name, silo, process_id, base_address, instance=None):
@@ -371,7 +373,7 @@ def _webjob(webjob_name, kind='webjobs'):
def list_silo_webjobs(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/webjobs')
+ return _site(cmd, resource_group_name, name, silo, '/webjobs', collection=True)
def show_silo_webjob(cmd, resource_group_name, name, silo, webjob_name):
@@ -379,7 +381,7 @@ def show_silo_webjob(cmd, resource_group_name, name, silo, webjob_name):
def list_silo_continuous_webjobs(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/continuouswebjobs')
+ return _site(cmd, resource_group_name, name, silo, '/continuouswebjobs', collection=True)
def show_silo_continuous_webjob(cmd, resource_group_name, name, silo, webjob_name):
@@ -397,7 +399,7 @@ def stop_silo_continuous_webjob(cmd, resource_group_name, name, silo, webjob_nam
def list_silo_triggered_webjobs(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/triggeredwebjobs')
+ return _site(cmd, resource_group_name, name, silo, '/triggeredwebjobs', collection=True)
def show_silo_triggered_webjob(cmd, resource_group_name, name, silo, webjob_name):
@@ -405,7 +407,8 @@ def show_silo_triggered_webjob(cmd, resource_group_name, name, silo, webjob_name
def list_silo_webjob_history(cmd, resource_group_name, name, silo, webjob_name):
- return _site(cmd, resource_group_name, name, silo, _webjob(webjob_name, 'triggeredwebjobs') + '/history')
+ return _site(cmd, resource_group_name, name, silo, _webjob(webjob_name, 'triggeredwebjobs') + '/history',
+ collection=True)
def show_silo_webjob_history(cmd, resource_group_name, name, silo, webjob_name, run_id):
@@ -419,7 +422,7 @@ def run_silo_triggered_webjob(cmd, resource_group_name, name, silo, webjob_name)
def list_silo_site_extensions(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/siteextensions')
+ return _site(cmd, resource_group_name, name, silo, '/siteextensions', collection=True)
def show_silo_site_extension(cmd, resource_group_name, name, silo, extension_name):
@@ -480,7 +483,7 @@ def show_silo_msdeploy_log(cmd, resource_group_name, name, silo, instance=None):
def list_silo_instances(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/instances')
+ return _site(cmd, resource_group_name, name, silo, '/instances', collection=True)
def show_silo_instance(cmd, resource_group_name, name, silo, instance):
@@ -492,11 +495,11 @@ def show_silo_config(cmd, resource_group_name, name, silo):
def list_silo_config(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/config')
+ return _site(cmd, resource_group_name, name, silo, '/config', collection=True)
def list_silo_config_snapshots(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/config/web/snapshots')
+ return _site(cmd, resource_group_name, name, silo, '/config/web/snapshots', collection=True)
def show_silo_config_snapshot(cmd, resource_group_name, name, silo, snapshot_id, include_secrets=False):
@@ -506,7 +509,7 @@ def show_silo_config_snapshot(cmd, resource_group_name, name, silo, snapshot_id,
def list_silo_keyvault_references(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/config/configreferences/connectionstrings')
+ return _site(cmd, resource_group_name, name, silo, '/config/configreferences/connectionstrings', collection=True)
def refresh_silo_keyvault_references(cmd, resource_group_name, name, silo, force_restart=None):
@@ -558,7 +561,7 @@ def show_silo_log_config(cmd, resource_group_name, name, silo):
def list_silo_premier_addons(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/premieraddons')
+ return _site(cmd, resource_group_name, name, silo, '/premieraddons', collection=True)
def show_silo_premier_addon(cmd, resource_group_name, name, silo, addon_name):
@@ -570,18 +573,18 @@ def list_silo_metrics(cmd, resource_group_name, name, silo, start_time=None, end
query = _metric_query(start_time, end_time, time_grain, names, filter_string)
query['details'] = _boolean(details)
query['slotView'] = _boolean(slot_view)
- return _site(cmd, resource_group_name, name, silo, '/metrics', query=query)
+ return _site(cmd, resource_group_name, name, silo, '/metrics', query=query, collection=True)
def list_silo_metric_definitions(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/metricdefinitions')
+ return _site(cmd, resource_group_name, name, silo, '/metricdefinitions', collection=True)
def list_silo_perf_counters(
cmd, resource_group_name, name, silo, start_time=None, end_time=None,
time_grain=None, names=None, filter_string=None):
return _site(cmd, resource_group_name, name, silo, '/perfcounters',
- query=_metric_query(start_time, end_time, time_grain, names, filter_string))
+ query=_metric_query(start_time, end_time, time_grain, names, filter_string), collection=True)
def restart_site_silo(
@@ -648,11 +651,11 @@ def show_silo_antivirus_log(cmd, resource_group_name, name, silo, scan_id, inclu
def list_silo_deployment_statuses(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/deploymentStatus')
+ return _site(cmd, resource_group_name, name, silo, '/deploymentStatus', collection=True)
def list_silo_sitecontainers(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/sitecontainers')
+ return _site(cmd, resource_group_name, name, silo, '/sitecontainers', collection=True)
def show_silo_sitecontainer(cmd, resource_group_name, name, silo, container_name):
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py
index 35725a57c50..f14fa7c0977 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py
@@ -152,6 +152,11 @@
{'silo': 'backup'}),
)
+PAGED_COLLECTION_APIS = {
+ 'P02', 'P09', 'P11', 'P21', 'P23', 'P27', 'P29', 'P32', 'P40',
+ 'P43', 'P44', 'P47', 'P62', 'P64', 'P65', 'P66', 'P82', 'P83',
+}
+
class ActiveBackupCommandTests(unittest.TestCase):
@@ -192,7 +197,8 @@ def test_all_84_pass_through_methods_have_exact_route_and_no_preflight(self):
getattr(commands, handler)(self.cmd, 'group', 'primary', 'backup', **kwargs)
call = consume.call_args if consume.called else self.request.call_args
self.assertIsNotNone(call)
- self.assertEqual(call.args[:2], (method, self.client.resource('backup') + suffix))
+ self.assertEqual(call.args[0], method)
+ self.assertEqual(urlsplit(call.args[1]).path, self.client.resource('backup') + suffix)
self.assertEqual(self.request.call_count + consume.call_count, 1)
primary.assert_not_called()
self.assertIn(handler, registered)
@@ -206,7 +212,61 @@ def test_instance_qualified_process_and_live_log_routes(self):
getattr(commands, handler)(self.cmd, 'group', 'primary', 'backup',
**dict(kwargs, instance='worker'))
call = consume.call_args if consume.called else self.request.call_args
- self.assertEqual(call.args[:2], (method, self.client.resource('backup') + '/instances/worker' + suffix))
+ self.assertEqual(call.args[0], method)
+ self.assertEqual(urlsplit(call.args[1]).path,
+ self.client.resource('backup') + '/instances/worker' + suffix)
+
+ def test_every_paged_pass_through_returns_all_collection_items(self):
+ for key, handler, _, suffix, kwargs in PASS_THROUGH_CASES:
+ if key not in PAGED_COLLECTION_APIS:
+ continue
+ with self.subTest(api=key):
+ self.request.reset_mock()
+ path = self.client.resource('backup') + suffix
+ next_link = path + '?api-version=' + API_VERSION + '&page=2'
+ items = [{'name': 'first', 'properties': {'unknownField': 1}},
+ {'name': 'second', 'properties': {'unknownField': 2}}]
+ self.request.side_effect = [
+ response(payload={'value': items[:1], 'nextLink': next_link}),
+ response(payload={'value': items[1:]})]
+ self.assertEqual(getattr(commands, handler)(self.cmd, 'group', 'primary', 'backup', **kwargs), items)
+ self.assertEqual(self.request.call_count, 2)
+ self.assertEqual(self.request.call_args.args, ('GET', self.client.url(next_link)))
+
+ def test_collection_pagination_failure_does_not_return_partial_success(self):
+ self.request.side_effect = [
+ response(payload={'value': [{'name': 'first'}], 'nextLink': self.client.resource('backup') + '?page=2'}),
+ ResourceNotFoundError('Second page missing')]
+ with self.assertRaises(ResourceNotFoundError):
+ commands.list_silo_instances(self.cmd, 'group', 'primary', 'backup')
+ self.assertEqual(self.request.call_count, 2)
+
+ def test_collection_query_parameters_are_not_reapplied_to_next_link(self):
+ next_link = self.client.resource('backup') + '/metrics?api-version=' + API_VERSION + '&page=2'
+ self.request.side_effect = [
+ response(payload={'value': [], 'nextLink': next_link}), response(payload={'value': []})]
+ self.assertEqual(commands.list_silo_metrics(
+ self.cmd, 'group', 'primary', 'backup', names=['Requests'], time_grain='PT1H',
+ details=False, slot_view=True), [])
+ first_query = parse_qs(urlsplit(self.request.call_args_list[0].args[1]).query)
+ self.assertIn("name.value eq 'Requests'", first_query['$filter'][0])
+ self.assertEqual(first_query['details'], ['false'])
+ self.assertEqual(first_query['slotView'], ['true'])
+ self.assertEqual(parse_qs(urlsplit(self.request.call_args.args[1]).query),
+ {'api-version': [API_VERSION], 'page': ['2']})
+
+ def test_property_bags_and_startup_log_wrappers_are_not_flattened(self):
+ bag = {'properties': {'value': 'setting value', 'other': False}, 'unknownField': 'preserved'}
+ startup = {'files': [{'name': 'startup.log'}], 'nextToken': 'preserved'}
+ for handler, payload in (
+ (commands.list_silo_appsettings, bag), (commands.list_silo_storage_accounts, bag),
+ (commands.list_silo_connection_strings, bag), (commands.list_silo_metadata, bag),
+ (commands.list_silo_startup_logs, startup)):
+ with self.subTest(handler=handler.__name__):
+ self.request.reset_mock()
+ self.request.return_value = response(payload=payload)
+ self.assertEqual(handler(self.cmd, 'group', 'primary', 'backup'), payload)
+ self.request.assert_called_once()
def test_extension_verbs_bodies_and_path_validation(self):
for method in ('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'):
@@ -376,12 +436,16 @@ class ActiveBackupHelpTests(unittest.TestCase):
@staticmethod
def invoke_json(arguments):
+ return ActiveBackupHelpTests.invoke_output(arguments)
+
+ @staticmethod
+ def invoke_output(arguments, output_format='json'):
from azure.cli.core import get_default_cli
stdout = io.StringIO()
stderr = io.StringIO()
with redirect_stdout(stdout), redirect_stderr(stderr):
try:
- exit_code = get_default_cli().invoke(arguments + ['--output', 'json'], out_file=stdout)
+ exit_code = get_default_cli().invoke(arguments + ['--output', output_format], out_file=stdout)
except SystemExit as ex:
exit_code = ex.code
if exit_code:
@@ -414,6 +478,75 @@ def test_named_command_executes_exact_silo_request(self):
'webapp silo config show -g group -n primary --silo backup'.split()), 0)
self.assertEqual(request.call_args.args[:2], ('GET', client.resource('backup') + '/config/web'))
+ def test_cli_updates_ignore_configured_location_defaults(self):
+ for group, resource_type in (('appservice plan silo', 'serverfarms'), ('webapp silo', 'sites')):
+ for explicit_location in (False, True):
+ with self.subTest(group=group, explicit_location=explicit_location):
+ client = ActiveBackupClient(
+ cli_context(), 'group', 'primary', resource_type, subscription='sub')
+ resource = {'location': 'westus2', 'properties': {'primaryLocation': 'eastus'}}
+ arguments = (group + ' update -g group -n primary --silo backup').split()
+ arguments += (['--number-of-workers', '2'] if resource_type == 'serverfarms'
+ else ['--endpoint-status', 'Disabled'])
+ if explicit_location:
+ arguments += ['--location', 'westus2']
+ with patch.dict('os.environ', {'AZURE_DEFAULTS_LOCATION': 'eastus'}), \
+ patch(MODULE + '._client', return_value=client), \
+ patch.object(client, 'request', return_value=response(payload=resource)) as request:
+ self.assertEqual(self.invoke(arguments), 0)
+ self.assertEqual(request.call_count, 2)
+ self.assertEqual(request.call_args.args[0], 'PUT')
+ self.assertEqual(request.call_args.args[2]['location'], 'westus2')
+
+ def test_cli_creates_require_an_explicit_silo_location_despite_defaults(self):
+ for group in ('appservice plan silo', 'webapp silo'):
+ with self.subTest(group=group), \
+ patch.dict('os.environ', {'AZURE_DEFAULTS_LOCATION': 'eastus'}), \
+ patch(MODULE + '._client') as factory:
+ self.assertNotEqual(self.invoke(
+ (group + ' create -g group -n primary --silo backup --primary-location eastus').split()), 0)
+ factory.assert_not_called()
+
+ def test_cli_collection_commands_return_arrays_from_all_pages(self):
+ registered = {handler: group + ' ' + verb
+ for group, entries in COMMAND_GROUPS.items() for verb, handler in entries}
+ for key, handler, _, suffix, kwargs in PASS_THROUGH_CASES:
+ if key not in PAGED_COLLECTION_APIS:
+ continue
+ with self.subTest(api=key):
+ client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
+ arguments = registered[handler].split() + ['-g', 'group', '-n', 'primary', '--silo', 'backup']
+ for key_name, value in kwargs.items():
+ arguments += ['--' + key_name.replace('_', '-'), value]
+ with patch(MODULE + '._client', return_value=client), \
+ patch.object(client, 'request', side_effect=[
+ response(payload={'value': [{'name': 'first'}],
+ 'nextLink': client.resource('backup') + suffix + '?page=2'}),
+ response(payload={'value': [{'name': 'second'}]})]) as request:
+ _, output = self.invoke_json(arguments)
+ self.assertEqual(json.loads(output), [{'name': 'first'}, {'name': 'second'}])
+ self.assertEqual(request.call_count, 2)
+
+ def test_cli_collection_table_and_list_queries_include_all_pages(self):
+ for output_format, query in (('table', None), ('json', '[].name')):
+ with self.subTest(output_format=output_format, query=query):
+ client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
+ arguments = 'webapp silo instance list -g group -n primary --silo backup'.split()
+ if query:
+ arguments += ['--query', query]
+ with patch(MODULE + '._client', return_value=client), \
+ patch.object(client, 'request', side_effect=[
+ response(payload={'value': [{'name': 'first'}],
+ 'nextLink': client.resource('backup') + '/instances?page=2'}),
+ response(payload={'value': [{'name': 'second'}]})]) as request:
+ _, output = self.invoke_output(arguments, output_format)
+ self.assertEqual(request.call_count, 2)
+ if query:
+ self.assertEqual(json.loads(output), ['first', 'second'])
+ else:
+ self.assertIn('first', output)
+ self.assertIn('second', output)
+
def test_nested_ids_are_typed_and_do_not_silently_override_names(self):
resource_id = (
'/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/group/'
From fc82d0d105b4185e1bd532b6b9c9acbb8ed4ffd5 Mon Sep 17 00:00:00 2001
From: Your Name
Date: Tue, 20 Oct 2026 12:24:41 -0500
Subject: [PATCH 5/6] Harden Active Backup CLI contracts and validation
recovery
Normalize identity conflicts without private argparse APIs, preserve core HTTP error categories and retry behavior, expose named diagnostic inputs, and author complete help/examples. Protect recovery manifests with initialization/lock guards, ownership reconciliation and snapshot handoff validation. Keep strict delete polling errors; add regression coverage validated by 119 Python tests and 57 offline harness checks.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 224abf0a-0923-4347-a5ea-5c4a1b4e4824
---
doc/active_backup_preview.md | 19 +-
.../appservice/ActiveBackupTestHarness.psm1 | 36 +-
scripts/appservice/README.md | 9 +
scripts/appservice/test_active_backup.ps1 | 6 +-
.../tests/Test-ActiveBackupHarness.ps1 | 84 +++
.../appservice/_active_backup_client.py | 10 +-
.../appservice/_active_backup_commands.py | 673 +++++++++++++++++-
.../appservice/active_backup.py | 79 +-
.../tests/latest/test_active_backup_client.py | 28 +-
.../test_active_backup_commands_thru_mock.py | 155 +++-
10 files changed, 1013 insertions(+), 86 deletions(-)
diff --git a/doc/active_backup_preview.md b/doc/active_backup_preview.md
index 7969fac8358..018548014a7 100644
--- a/doc/active_backup_preview.md
+++ b/doc/active_backup_preview.md
@@ -73,6 +73,10 @@ The receipt contains `resourceId`, `operationId`, `operationUrl`, and
`status: Accepted`. It is returned in the selected output format; `--output none`
explicitly suppresses it.
+No-wait skips subsequent operation polling; the initial request must still
+complete. For restart, use `--synchronous false --no-wait` when you also want
+the service to avoid synchronous restart work in that initial request.
+
```powershell
$operation = az appservice plan silo create -g MyGroup -n MyPlan --silo WestPlan --location westus2 --primary-location eastus --no-wait | ConvertFrom-Json
az appservice plan silo wait -g MyGroup -n MyPlan --silo WestPlan --operation-id $operation.operationId --timeout 7200
@@ -187,7 +191,20 @@ downloads require `--file`; an existing destination requires `--overwrite`.
string/auth/private-snapshot reads intentionally return sensitive values: protect
their output and do not put it in source control.
-Request-body operations accept JSON or `@file` containing the API envelope:
+Use named options for DNS checks, TCP checks and antivirus configuration:
+
+```azurecli
+az webapp silo network-check dns -g MyGroup -n MyApp --silo WestApp --dns-name example.com
+az webapp silo network-check tcp-ping -g MyGroup -n MyApp --silo WestApp --host example.com --port 443
+az webapp silo antivirus config update -g MyGroup -n MyApp --silo WestApp --delete-infected-files false
+```
+
+For those commands, `--body` remains an advanced alternative and cannot be
+combined with the named request fields. TCP ports are integers from 1 to 65535;
+the CLI serializes the port using the service's string-valued field.
+
+Other request-body operations accept JSON or `@file` containing the API envelope.
+The advanced-body forms of the commands above use the same envelopes:
| Operation | Example body |
|---|---|
diff --git a/scripts/appservice/ActiveBackupTestHarness.psm1 b/scripts/appservice/ActiveBackupTestHarness.psm1
index 5c92ab68a60..d76efa9da36 100644
--- a/scripts/appservice/ActiveBackupTestHarness.psm1
+++ b/scripts/appservice/ActiveBackupTestHarness.psm1
@@ -201,7 +201,7 @@ function New-AbContext {
AuthConfigDir = $auth; Catalog = $catalog; Manifest = $manifest; CurrentCase = $null
RunDeadline = [DateTime]::UtcNow.AddMinutes($RunTimeoutMinutes); CleanupDeadline = $null
Executor = $null; HttpExecutor = $null; Lock = $null; ArmEndpoint = 'https://management.azure.com/'
- ApiVersion = $version; Environment = @{}; InCleanup = $false
+ ApiVersion = $version; Environment = @{}; InCleanup = $false; ManifestReadHash = $null
}
}
@@ -234,6 +234,13 @@ function Initialize-AbLocalState {
Protect-AbDirectory (Join-Path $Context.RunDirectory $name)
}
$Context.Lock = [IO.File]::Open((Join-Path $Context.RunDirectory 'run.lock'), 'OpenOrCreate', 'ReadWrite', 'None')
+ if ($Existing) {
+ $currentBytes = [IO.File]::ReadAllBytes($Context.ManifestPath)
+ $currentHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($currentBytes))
+ if (-not $Context.ManifestReadHash -or $currentHash -cne $Context.ManifestReadHash) {
+ Stop-Ab 'The recovery manifest changed before its lock was acquired. Reread the manifest and retry recovery.' Invalid
+ }
+ }
$Context.PythonExe = Resolve-AbPython $Context.RepoRoot $Context.PythonExe
$Context.Manifest.pythonExe = $Context.PythonExe
$Context.Environment = @{
@@ -692,7 +699,21 @@ function Read-AbManifest {
param([string]$RepoRoot, [string]$ManifestPath, [string]$Subscription, [string]$Mode = 'Cleanup')
$path = Assert-AbLocalPath $RepoRoot $ManifestPath -RequireIgnored
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { Stop-Ab 'The manifest file does not exist.' Invalid }
- try { $manifest = ConvertFrom-Json -InputObject ([IO.File]::ReadAllText($path)) -AsHashtable } catch { Stop-Ab 'The manifest is not valid JSON.' Invalid }
+ try {
+ $stream = [IO.File]::Open($path, 'Open', 'Read', ([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete))
+ $buffer = [IO.MemoryStream]::new()
+ try {
+ $stream.CopyTo($buffer)
+ $manifestBytes = $buffer.ToArray()
+ }
+ finally {
+ $stream.Dispose()
+ $buffer.Dispose()
+ }
+ $manifestText = [Text.Encoding]::UTF8.GetString($manifestBytes).TrimStart([char]0xfeff)
+ $manifest = ConvertFrom-Json -InputObject $manifestText -AsHashtable
+ }
+ catch { Stop-Ab 'The manifest could not be read as valid JSON.' Invalid }
if ($manifest.schemaVersion -ne 1 -or $manifest.purpose -ne 'active-backup-cli-validation' -or
$manifest.subscription -ne $Subscription -or
$manifest.repoRoot -ine [IO.Path]::GetFullPath($RepoRoot) -or
@@ -742,6 +763,7 @@ function Read-AbManifest {
}
}
$context.Manifest = $manifest
+ $context.ManifestReadHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($manifestBytes))
$context.ArmEndpoint = [string](Get-AbValue $manifest 'armEndpoint' 'https://management.azure.com/')
if ($context.ArmEndpoint -ne 'https://management.azure.com/') { Stop-Ab 'The saved ARM origin is not the reviewed public cloud.' Invalid }
return $context
@@ -1699,10 +1721,14 @@ function Invoke-AbRun {
$_.status -ne 'Pass' -and @($_.calls | Where-Object { (Get-AbValue $_ 'mutationReplay') -eq 'NoReplay' }).Count
})
if ($unsafeCases.Count) { Stop-Ab 'A non-idempotent action in an incomplete case was already attempted. It was not replayed; inspect or clean up this run.' BlockedFixture }
- if ($null -ne $Context.Manifest.pendingMutation) {
- foreach ($suite in $Context.Manifest.suites) {
- if ($suite.groupCreateAttempted -and -not $suite.cleanupComplete) { Assert-AbOwnedGroup $Context $suite | Out-Null }
+ foreach ($suite in $Context.Manifest.suites) {
+ if ($suite.groupCreateAttempted -and -not $suite.cleanupComplete) {
+ if (-not (Assert-AbOwnedGroup $Context $suite)) {
+ Stop-Ab 'Resume cannot reconcile a missing owned resource group. Use Cleanup or start a new run.' BlockedFixture
+ }
}
+ }
+ if ($null -ne $Context.Manifest.pendingMutation) {
if ($Context.Manifest.pendingMutation.replay -eq 'NoReplay') {
Stop-Ab 'A previous non-idempotent action has an uncertain outcome. It was not replayed. Use ownership-checked Cleanup or investigate the recorded case.' BlockedFixture
}
diff --git a/scripts/appservice/README.md b/scripts/appservice/README.md
index 95b15b5a75d..1512ae1090e 100644
--- a/scripts/appservice/README.md
+++ b/scripts/appservice/README.md
@@ -278,6 +278,12 @@ Replace `` with the printed run; never delete groups by a name
prefix. Cleanup revalidates the actual group tags, subscription and resource IDs
and waits for absence. A source change prevents Resume but does not require
using a stale/broken interpreter for ownership-checked Cleanup.
+Rejected RunId collisions and lock-acquisition failures leave the existing
+manifest and reports untouched. Reports are finalized only after local
+initialization succeeds while holding the run lock.
+Recovery also compares the exact snapshot read before confirmation with the
+manifest after acquiring the lock. If another invocation updated it in between,
+recovery stops without saving stale state; reread the manifest and retry.
Some enterprise subscriptions add policy-generated NRMS NSGs during VNet creation.
If these lack the run's tags/journal entries, automatic cleanup intentionally
refuses group deletion. Review exact IDs and attachments and obtain explicit
@@ -300,6 +306,9 @@ pwsh -NoProfile -File .\scripts\appservice\test_active_backup.ps1 `
Resume does not recreate cleaned fixtures or blindly replay uncertain deployment,
refresh, mark-LKG, or other non-idempotent actions. In those cases, investigate or
clean up and start a fresh run.
+Before continuing, Resume revalidates every attempted, uncleared resource group,
+even when no mutation is pending. Missing groups and mismatched ownership tags
+stop resumption before further resource mutations.
Successful commands clear their pending-mutation journal entry after output
validation, including empty refresh results and text output. Failed, timed-out,
diff --git a/scripts/appservice/test_active_backup.ps1 b/scripts/appservice/test_active_backup.ps1
index 58fd21b2c13..bbeb51e7664 100644
--- a/scripts/appservice/test_active_backup.ps1
+++ b/scripts/appservice/test_active_backup.ps1
@@ -32,6 +32,7 @@ Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot 'ActiveBackupTestHarness.psm1') -Force -DisableNameChecking
$context = $null
+$initialized = $false
$exitCode = 0
$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
try {
@@ -95,6 +96,7 @@ try {
else {
if ($Mode -eq 'Cleanup' -and -not $PSCmdlet.ShouldProcess($context.Subscription, 'Delete only manifest-owned Active Backup test resources')) { return }
Initialize-AbLocalState $context -Existing:($Mode -in @('Resume', 'Cleanup'))
+ $initialized = $true
Test-AbSource $context
$context.Manifest.Remove('failure')
Save-AbManifest $context
@@ -122,7 +124,7 @@ catch {
$kind = $(if ($_.Exception.Data.Contains('AbKind')) { $_.Exception.Data['AbKind'] } else { 'Invalid' })
$exitCode = switch ($kind) { 'Cleanup' { 3 } 'BlockedEnvironment' { 2 } 'BlockedFixture' { 2 } 'Fail' { 1 } default { 4 } }
Write-Warning $(if ($_.Exception.Data.Contains('AbSafeMessage')) { $_.Exception.Data['AbSafeMessage'] } else { 'Unexpected local failure. Raw output was withheld to avoid persisting credentials.' })
- if ($context -and (Test-Path -LiteralPath $context.ManifestPath)) {
+ if ($initialized -and $context.Lock -and (Test-Path -LiteralPath $context.ManifestPath)) {
$context.Manifest.failure = @{
kind = $kind
reason = $(if ($_.Exception.Data.Contains('AbSafeMessage')) { $_.Exception.Data['AbSafeMessage'] } else { 'Unexpected local failure; raw details withheld.' })
@@ -137,7 +139,7 @@ catch {
finally {
if ($context) {
if ($context.Lock) { $context.Lock.Dispose() }
- if ($context.Mode -ne 'Plan' -and (Test-Path -LiteralPath $context.ManifestPath)) {
+ if ($initialized -and $context.Mode -ne 'Plan' -and (Test-Path -LiteralPath $context.ManifestPath)) {
$relative = [IO.Path]::GetRelativePath($repo, $context.ManifestPath)
Write-Host "Recovery manifest: $relative"
$scriptPath = (Join-Path $PSScriptRoot 'test_active_backup.ps1').Replace("'", "''")
diff --git a/scripts/appservice/tests/Test-ActiveBackupHarness.ps1 b/scripts/appservice/tests/Test-ActiveBackupHarness.ps1
index 8b93f0a0fcc..6f8c59c4dd0 100644
--- a/scripts/appservice/tests/Test-ActiveBackupHarness.ps1
+++ b/scripts/appservice/tests/Test-ActiveBackupHarness.ps1
@@ -347,6 +347,64 @@ try {
Assert-Throws { Initialize-AbLocalState $other -Existing }
Assert-Test ([IO.File]::ReadAllText($c.ManifestPath) -ceq $before)
}
+ Test-Case 'recovery rejects a stale snapshot after the writer releases its lock' {
+ $c = New-TestContext
+ $stale = Read-AbManifest $repo $c.ManifestPath $subscription Cleanup
+ $contexts.Add($stale)
+ $suite = New-AbSuite $c Windows
+ $suite.groupCreateAttempted = $true
+ $suite.intents = @($suite.groupId, $suite.appId)
+ $c.Manifest.suites = @($suite)
+ $c.Manifest.pendingMutation = @{ replay = 'NoReplay'; command = 'webapp silo refresh'; resourceId = $suite.appId }
+ $c.Manifest.cases = @(@{ id = 'retained-history'; status = 'Pass'; calls = @() })
+ Save-AbManifest $c
+ $newer = [IO.File]::ReadAllText($c.ManifestPath)
+ $c.Lock.Dispose()
+ $c.Lock = $null
+ Assert-Throws { Initialize-AbLocalState $stale -Existing } 'changed before its lock'
+ Assert-Test ([IO.File]::ReadAllText($c.ManifestPath) -ceq $newer)
+ $stale.Lock.Dispose()
+ $stale.Lock = $null
+ $fresh = Read-AbManifest $repo $c.ManifestPath $subscription Cleanup
+ $contexts.Add($fresh)
+ Initialize-AbLocalState $fresh -Existing
+ Assert-Test ($fresh.Manifest.suites.Count -eq 1)
+ Assert-Test ($fresh.Manifest.pendingMutation.command -eq 'webapp silo refresh')
+ Assert-Test ($fresh.Manifest.cases[0].id -eq 'retained-history')
+ }
+ Test-Case 'entrypoint collisions and rejected locks preserve existing recovery artifacts' {
+ foreach ($mode in @('Preflight', 'Resume')) {
+ $c = New-TestContext
+ $suite = New-AbSuite $c Windows
+ $suite.groupCreateAttempted = $true
+ $suite.intents = @($suite.groupId, $suite.appId)
+ $c.Manifest.suites = @($suite)
+ $c.Manifest.pendingMutation = @{ replay = 'NoReplay'; case = 'refresh'; resourceId = $suite.appId }
+ Save-AbManifest $c
+ $reportPath = Join-Path $c.RunDirectory 'results.json'
+ $eventsPath = Join-Path $c.RunDirectory 'events.jsonl'
+ [IO.File]::WriteAllText($reportPath, '{"preserve":"report"}')
+ [IO.File]::WriteAllText($eventsPath, '{"preserve":"events"}')
+ $before = @{}
+ foreach ($path in @($c.ManifestPath, $reportPath, $eventsPath)) {
+ $before[$path] = [IO.File]::ReadAllText($path)
+ }
+ $arguments = @('-NoProfile', '-File', (Join-Path $repo 'scripts\appservice\test_active_backup.ps1'),
+ '-Mode', $mode, '-Subscription', $subscription, '-PythonExe', $python)
+ if ($mode -eq 'Preflight') {
+ $arguments += @('-RunId', $c.Manifest.runId, '-OutputRoot', (Split-Path $c.RunDirectory),
+ '-AuthConfigDir', $c.AuthConfigDir, '-OperatingSystem', 'Windows', '-Profile', 'Smoke')
+ }
+ else {
+ $arguments += @('-Manifest', $c.ManifestPath, '-AllowResourceCreation', '-AllowCleanup')
+ }
+ $process = Invoke-AbProcess (Get-Command pwsh -CommandType Application).Source $arguments $repo @{} 60
+ Assert-Test ($process.exitCode -eq 4)
+ foreach ($path in $before.Keys) {
+ Assert-Test ([IO.File]::ReadAllText($path) -ceq $before[$path]) "$mode changed a locked recovery artifact."
+ }
+ }
+ }
Test-Case 'native arguments preserve quotes and whitespace, warnings stay distinct' {
$code = 'import json,sys; print(json.dumps(sys.argv[1:])); print("preview warning",file=sys.stderr)'
$result = Invoke-AbProcess $python @('-c', $code, 'a path\with spaces', '{"x":"quoted value"}') $repo @{ PYTHONDONTWRITEBYTECODE = '1' } 30
@@ -970,6 +1028,32 @@ try {
$c.Manifest.pendingMutation = @{ replay = 'NoReplay'; case = 'refresh'; resourceId = ''; command = 'webapp silo refresh' }
Assert-Throws { Invoke-AbRun $c } 'not replayed'
}
+ Test-Case 'resume validates all existing groups before mutation with an empty pending journal' {
+ foreach ($missing in @($false, $true)) {
+ $c = New-TestContext -Mode Resume
+ $suite = New-AbSuite $c Windows
+ $suite.groupCreateAttempted = $true
+ $suite.intents = @($suite.groupId, $suite.planId, $suite.appId)
+ $c.Manifest.suites = @($suite)
+ $c.Manifest.cases = @(@{
+ id = $suite.key + '.primary'; status = 'Pass'; required = $true
+ evidenceKind = 'Behavioral'; calls = @(); reason = ''
+ })
+ $calls = [Collections.Generic.List[string]]::new()
+ $c.Executor = {
+ param($arguments)
+ $calls.Add(($arguments -join ' '))
+ if (($arguments -join ' ').StartsWith('group show ')) {
+ if ($missing) { return New-FakeResponse -ExitCode 3 }
+ return New-FakeResponse @{ id = $suite.groupId; tags = @{ abcliRunId = 'foreign-run' } }
+ }
+ throw 'Resume sent a command before validating group ownership.'
+ }.GetNewClosure()
+ Assert-Test ($null -eq $c.Manifest.pendingMutation)
+ Assert-Throws { Invoke-AbRun $c }
+ Assert-Test ($calls.Count -eq 1 -and $calls[0].StartsWith('group show '))
+ }
+ }
Test-Case 'resume accepts a completed empty refresh without replaying it' {
$c = New-TestContext
$suite = New-AbSuite $c Windows
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_client.py b/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_client.py
index a1aa12655a8..10abeb30899 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_client.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_client.py
@@ -20,11 +20,11 @@
from knack.log import get_logger
from azure.cli.core._profile import Profile
-from azure.cli.core.azclierror import (AzureConnectionError, AzureResponseError, FileOperationError,
- InvalidArgumentValueError, ResourceNotFoundError)
+from azure.cli.core.azclierror import (AzureConnectionError, AzureInternalError, AzureResponseError, FileOperationError,
+ InvalidArgumentValueError, ResourceNotFoundError, UnclassifiedUserFault)
from azure.cli.core.commands.client_factory import get_subscription_id
from azure.cli.core.sdk.policies import get_custom_hook_policy
-from azure.cli.core.util import get_az_user_agent, should_disable_connection_verify
+from azure.cli.core.util import get_az_user_agent, get_error_type_by_status_code, should_disable_connection_verify
API_VERSION = '2026-08-01'
@@ -86,7 +86,7 @@ def _service_error(response):
request_id = response.headers.get('x-ms-request-id')
if request_id:
message += ' Request ID: {}.'.format(request_id)
- error_type = ResourceNotFoundError if response.status_code == 404 else AzureResponseError
+ error_type = get_error_type_by_status_code(str(response.status_code))
error = error_type(message)
error.response = response
return error
@@ -297,7 +297,7 @@ def wait(self, path, timeout=DEFAULT_TIMEOUT, initial=None):
try:
response = self.request('GET', path, timeout=min(60, remaining))
read_retries = 0
- except (ResourceNotFoundError, AzureResponseError) as ex:
+ except (ResourceNotFoundError, AzureResponseError, AzureInternalError, UnclassifiedUserFault) as ex:
failed_response = getattr(ex, 'response', None)
status = getattr(failed_response, 'status_code', None)
known_pending = isinstance(ex, ResourceNotFoundError) and initial is not None
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py b/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py
index 9c71ae88b14..d2b78e53758 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py
@@ -12,8 +12,13 @@ def validate_identity(cmd, namespace):
identifiers = getattr(namespace, '_ids', None)
if not identifiers:
return
- named_flags = {'--name', '-n', '--resource-group', '-g', '--silo', '--subscription'}
- if named_flags.intersection(cmd.cli_ctx.data.get('safe_params', [])):
+ parser = cmd.cli_ctx.invocation.parser.subparser_map[cmd.name]
+ specified = set(parser.specified_arguments)
+ # Knack omits private destinations; argparse has already disambiguated accepted long-option prefixes.
+ if any(option.startswith('--') and '--subscription'.startswith(option)
+ for option in cmd.cli_ctx.data.get('safe_params', [])):
+ specified.add('_subscription')
+ if specified.intersection({'name', 'resource_group_name', 'silo', '_subscription'}):
raise MutuallyExclusiveArgumentError('Use --ids or named resource arguments, not both.')
expected_type = 'serverfarms' if cmd.name.startswith('appservice plan silo') else 'sites'
child_required = hasattr(namespace, 'silo')
@@ -113,6 +118,578 @@ def validate_identity(cmd, namespace):
'webapp silo sitecontainers': (('list', 'list_silo_sitecontainers'), ('show', 'show_silo_sitecontainer')),
}
+ARGUMENT_HELP = {
+ 'addon_name': 'Name of the installed premium add-on.',
+ 'base_address': 'Module base address returned by the process module list.',
+ 'body': 'API request envelope as JSON or @file. Prefer named request options when available.',
+ 'container_name': 'Name of the container within the silo app.',
+ 'content_type': 'Content-Type header for the extension request body, such as application/json.',
+ 'delete_infected_files': 'Whether an antivirus scan may delete infected files. Specify true or false.',
+ 'deployment_id': 'Deployment identifier returned by deployment list.',
+ 'details': 'Include detailed metric values in the service response.',
+ 'dns_name': 'DNS name to resolve from the silo worker, such as example.com.',
+ 'duration': 'Network capture duration in seconds.',
+ 'end_time': 'End of the metrics window in UTC ISO 8601 format, such as 2026-10-20T01:00:00Z.',
+ 'endpoint_status': 'Enable or disable this silo as a managed-traffic endpoint.',
+ 'endpoints': 'JSON array or @file of region-keyed weight or priority overrides, each from 1 to 1000.',
+ 'expand_endpoints': 'Include persisted endpoint details in the managed-traffic collection.',
+ 'extension_name': 'Identifier of the installed site extension.',
+ 'file': 'Destination file for response bytes. Existing files require --overwrite.',
+ 'filename': 'Startup-log filename returned by startup list.',
+ 'filter_string': 'API OData metric filter. Do not combine with time, grain or metric-name options.',
+ 'follow_last_known_good': 'Whether the silo follows Last Known Good instead of newer ordinary revisions.',
+ 'force_restart': 'Whether the service may restart the silo when refreshing Key Vault references.',
+ 'format': 'Container-log download format: raw content or a ZIP archive.',
+ 'host': 'Destination hostname or IP address to test from the silo worker.',
+ 'include_file_paths': 'Include scanned file paths. The output may contain sensitive paths.',
+ 'include_secrets': 'Include private configuration values. Protect the command output.',
+ 'instance': 'Worker instance identifier returned by instance list.',
+ 'key': 'Connection-string name for a private-value point read. Omit to use the collection metadata route.',
+ 'latest': 'Return the latest startup-log metadata instead of the startup file list.',
+ 'legacy': 'Use the legacy service endpoint for this operation.',
+ 'location': 'Target silo region. Required on create; preserved from the existing silo on update.',
+ 'log_type': 'Startup-log type filter accepted by the service.',
+ 'max_frame_length': 'Maximum captured network-frame length in bytes.',
+ 'metadata_only': 'Read authentication metadata without requesting the full private settings.',
+ 'method': 'HTTP method used for the instance-qualified extension request.',
+ 'names': 'One or more metric names to include in the response.',
+ 'number_of_workers': 'Silo worker-count override, from 1 to 30. Omit to preserve inheritance.',
+ 'operation_id': 'Operation identifier returned by the corresponding accepted operation.',
+ 'overwrite': 'Replace the destination file if it already exists.',
+ 'path': 'Relative extension API path, without a leading slash, query string or fragment.',
+ 'port': 'Destination TCP port, from 1 to 65535.',
+ 'primary_location': 'Primary region. On create, resolved from the primary resource when omitted.',
+ 'process_id': 'Process identifier on the selected silo worker.',
+ 'run_id': 'Triggered WebJob run identifier returned by history list.',
+ 'sas_url': 'SAS URL for the trace destination. Treat this URL as a credential.',
+ 'scan_id': 'Antivirus scan identifier returned by antivirus log list.',
+ 'slot_view': 'Request the service slot view for the returned metrics.',
+ 'snapshot_id': 'Configuration snapshot identifier returned by config snapshot list.',
+ 'soft_restart': 'Whether to request the service soft-restart behavior.',
+ 'start_time': 'Start of the metrics window in UTC ISO 8601 format, such as 2026-10-20T00:00:00Z.',
+ 'subnet': 'Full subnet ARM resource ID for the silo VNet integration.',
+ 'synchronous': 'Whether the service should finish the restart before responding.',
+ 'thread_id': 'Thread identifier returned by process thread list.',
+ 'time_grain': 'Metrics interval as an ISO 8601 duration, such as PT1M or PT1H.',
+ 'timeout': 'Maximum API-operation wait in seconds. Expiry does not cancel the service operation.',
+ 'traffic_mode': 'Managed regional traffic mode: Active weights or Passive priorities.',
+ 'uri_parameters': 'Extension query parameters as KEY=VALUE pairs. Repeat keys when needed.',
+ 'webjob_name': 'Name of the WebJob installed in the silo app.',
+ 'zone_redundant': 'Silo zone-redundancy override. Specify true or false; omit to preserve inheritance.',
+}
+
+COMMAND_SUMMARIES = {
+ 'create_plan_silo': 'Create a regional Active Backup silo for an existing App Service plan.',
+ 'update_plan_silo': 'Update worker-count or zone-redundancy overrides for a plan silo.',
+ 'show_plan_silo': 'Show a plan silo, including its provisioning state and overrides.',
+ 'list_plan_silos': 'List all silos associated with an App Service plan.',
+ 'delete_plan_silo': 'Delete a plan silo after its dependent site silos have been removed.',
+ 'wait_plan_silo': 'Wait for a plan-silo API operation to complete.',
+ 'show_plan_silo_operation': 'Inspect the current status of a plan-silo API operation.',
+ 'list_plan_silo_vnet_integrations': 'List the VNet integrations associated with a plan silo.',
+ 'create_site_silo': 'Create a web app silo in a region with an existing plan silo.',
+ 'update_site_silo': 'Update a silo endpoint, revision policy or networking override.',
+ 'show_site_silo': 'Show a web app silo, its current revision and service error details.',
+ 'list_site_silos': 'List a web app origin and its regional silos.',
+ 'delete_site_silo': 'Delete a web app silo without deleting the primary app.',
+ 'wait_site_silo': 'Wait for a web app silo API operation to complete.',
+ 'show_site_silo_operation': 'Inspect the current status of a web app silo API operation.',
+ 'refresh_site_silos': 'Request a fresh revision and asynchronous propagation to the app silos.',
+ 'start_site_silo': 'Start the selected web app silo.',
+ 'stop_site_silo': 'Stop the selected web app silo.',
+ 'restart_site_silo': 'Restart the selected web app silo.',
+ 'show_silo_vnet_integration': 'Show the selected silo VNet integration.',
+ 'add_silo_vnet_integration': 'Connect a web app silo to a delegated VNet subnet.',
+ 'update_silo_vnet_integration': 'Update a web app silo VNet integration.',
+ 'remove_silo_vnet_integration': 'Disconnect a web app silo from its VNet subnet.',
+ 'list_managed_traffic': 'List the app managed-traffic configuration, with optional endpoint expansion.',
+ 'show_managed_traffic': 'Show the app regional traffic mode and endpoint overrides.',
+ 'update_managed_traffic': 'Update the app regional traffic mode or selected endpoint overrides.',
+ 'list_managed_traffic_metrics': 'Read health metrics for the app managed regional traffic.',
+ 'list_last_known_good': 'List the app Last Known Good revision metadata.',
+ 'show_last_known_good': 'Show the app current Last Known Good revision.',
+ 'mark_last_known_good': 'Mark the app latest ready revision as Last Known Good.',
+ 'show_silo_php_logging': 'Read the silo PHP error-logging setting.',
+ 'list_silo_deployments': 'List deployment history recorded for the selected silo.',
+ 'show_silo_deployment': 'Show one deployment from the silo deployment history.',
+ 'list_silo_deployment_logs': 'List log entries for one silo deployment.',
+ 'show_silo_msdeploy_log': 'Read the selected silo MSDeploy log.',
+ 'show_silo_onedeploy_status': 'Show the selected silo OneDeploy status.',
+ 'list_silo_deployment_statuses': 'List deployment-status records for the selected silo.',
+ 'show_silo_process': 'Show one process on a silo worker.',
+ 'stop_silo_process': 'Stop one process on a silo worker.',
+ 'delete_silo_process': 'Delete one process on a silo worker.',
+ 'dump_silo_process': 'Save a silo worker process dump to a local file.',
+ 'list_silo_process_threads': 'List threads belonging to a silo worker process.',
+ 'show_silo_process_thread': 'Show one thread belonging to a silo worker process.',
+ 'list_silo_process_modules': 'List modules loaded by a silo worker process.',
+ 'show_silo_process_module': 'Show one module loaded by a silo worker process.',
+ 'restart_silo_container': 'Restart a named container in the selected silo.',
+ 'download_silo_container_logs': 'Download raw or ZIP container logs to a local file.',
+ 'list_silo_startup_logs': 'List container startup-log files or request the latest-log metadata.',
+ 'show_silo_startup_summary': 'Show per-instance container startup success and failure summaries.',
+ 'show_silo_startup_log': 'Read one container startup-log file.',
+ 'list_silo_logs': 'List available instance logs for the selected silo.',
+ 'tail_silo_logs': 'Stream live silo logs to the console or a local file.',
+ 'show_silo_log_config': 'Show the silo diagnostic logging configuration.',
+ 'list_silo_webjobs': 'List WebJobs installed in the selected silo.',
+ 'show_silo_webjob': 'Show one WebJob installed in the selected silo.',
+ 'list_silo_continuous_webjobs': 'List continuous WebJobs in the selected silo.',
+ 'show_silo_continuous_webjob': 'Show a continuous WebJob in the selected silo.',
+ 'start_silo_continuous_webjob': 'Start a continuous WebJob in the selected silo.',
+ 'stop_silo_continuous_webjob': 'Stop a continuous WebJob in the selected silo.',
+ 'list_silo_triggered_webjobs': 'List triggered WebJobs in the selected silo.',
+ 'show_silo_triggered_webjob': 'Show a triggered WebJob in the selected silo.',
+ 'run_silo_triggered_webjob': 'Start a new run of a triggered WebJob.',
+ 'list_silo_webjob_history': 'List previous runs of a triggered WebJob.',
+ 'show_silo_webjob_history': 'Show one previous run of a triggered WebJob.',
+ 'list_silo_site_extensions': 'List site extensions installed in the selected silo.',
+ 'show_silo_site_extension': 'Show one site extension installed in the selected silo.',
+ 'invoke_silo_extension': 'Send a request to an instance-qualified silo extension API.',
+ 'check_silo_connectivity': 'Run a service connectivity check from the selected silo.',
+ 'check_silo_dns': 'Resolve a DNS name from the selected silo worker.',
+ 'check_silo_tcp': 'Test a TCP destination from the selected silo worker.',
+ 'list_silo_instances': 'List worker instances assigned to the selected silo.',
+ 'show_silo_instance': 'Show one worker instance assigned to the selected silo.',
+ 'show_silo_config': 'Read the selected silo web configuration.',
+ 'list_silo_config': 'List web-configuration resources for the selected silo.',
+ 'list_silo_config_snapshots': 'List configuration snapshots available for the selected silo.',
+ 'show_silo_config_snapshot': 'Read a configuration snapshot, optionally including private values.',
+ 'list_silo_keyvault_references': 'List connection-string Key Vault reference status for the silo.',
+ 'refresh_silo_keyvault_references': 'Request refresh of the silo app-setting Key Vault references.',
+ 'list_silo_detected_stacks': 'List runtime stacks detected in the selected silo.',
+ 'list_silo_appsettings': 'Read the selected silo app-setting values.',
+ 'list_silo_storage_accounts': 'Read storage-account mount settings for the selected silo.',
+ 'list_silo_connection_strings': 'Read connection-string values for the selected silo.',
+ 'show_silo_connection_strings': 'Read connection-string metadata or one named private value.',
+ 'show_silo_auth': 'Read the silo authentication settings or their metadata.',
+ 'show_silo_auth_v2': 'Read the silo version 2 authentication settings.',
+ 'show_silo_push_settings': 'Read the silo push-notification settings.',
+ 'list_silo_metadata': 'Read the selected silo metadata properties.',
+ 'list_silo_premier_addons': 'List premium add-ons associated with the selected silo.',
+ 'show_silo_premier_addon': 'Show a premium add-on associated with the selected silo.',
+ 'list_silo_metrics': 'Read metric values for the selected silo and requested time window.',
+ 'list_silo_metric_definitions': 'List metric names and definitions available for the selected silo.',
+ 'list_silo_perf_counters': 'Read performance-counter values for the selected silo.',
+ 'start_silo_network_trace': 'Start a network capture for the selected silo.',
+ 'stop_silo_network_trace': 'Stop the selected silo network capture.',
+ 'show_silo_network_trace': 'Read the results of a completed silo network capture.',
+ 'show_silo_network_trace_operation': 'Inspect a silo network-capture operation.',
+ 'show_silo_runtime_operation': 'Inspect an app-runtime operation on the selected silo.',
+ 'scan_silo_antivirus': 'Queue an on-demand antivirus scan of the selected silo.',
+ 'show_silo_antivirus_config': 'Read the selected silo antivirus scan configuration.',
+ 'update_silo_antivirus_config': 'Update whether silo antivirus scans may delete infected files.',
+ 'list_silo_antivirus_logs': 'List antivirus scan records for the selected silo.',
+ 'show_silo_antivirus_log': 'Read an antivirus scan record, optionally including file paths.',
+ 'list_silo_sitecontainers': 'List site-container definitions for the selected silo.',
+ 'show_silo_sitecontainer': 'Show one site-container definition for the selected silo.',
+}
+
+EXAMPLE_ARGUMENTS = {
+ 'create_plan_silo': '--number-of-workers 2 --timeout 7200',
+ 'create_site_silo': '--endpoint-status Enabled --follow-last-known-good false --timeout 7200',
+ 'update_plan_silo': '--number-of-workers 2 --zone-redundant false --timeout 7200',
+ 'update_site_silo': '--endpoint-status Enabled --follow-last-known-good false --timeout 7200',
+ 'delete_plan_silo': '--yes --timeout 7200',
+ 'delete_site_silo': '--yes --timeout 7200',
+ 'wait_plan_silo': '--timeout 7200',
+ 'wait_site_silo': '--timeout 7200',
+ 'restart_site_silo': '--soft-restart true --synchronous true --timeout 7200',
+ 'start_site_silo': '--timeout 7200',
+ 'stop_site_silo': '--timeout 7200',
+ 'list_managed_traffic': '--expand-endpoints',
+ 'update_managed_traffic': '--traffic-mode Passive',
+ 'show_silo_process': '--instance MyInstance',
+ 'list_silo_process_threads': '--instance MyInstance',
+ 'list_silo_process_modules': '--instance MyInstance',
+ 'delete_silo_process': '--yes',
+ 'download_silo_container_logs': '--format zip',
+ 'list_silo_startup_logs': '--latest true --instance MyInstance',
+ 'show_silo_startup_summary': '--duration 1.5 --instance MyInstance',
+ 'tail_silo_logs': '--instance MyInstance',
+ 'invoke_silo_extension': '--method POST --body @request.json',
+ 'check_silo_dns': '--dns-name example.com',
+ 'check_silo_tcp': '--host example.com --port 443',
+ 'show_silo_config_snapshot': '--include-secrets',
+ 'refresh_silo_keyvault_references': '--force-restart false',
+ 'show_silo_connection_strings': '--key MyConnection',
+ 'show_silo_auth': '--metadata-only',
+ 'show_silo_auth_v2': '--legacy',
+ 'list_silo_metrics': (
+ '--names Requests --details true --time-grain PT1H '
+ '--start-time 2026-10-20T00:00:00Z --end-time 2026-10-20T01:00:00Z'),
+ 'list_silo_perf_counters': (
+ '--names Requests --time-grain PT1H '
+ '--start-time 2026-10-20T00:00:00Z --end-time 2026-10-20T01:00:00Z'),
+ 'start_silo_network_trace': '--duration 60 --max-frame-length 128 --timeout 7200',
+ 'update_silo_antivirus_config': '--delete-infected-files false',
+ 'show_silo_antivirus_log': '--include-file-paths',
+}
+
+ADDITIONAL_EXAMPLES = {
+ 'create_plan_silo': (
+ 'Create a plan silo with explicit zone and worker overrides.',
+ 'az appservice plan silo create -g MyResourceGroup -n MyPrimary --silo MySilo '
+ '--location westus2 --primary-location eastus --number-of-workers 2 --zone-redundant false'),
+ 'update_plan_silo': (
+ 'Update capacity while supplying the unchanged routing locations explicitly.',
+ 'az appservice plan silo update -g MyResourceGroup -n MyPrimary --silo MySilo '
+ '--number-of-workers 2 --location westus2 --primary-location eastus'),
+ 'show_plan_silo': (
+ 'Read only the plan-silo provisioning state for a script.',
+ 'az appservice plan silo show -g MyResourceGroup -n MyPrimary --silo MySilo '
+ '--query properties.provisioningState --output tsv'),
+ 'list_plan_silos': (
+ 'List the names of all plan silos for a script.',
+ 'az appservice plan silo list -g MyResourceGroup -n MyPrimary --query "[].name" --output tsv'),
+ 'delete_plan_silo': (
+ 'Request plan-silo deletion using its ARM ID after removing dependent site silos.',
+ 'az appservice plan silo delete --ids $PlanSiloResourceId --yes --no-wait'),
+ 'wait_plan_silo': (
+ 'Wait for a known plan-silo operation and return only its terminal status.',
+ 'az appservice plan silo wait --ids $PlanSiloResourceId '
+ '--operation-id 00000000-0000-0000-0000-000000000001 --timeout 120 --query status --output tsv'),
+ 'show_plan_silo_operation': (
+ 'Inspect the status of an operation on a plan silo identified by ARM ID.',
+ 'az appservice plan silo operation show --ids $PlanSiloResourceId '
+ '--operation-id 00000000-0000-0000-0000-000000000001 --query status --output tsv'),
+ 'list_plan_silo_vnet_integrations': (
+ 'Read integrations for a plan silo identified by ARM ID.',
+ 'az appservice plan silo vnet-integration list --ids $PlanSiloResourceId --output json'),
+ 'create_site_silo': (
+ 'Create a Passive-traffic silo with a delegated subnet override.',
+ 'az webapp silo create -g MyResourceGroup -n MyPrimary --silo MySilo '
+ '--location westus2 --primary-location eastus --traffic-mode Passive '
+ '--subnet /subscriptions/SUB/resourceGroups/NetworkGroup/providers/Microsoft.Network/'
+ 'virtualNetworks/WestNet/subnets/integration'),
+ 'update_site_silo': (
+ 'Set a subnet override while supplying the unchanged routing locations.',
+ 'az webapp silo update -g MyResourceGroup -n MyPrimary --silo MySilo '
+ '--location westus2 --primary-location eastus '
+ '--subnet /subscriptions/SUB/resourceGroups/NetworkGroup/providers/Microsoft.Network/'
+ 'virtualNetworks/WestNet/subnets/integration'),
+ 'show_site_silo': (
+ 'Read the current revision for a silo identified by ARM ID.',
+ 'az webapp silo show --ids $SiteSiloResourceId --query properties.revisionNumber --output tsv'),
+ 'list_site_silos': (
+ 'Show regional silo names and locations in a table.',
+ 'az webapp silo list -g MyResourceGroup -n MyPrimary '
+ '--query "[].{Name:name,Region:location}" --output table'),
+ 'delete_site_silo': (
+ 'Request deletion by silo ARM ID without polling the accepted operation.',
+ 'az webapp silo delete --ids $SiteSiloResourceId --yes --no-wait'),
+ 'wait_site_silo': (
+ 'Wait for an operation on a silo identified by ARM ID and return its terminal status.',
+ 'az webapp silo wait --ids $SiteSiloResourceId '
+ '--operation-id 00000000-0000-0000-0000-000000000001 --timeout 120 --query status --output tsv'),
+ 'show_site_silo_operation': (
+ 'Read only the current operation status.',
+ 'az webapp silo operation show --ids $SiteSiloResourceId '
+ '--operation-id 00000000-0000-0000-0000-000000000001 --query status --output tsv'),
+ 'refresh_site_silos': (
+ 'Request asynchronous refresh for a primary app identified by ARM ID.',
+ 'az webapp silo refresh --ids $PrimaryAppResourceId'),
+ 'start_site_silo': (
+ 'Start a silo identified by ARM ID without polling the accepted operation.',
+ 'az webapp silo start --ids $SiteSiloResourceId --no-wait'),
+ 'stop_site_silo': (
+ 'Stop a silo identified by ARM ID without polling the accepted operation.',
+ 'az webapp silo stop --ids $SiteSiloResourceId --no-wait'),
+ 'restart_site_silo': (
+ 'Ask the service for an asynchronous restart and skip subsequent operation polling.',
+ 'az webapp silo restart --ids $SiteSiloResourceId --synchronous false --no-wait'),
+ 'show_silo_vnet_integration': (
+ 'Read the subnet ID from a silo VNet integration.',
+ 'az webapp silo vnet-integration show --ids $SiteSiloResourceId '
+ '--query properties.subnetResourceId --output tsv'),
+ 'add_silo_vnet_integration': (
+ 'Connect using an advanced VNet connection envelope.',
+ 'az webapp silo vnet-integration add -g MyResourceGroup -n MyPrimary --silo MySilo --body @vnet.json'),
+ 'update_silo_vnet_integration': (
+ 'Patch advanced VNet connection properties from a file.',
+ 'az webapp silo vnet-integration update -g MyResourceGroup -n MyPrimary --silo MySilo --body @vnet.json'),
+ 'remove_silo_vnet_integration': (
+ 'Disconnect the integration on a silo identified by ARM ID.',
+ 'az webapp silo vnet-integration remove --ids $SiteSiloResourceId'),
+ 'list_managed_traffic': (
+ 'Read the expanded managed-traffic resource for a primary app identified by ARM ID.',
+ 'az webapp managed-traffic list --ids $PrimaryAppResourceId --expand-endpoints --output json'),
+ 'show_managed_traffic': (
+ 'Read only the configured regional traffic mode.',
+ 'az webapp managed-traffic show -g MyResourceGroup -n MyPrimary --query properties.trafficMode --output tsv'),
+ 'update_managed_traffic': (
+ 'Apply region-keyed weight overrides from a JSON array file.',
+ 'az webapp managed-traffic update -g MyResourceGroup -n MyPrimary '
+ '--traffic-mode Active --endpoints @endpoints.json'),
+ 'list_managed_traffic_metrics': (
+ 'Read health metrics for a one-hour UTC window at one-minute intervals.',
+ 'az webapp managed-traffic metrics list -g MyResourceGroup -n MyPrimary '
+ '--start-time 2026-10-20T00:00:00Z --end-time 2026-10-20T01:00:00Z --time-grain PT1M'),
+ 'list_last_known_good': (
+ 'Read Last Known Good metadata for a primary app identified by ARM ID.',
+ 'az webapp last-known-good list --ids $PrimaryAppResourceId --output json'),
+ 'show_last_known_good': (
+ 'Read only the Last Known Good revision number.',
+ 'az webapp last-known-good show -g MyResourceGroup -n MyPrimary '
+ '--query properties.revisionNumber --output tsv'),
+ 'mark_last_known_good': (
+ 'Mark the latest ready revision using the primary app ARM ID.',
+ 'az webapp last-known-good mark --ids $PrimaryAppResourceId'),
+ 'show_silo_php_logging': (
+ 'Inspect the PHP logging response for a silo identified by ARM ID.',
+ 'az webapp silo log php show --ids $SiteSiloResourceId --output json'),
+ 'list_silo_deployments': (
+ 'List deployment identifiers for subsequent deployment-log reads.',
+ 'az webapp silo deployment list --ids $SiteSiloResourceId --query "[].id" --output tsv'),
+ 'show_silo_deployment': (
+ 'Read one deployment using the silo ARM ID.',
+ 'az webapp silo deployment show --ids $SiteSiloResourceId --deployment-id MyDeployment --output json'),
+ 'list_silo_deployment_logs': (
+ 'Read log entries for a deployment using the silo ARM ID.',
+ 'az webapp silo deployment log list --ids $SiteSiloResourceId --deployment-id MyDeployment --output json'),
+ 'show_silo_msdeploy_log': (
+ 'Read the MSDeploy log from one worker instance.',
+ 'az webapp silo deployment log show --ids $SiteSiloResourceId --instance MyInstance --output json'),
+ 'show_silo_onedeploy_status': (
+ 'Read OneDeploy status from one worker instance.',
+ 'az webapp silo deployment status show --ids $SiteSiloResourceId --instance MyInstance --output json'),
+ 'list_silo_deployment_statuses': (
+ 'Read deployment-status resources for a silo identified by ARM ID.',
+ 'az webapp silo deployment status list --ids $SiteSiloResourceId --output json'),
+ 'show_silo_process': (
+ 'Read one process using the silo ARM ID and a worker instance.',
+ 'az webapp silo process show --ids $SiteSiloResourceId --process-id 123 --instance MyInstance --output json'),
+ 'stop_silo_process': (
+ 'Stop a process on a specific worker instance.',
+ 'az webapp silo process stop --ids $SiteSiloResourceId --process-id 123 --instance MyInstance'),
+ 'delete_silo_process': (
+ 'Delete a process on a specific worker without prompting.',
+ 'az webapp silo process delete --ids $SiteSiloResourceId --process-id 123 --instance MyInstance --yes'),
+ 'dump_silo_process': (
+ 'Save a worker-qualified process dump and replace an existing local file.',
+ 'az webapp silo process dump --ids $SiteSiloResourceId --process-id 123 --instance MyInstance '
+ '--file worker.dmp --overwrite'),
+ 'list_silo_process_threads': (
+ 'List thread identifiers for a process on a specific worker.',
+ 'az webapp silo process thread list --ids $SiteSiloResourceId --process-id 123 '
+ '--instance MyInstance --query "[].id" --output tsv'),
+ 'show_silo_process_thread': (
+ 'Read one thread on a specific worker instance.',
+ 'az webapp silo process thread show --ids $SiteSiloResourceId --process-id 123 '
+ '--thread-id 456 --instance MyInstance'),
+ 'list_silo_process_modules': (
+ 'List process module names on a specific worker.',
+ 'az webapp silo process module list --ids $SiteSiloResourceId --process-id 123 '
+ '--instance MyInstance --query "[].name" --output tsv'),
+ 'show_silo_process_module': (
+ 'Read a module at a known base address on a specific worker.',
+ 'az webapp silo process module show --ids $SiteSiloResourceId --process-id 123 '
+ '--base-address 0x1000 --instance MyInstance'),
+ 'restart_silo_container': (
+ 'Restart a named container on a specific worker.',
+ 'az webapp silo container restart --ids $SiteSiloResourceId '
+ '--container-name MyContainer --instance MyInstance'),
+ 'download_silo_container_logs': (
+ 'Download raw container logs and replace an existing destination.',
+ 'az webapp silo log container download --ids $SiteSiloResourceId --format raw --file container.log --overwrite'),
+ 'list_silo_startup_logs': (
+ 'List startup files for a worker and a service-supported log type.',
+ 'az webapp silo log startup list --ids $SiteSiloResourceId '
+ '--instance MyInstance --log-type $StartupLogType --latest false'),
+ 'show_silo_startup_summary': (
+ 'Read the last two hours of startup summaries for one worker.',
+ 'az webapp silo log startup show-summary --ids $SiteSiloResourceId --instance MyInstance --duration 2'),
+ 'show_silo_startup_log': (
+ 'Read a startup-log filename returned by startup list.',
+ 'az webapp silo log startup show --ids $SiteSiloResourceId --filename startup.log'),
+ 'list_silo_logs': (
+ 'List available logs from a specific worker.',
+ 'az webapp silo log list --ids $SiteSiloResourceId --instance MyInstance --output json'),
+ 'tail_silo_logs': (
+ 'Stream worker logs to a local file, replacing an existing destination.',
+ 'az webapp silo log tail --ids $SiteSiloResourceId --instance MyInstance --file live.log --overwrite'),
+ 'show_silo_log_config': (
+ 'Read only the diagnostic logging properties.',
+ 'az webapp silo log config show --ids $SiteSiloResourceId --query properties --output json'),
+ 'list_silo_webjobs': (
+ 'List installed WebJob names.',
+ 'az webapp silo webjob list --ids $SiteSiloResourceId --query "[].name" --output tsv'),
+ 'show_silo_webjob': (
+ 'Read one WebJob using the silo ARM ID.',
+ 'az webapp silo webjob show --ids $SiteSiloResourceId --webjob-name MyJob --output json'),
+ 'list_silo_continuous_webjobs': (
+ 'List continuous WebJob names for a script.',
+ 'az webapp silo webjob continuous list --ids $SiteSiloResourceId --query "[].name" --output tsv'),
+ 'show_silo_continuous_webjob': (
+ 'Inspect one continuous WebJob using the silo ARM ID.',
+ 'az webapp silo webjob continuous show --ids $SiteSiloResourceId --webjob-name MyJob --output json'),
+ 'start_silo_continuous_webjob': (
+ 'Start an installed continuous WebJob using the silo ARM ID.',
+ 'az webapp silo webjob continuous start --ids $SiteSiloResourceId --webjob-name MyJob'),
+ 'stop_silo_continuous_webjob': (
+ 'Stop an installed continuous WebJob using the silo ARM ID.',
+ 'az webapp silo webjob continuous stop --ids $SiteSiloResourceId --webjob-name MyJob'),
+ 'list_silo_triggered_webjobs': (
+ 'List triggered WebJob names for a script.',
+ 'az webapp silo webjob triggered list --ids $SiteSiloResourceId --query "[].name" --output tsv'),
+ 'show_silo_triggered_webjob': (
+ 'Inspect a triggered WebJob using the silo ARM ID.',
+ 'az webapp silo webjob triggered show --ids $SiteSiloResourceId --webjob-name MyJob --output json'),
+ 'run_silo_triggered_webjob': (
+ 'Queue a new run of an installed triggered WebJob.',
+ 'az webapp silo webjob triggered run --ids $SiteSiloResourceId --webjob-name MyJob'),
+ 'list_silo_webjob_history': (
+ 'List previous run identifiers for a triggered WebJob.',
+ 'az webapp silo webjob triggered history list --ids $SiteSiloResourceId '
+ '--webjob-name MyJob --query "[].id" --output tsv'),
+ 'show_silo_webjob_history': (
+ 'Read a known triggered-WebJob run.',
+ 'az webapp silo webjob triggered history show --ids $SiteSiloResourceId '
+ '--webjob-name MyJob --run-id MyRun --output json'),
+ 'list_silo_site_extensions': (
+ 'List installed extension names for a script.',
+ 'az webapp silo site-extension list --ids $SiteSiloResourceId --query "[].name" --output tsv'),
+ 'show_silo_site_extension': (
+ 'Inspect one installed site extension using the silo ARM ID.',
+ 'az webapp silo site-extension show --ids $SiteSiloResourceId --extension-name MyExtension --output json'),
+ 'invoke_silo_extension': (
+ 'Send a custom-content request with query parameters and save the response.',
+ 'az webapp silo extension invoke --ids $SiteSiloResourceId --instance MyInstance --path api/settings '
+ '--method POST --content-type application/xml --body @request.xml '
+ '--uri-parameters mode=inspect --file extension-response.bin --overwrite'),
+ 'check_silo_connectivity': (
+ 'Use a prepared connectivity-check request for a silo identified by ARM ID.',
+ 'az webapp silo network-check connectivity --ids $SiteSiloResourceId --body @connectivity-check.json'),
+ 'check_silo_dns': (
+ 'Use an advanced DNS-check request instead of named request fields.',
+ 'az webapp silo network-check dns --ids $SiteSiloResourceId --body @dns-check.json'),
+ 'check_silo_tcp': (
+ 'Use an advanced TCP-check request instead of host and port options.',
+ 'az webapp silo network-check tcp-ping --ids $SiteSiloResourceId --body @tcp-check.json'),
+ 'list_silo_instances': (
+ 'List worker instance names for later worker-qualified commands.',
+ 'az webapp silo instance list --ids $SiteSiloResourceId --query "[].name" --output tsv'),
+ 'show_silo_instance': (
+ 'Inspect one instance using the silo ARM ID.',
+ 'az webapp silo instance show --ids $SiteSiloResourceId --instance MyInstance --output json'),
+ 'show_silo_config': (
+ 'Read only the web-configuration properties.',
+ 'az webapp silo config show --ids $SiteSiloResourceId --query properties --output json'),
+ 'list_silo_config': (
+ 'List web-configuration resource names.',
+ 'az webapp silo config list --ids $SiteSiloResourceId --query "[].name" --output tsv'),
+ 'list_silo_config_snapshots': (
+ 'List configuration snapshot identifiers for point reads.',
+ 'az webapp silo config snapshot list --ids $SiteSiloResourceId --query "[].properties.snapshotId" --output tsv'),
+ 'show_silo_config_snapshot': (
+ 'Read a snapshot without including its private configuration values.',
+ 'az webapp silo config snapshot show --ids $SiteSiloResourceId --snapshot-id 1 --output json'),
+ 'list_silo_keyvault_references': (
+ 'Read connection-string Key Vault reference status for a silo identified by ARM ID.',
+ 'az webapp silo config keyvault-reference list --ids $SiteSiloResourceId --output json'),
+ 'refresh_silo_keyvault_references': (
+ 'Refresh app-setting Key Vault references and permit a service restart.',
+ 'az webapp silo config keyvault-reference refresh --ids $SiteSiloResourceId --force-restart true'),
+ 'list_silo_detected_stacks': (
+ 'Inspect detected runtime-stack records using the silo ARM ID.',
+ 'az webapp silo runtime list-detected --ids $SiteSiloResourceId --output json'),
+ 'list_silo_appsettings': (
+ 'Read one named app-setting value; protect the returned value.',
+ 'az webapp silo config appsettings list --ids $SiteSiloResourceId --query properties.MySetting --output tsv'),
+ 'list_silo_storage_accounts': (
+ 'Read storage-mount properties using the silo ARM ID; protect secret values.',
+ 'az webapp silo config storage-account list --ids $SiteSiloResourceId --query properties --output json'),
+ 'list_silo_connection_strings': (
+ 'Read one named connection string; protect the returned value.',
+ 'az webapp silo config connection-string list --ids $SiteSiloResourceId '
+ '--query properties.MyConnection --output json'),
+ 'show_silo_connection_strings': (
+ 'Read connection-string metadata without selecting a private-value key.',
+ 'az webapp silo config connection-string show --ids $SiteSiloResourceId --output json'),
+ 'show_silo_auth': (
+ 'Read full authentication settings rather than metadata; protect secret values.',
+ 'az webapp silo auth show --ids $SiteSiloResourceId --output json'),
+ 'show_silo_auth_v2': (
+ 'Use the current authentication-v2 endpoint instead of the legacy list route.',
+ 'az webapp silo auth v2 show --ids $SiteSiloResourceId --output json'),
+ 'show_silo_push_settings': (
+ 'Read push-notification properties using the silo ARM ID; protect secret values.',
+ 'az webapp silo config push-settings show --ids $SiteSiloResourceId --query properties --output json'),
+ 'list_silo_metadata': (
+ 'Read one named metadata property.',
+ 'az webapp silo config metadata list --ids $SiteSiloResourceId --query properties.MyMetadata --output tsv'),
+ 'list_silo_premier_addons': (
+ 'List associated premium add-on names.',
+ 'az webapp silo premier-addon list --ids $SiteSiloResourceId --query "[].name" --output tsv'),
+ 'show_silo_premier_addon': (
+ 'Read one premium add-on using the silo ARM ID.',
+ 'az webapp silo premier-addon show --ids $SiteSiloResourceId --addon-name MyAddon --output json'),
+ 'list_silo_metrics': (
+ 'Use a service OData filter instead of named metric-window options.',
+ 'az webapp silo metrics list --ids $SiteSiloResourceId '
+ '--filter "name.value eq \'Requests\'" --slot-view true --details false'),
+ 'list_silo_metric_definitions': (
+ 'Inspect metric definitions for a silo identified by ARM ID.',
+ 'az webapp silo metrics definition list --ids $SiteSiloResourceId --output json'),
+ 'list_silo_perf_counters': (
+ 'Use a service OData filter for performance counters.',
+ 'az webapp silo metrics perf-counter list --ids $SiteSiloResourceId '
+ '--filter "name.value eq \'Requests\'" --output json'),
+ 'start_silo_network_trace': (
+ 'Use the legacy capture endpoint with a caller-provided trace destination.',
+ 'az webapp silo network-trace start --ids $SiteSiloResourceId --legacy '
+ '--duration 60 --max-frame-length 128 --sas-url $TraceDestinationSas --timeout 7200'),
+ 'stop_silo_network_trace': (
+ 'Stop capture using the silo ARM ID.',
+ 'az webapp silo network-trace stop --ids $SiteSiloResourceId'),
+ 'show_silo_network_trace': (
+ 'Read results for a known capture using the silo ARM ID.',
+ 'az webapp silo network-trace show --ids $SiteSiloResourceId '
+ '--operation-id 00000000-0000-0000-0000-000000000001 --output json'),
+ 'show_silo_network_trace_operation': (
+ 'Read only the status of a known capture operation.',
+ 'az webapp silo network-trace operation show --ids $SiteSiloResourceId '
+ '--operation-id 00000000-0000-0000-0000-000000000001 --output json'),
+ 'show_silo_runtime_operation': (
+ 'Inspect an app-runtime operation using the silo ARM ID.',
+ 'az webapp silo runtime-operation show --ids $SiteSiloResourceId '
+ '--operation-id 00000000-0000-0000-0000-000000000001 --output json'),
+ 'scan_silo_antivirus': (
+ 'Queue a scan using the silo ARM ID.',
+ 'az webapp silo antivirus scan --ids $SiteSiloResourceId'),
+ 'show_silo_antivirus_config': (
+ 'Read the antivirus scan configuration properties.',
+ 'az webapp silo antivirus config show --ids $SiteSiloResourceId --query properties --output json'),
+ 'update_silo_antivirus_config': (
+ 'Use an advanced antivirus configuration envelope instead of named options.',
+ 'az webapp silo antivirus config update --ids $SiteSiloResourceId --body @antivirus.json'),
+ 'list_silo_antivirus_logs': (
+ 'Read scan records for a silo identified by ARM ID.',
+ 'az webapp silo antivirus log list --ids $SiteSiloResourceId --output json'),
+ 'show_silo_antivirus_log': (
+ 'Read a scan record without including sensitive file paths.',
+ 'az webapp silo antivirus log show --ids $SiteSiloResourceId --scan-id MyScan --output json'),
+ 'list_silo_sitecontainers': (
+ 'List the names of site-container definitions.',
+ 'az webapp silo sitecontainers list --ids $SiteSiloResourceId --query "[].name" --output tsv'),
+ 'show_silo_sitecontainer': (
+ 'Read one site-container definition using the silo ARM ID.',
+ 'az webapp silo sitecontainers show --ids $SiteSiloResourceId --container-name MyContainer --output json'),
+}
+
+NO_WAIT_EXAMPLE_ARGUMENTS = {
+ 'create_plan_silo': '--number-of-workers 2',
+ 'update_plan_silo': '--number-of-workers 2',
+ 'delete_plan_silo': '--yes',
+ 'create_site_silo': '--endpoint-status Enabled',
+ 'update_site_silo': '--endpoint-status Enabled',
+ 'delete_site_silo': '--yes',
+ 'start_site_silo': '',
+ 'stop_site_silo': '',
+ 'restart_site_silo': '--soft-restart true --synchronous false',
+ 'start_silo_network_trace': '--duration 60 --max-frame-length 128',
+}
+
def load_command_table(loader):
from azure.cli.core.commands import CliCommandType
@@ -149,45 +726,39 @@ def load_arguments(loader, command):
# Register the reserved flag locally; supports_no_wait would discard the receipt.
context.extra(
'no_wait', options_list=['--no-wait'], action='store_true', default=False,
- help='Return without waiting for the API operation, preserving its resource or accepted-operation receipt.')
+ help='Skip polling accepted API operations. The initial service request must still complete.')
for argument in parameters:
if argument in ('cmd', 'name', 'resource_group_name', 'silo', 'no_wait'):
continue
- settings = {'help': argument.replace('_', ' ').capitalize() + '.'}
+ settings = {'help': ARGUMENT_HELP[argument]}
if argument in ('zone_redundant', 'follow_last_known_good', 'force_restart',
- 'soft_restart', 'synchronous', 'details', 'latest', 'slot_view'):
+ 'soft_restart', 'synchronous', 'details', 'latest', 'slot_view', 'delete_infected_files'):
settings['arg_type'] = get_three_state_flag()
if argument in ('expand_endpoints', 'overwrite', 'include_secrets', 'metadata_only',
'legacy', 'include_file_paths'):
settings['action'] = 'store_true'
- if argument in ('number_of_workers', 'timeout', 'duration', 'max_frame_length'):
+ if argument in ('number_of_workers', 'timeout', 'duration', 'max_frame_length', 'port'):
settings['type'] = int
if argument == 'duration' and handler_name == 'show_silo_startup_summary':
settings['type'] = float
+ settings['help'] = 'Startup-summary look-back window in hours. Zero or omitted uses 24 hours.'
+ if argument == 'legacy':
+ settings['help'] = (
+ 'Use the legacy trace-start endpoint instead of the asynchronous start-operation endpoint.'
+ if handler_name == 'start_silo_network_trace'
+ else 'Read authsettingsV2 through the legacy /list GET endpoint.')
if argument == 'number_of_workers':
settings.update(default=None, help='Silo worker-count override, from 1 to 30. Omit to preserve inheritance.')
if argument == 'location':
settings.update(
configured_default=None, local_context_attribute=None,
help='Target silo region. Required on create; preserved from the existing silo on update.')
- if argument == 'primary_location':
- settings['help'] = 'Primary region. On create, resolved from the primary resource when omitted.'
- if argument == 'timeout':
- settings['help'] = 'Maximum API-operation wait in seconds. Does not cancel the service operation.'
- if argument == 'body':
- settings['help'] = 'Request JSON or @file containing the API request envelope, including properties.'
- if argument == 'endpoints':
- settings['help'] = 'JSON array or @file with region and optional weight/priority values (1-1000).'
- if argument == 'subnet':
- settings['help'] = 'Full subnet ARM resource ID for the silo network integration.'
- if argument == 'file':
- settings['help'] = 'Destination file for response bytes. Existing files require --overwrite.'
if argument == 'filter_string':
- settings.update(options_list=['--filter'], help='API OData metric filter; cannot combine with time/name filters.')
+ settings['options_list'] = ['--filter']
if argument == 'names':
settings['nargs'] = '+'
if argument == 'uri_parameters':
- settings.update(nargs='+', help='Extension query parameters as KEY=VALUE pairs. May repeat keys.')
+ settings['nargs'] = '+'
enums = {'traffic_mode': ['Active', 'Passive'], 'endpoint_status': ['Enabled', 'Disabled'],
'format': ['raw', 'zip'], 'method': ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']}
if argument in enums:
@@ -200,6 +771,11 @@ def load_arguments(loader, command):
def load_help():
from knack.help_files import helps
from . import active_backup
+ sensitive_handlers = {
+ 'list_silo_appsettings', 'list_silo_storage_accounts', 'list_silo_connection_strings',
+ 'show_silo_connection_strings', 'show_silo_auth', 'show_silo_auth_v2',
+ 'show_silo_push_settings', 'show_silo_config_snapshot', 'show_silo_antivirus_log',
+ }
for group, entries in COMMAND_GROUPS.items():
root_length = 3 if group.startswith('appservice plan') else 2
words = group.split()
@@ -229,23 +805,52 @@ def load_help():
example += ' --primary-location eastus'
if group == 'webapp silo':
example += ' --traffic-mode Active'
- if handler_name == 'update_managed_traffic':
- example += ' --traffic-mode Passive'
if handler_name in ('add_silo_vnet_integration', 'update_silo_vnet_integration'):
- example += ' --body @request.json'
- description = handler_name.replace('_', ' ').capitalize() + '.'
- origin_help = (
+ example += (
+ ' --subnet /subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/MyNetworkGroup'
+ '/providers/Microsoft.Network/virtualNetworks/MyVnet/subnets/MySubnet')
+ base_example = example
+ if handler_name in EXAMPLE_ARGUMENTS:
+ example += ' ' + EXAMPLE_ARGUMENTS[handler_name]
+ description = COMMAND_SUMMARIES[handler_name]
+ long_summary = 'Calls the public Active Backup API. Service eligibility and errors remain authoritative.'
+ if handler_name == 'refresh_site_silos':
+ long_summary += (
+ ' Returns on HTTP 204 acceptance without waiting for revision convergence or inventing a target revision.')
+ if 'no_wait' in parameters:
+ long_summary += (
+ ' No-wait skips subsequent operation polling, not the initial service request or synchronous service work.')
+ if handler_name in sensitive_handlers:
+ long_summary += ' Output can contain secrets or sensitive values; protect it.'
+ if any(variable in ADDITIONAL_EXAMPLES[handler_name][1] for variable in (
+ '$SiteSiloResourceId', '$PlanSiloResourceId', '$PrimaryAppResourceId')):
+ long_summary += ' ARM-ID variables in examples represent IDs returned by earlier show/list commands.'
+ if handler_name == 'start_silo_network_trace':
+ long_summary += ' Set TraceDestinationSas to your trace destination SAS URL; do not log or share it.'
+ if handler_name == 'list_silo_startup_logs':
+ long_summary += ' Set StartupLogType to the service-supported startup-log filter you need.'
+ long_summary += (
' Origin point GET 404s can use the matching silo-list entry for metadata, with a warning. '
'Origin writes and operation polling are unchanged.'
if handler_name in ('show_site_silo', 'update_site_silo') else '')
+ long_summary += ' See https://github.com/Azure/azure-cli/blob/dev/doc/active_backup_preview.md for details.'
helps[command] = (
'type: command\nshort-summary: {}\n'
- 'long-summary: Calls the supported silo ARM API without client-side OS or SKU eligibility checks. '
- 'Read operations can return sensitive configuration. File responses require --file. '
- 'See https://github.com/Azure/azure-cli/blob/dev/doc/active_backup_preview.md '
- 'for API bodies and service limitations.{}\n'
+ 'long-summary: {}\n'
'examples:\n - name: {}\n text: {}\n'
- ).format(description, origin_help, description, example)
+ ).format(description, long_summary, description, example)
+ extra_name, extra_text = ADDITIONAL_EXAMPLES[handler_name]
+ helps[command] += (
+ ' - name: {}\n'
+ ' text: {}\n'
+ ).format(extra_name, extra_text)
+ if 'no_wait' in parameters:
+ options = NO_WAIT_EXAMPLE_ARGUMENTS[handler_name]
+ no_wait_example = base_example + (' ' + options if options else '')
+ helps[command] += (
+ ' - name: Skip operation polling after the initial service response.\n'
+ ' text: {} --no-wait\n'
+ ).format(no_wait_example)
for group in ('appservice plan silo', 'webapp silo'):
helps[group] = (
'type: group\nshort-summary: Manage App Service Active Backup silos.\n'
@@ -254,11 +859,3 @@ def load_help():
'PremiumV4 and PremiumMV4 plans. P0v3 is excluded. Regional subscription/flavor enablement, '
'slot restrictions and dependency ordering are service-enforced. Creating the first silo '
'can restart the primary app. Pass-through operations are not filtered by OS or SKU.\n')
- helps['webapp silo refresh'] = """
-type: command
-short-summary: Request a fresh revision and asynchronous propagation to the app's silos.
-long-summary: Returns successfully on HTTP 204 acceptance. Does not wait for revision convergence or invent a target revision.
-examples:
- - name: Request refresh for all silos of a primary app.
- text: az webapp silo refresh -g MyResourceGroup -n MyPrimary
-"""
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/active_backup.py b/src/azure-cli/azure/cli/command_modules/appservice/active_backup.py
index 7e782f2a7e2..747041a6e8d 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/active_backup.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/active_backup.py
@@ -7,7 +7,8 @@
from knack.log import get_logger
-from azure.cli.core.azclierror import AzureResponseError, FileOperationError, InvalidArgumentValueError
+from azure.cli.core.azclierror import (AzureResponseError, FileOperationError, InvalidArgumentValueError,
+ MutuallyExclusiveArgumentError, RequiredArgumentMissingError)
from ._active_backup_client import ActiveBackupClient, DEFAULT_TIMEOUT, load_json, segment
@@ -264,18 +265,23 @@ def remove_silo_vnet_integration(cmd, resource_group_name, name, silo):
def _site(cmd, resource_group_name, name, silo, suffix='', method='GET', query=None, body=None,
- filename=None, overwrite=False, tail=False, content_type=None, no_wait=False, timeout=None, collection=False):
+ filename=None, overwrite=False, tail=False, content_type=None, collection=False):
client = _client(cmd, resource_group_name, name)
path = client.resource(silo) + suffix
if collection:
return client.list(path, query)
if filename is not None or tail:
return client.consume(method, path, filename, overwrite, query, body, content_type, tail)
- if timeout is not None:
- return client.mutate(method, path, body, no_wait, timeout, query)
return client.decode(client.request(method, path, body, query, content_type=content_type))
+def _site_operation(cmd, resource_group_name, name, silo, suffix, query=None,
+ no_wait=False, timeout=DEFAULT_TIMEOUT):
+ client = _client(cmd, resource_group_name, name)
+ return client.mutate('POST', client.resource(silo) + suffix,
+ no_wait=no_wait, timeout=timeout, query=query)
+
+
def _instance(instance):
return '/instances/' + segment(instance) if instance is not None else ''
@@ -433,12 +439,34 @@ def check_silo_connectivity(cmd, resource_group_name, name, silo, body):
return _site(cmd, resource_group_name, name, silo, '/connectivityCheck', method='POST', body=load_json(body))
-def check_silo_dns(cmd, resource_group_name, name, silo, body):
- return _site(cmd, resource_group_name, name, silo, '/dnsCheck', method='POST', body=load_json(body))
-
-
-def check_silo_tcp(cmd, resource_group_name, name, silo, body):
- return _site(cmd, resource_group_name, name, silo, '/tcpPingCheck', method='POST', body=load_json(body))
+def check_silo_dns(cmd, resource_group_name, name, silo, body=None, dns_name=None):
+ if body is not None:
+ if dns_name is not None:
+ raise MutuallyExclusiveArgumentError('Use --dns-name or --body, not both.')
+ payload = load_json(body)
+ else:
+ if dns_name is None:
+ raise RequiredArgumentMissingError('Provide --dns-name or --body.')
+ if not isinstance(dns_name, str) or not dns_name.strip():
+ raise InvalidArgumentValueError('--dns-name must be a non-empty DNS name.')
+ payload = {'properties': {'dnsName': dns_name}}
+ return _site(cmd, resource_group_name, name, silo, '/dnsCheck', method='POST', body=payload)
+
+
+def check_silo_tcp(cmd, resource_group_name, name, silo, body=None, host=None, port=None):
+ if body is not None:
+ if host is not None or port is not None:
+ raise MutuallyExclusiveArgumentError('Use --host and --port or --body, not both.')
+ payload = load_json(body)
+ else:
+ if host is None or port is None:
+ raise RequiredArgumentMissingError('Provide both --host and --port, or use --body.')
+ if not isinstance(host, str) or not host.strip():
+ raise InvalidArgumentValueError('--host must be a non-empty hostname or IP address.')
+ if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
+ raise InvalidArgumentValueError('--port must be an integer from 1 to 65535.')
+ payload = {'properties': {'host': host, 'port': str(port)}}
+ return _site(cmd, resource_group_name, name, silo, '/tcpPingCheck', method='POST', body=payload)
def invoke_silo_extension(cmd, resource_group_name, name, silo, instance, path, method='GET',
@@ -590,26 +618,27 @@ def list_silo_perf_counters(
def restart_site_silo(
cmd, resource_group_name, name, silo, soft_restart=None, synchronous=None,
no_wait=False, timeout=DEFAULT_TIMEOUT):
- return _site(cmd, resource_group_name, name, silo, '/restart', method='POST',
- query={'softRestart': _boolean(soft_restart), 'synchronous': _boolean(synchronous)},
- no_wait=no_wait, timeout=timeout)
+ return _site_operation(
+ cmd, resource_group_name, name, silo, '/restart',
+ query={'softRestart': _boolean(soft_restart), 'synchronous': _boolean(synchronous)},
+ no_wait=no_wait, timeout=timeout)
def start_site_silo(cmd, resource_group_name, name, silo, no_wait=False, timeout=DEFAULT_TIMEOUT):
- return _site(cmd, resource_group_name, name, silo, '/start', method='POST', no_wait=no_wait, timeout=timeout)
+ return _site_operation(cmd, resource_group_name, name, silo, '/start', no_wait=no_wait, timeout=timeout)
def stop_site_silo(cmd, resource_group_name, name, silo, no_wait=False, timeout=DEFAULT_TIMEOUT):
- return _site(cmd, resource_group_name, name, silo, '/stop', method='POST', no_wait=no_wait, timeout=timeout)
+ return _site_operation(cmd, resource_group_name, name, silo, '/stop', no_wait=no_wait, timeout=timeout)
def start_silo_network_trace(
cmd, resource_group_name, name, silo, duration=None, max_frame_length=None,
sas_url=None, legacy=False, no_wait=False, timeout=DEFAULT_TIMEOUT):
- return _site(cmd, resource_group_name, name, silo, '/networkTrace/start' + ('' if legacy else 'Operation'),
- method='POST', query={
- 'durationInSeconds': duration, 'maxFrameLength': max_frame_length, 'sasUrl': sas_url},
- no_wait=no_wait, timeout=timeout)
+ return _site_operation(
+ cmd, resource_group_name, name, silo, '/networkTrace/start' + ('' if legacy else 'Operation'),
+ query={'durationInSeconds': duration, 'maxFrameLength': max_frame_length, 'sasUrl': sas_url},
+ no_wait=no_wait, timeout=timeout)
def stop_silo_network_trace(cmd, resource_group_name, name, silo):
@@ -636,8 +665,16 @@ def show_silo_antivirus_config(cmd, resource_group_name, name, silo):
return _site(cmd, resource_group_name, name, silo, '/antivirus/config')
-def update_silo_antivirus_config(cmd, resource_group_name, name, silo, body):
- return _site(cmd, resource_group_name, name, silo, '/antivirus/config', method='PUT', body=load_json(body))
+def update_silo_antivirus_config(cmd, resource_group_name, name, silo, body=None, delete_infected_files=None):
+ if body is not None:
+ if delete_infected_files is not None:
+ raise MutuallyExclusiveArgumentError('Use --delete-infected-files or --body, not both.')
+ payload = load_json(body)
+ else:
+ if delete_infected_files is None:
+ raise RequiredArgumentMissingError('Provide --delete-infected-files or --body.')
+ payload = {'properties': {'deleteInfectedFiles': _boolean(delete_infected_files)}}
+ return _site(cmd, resource_group_name, name, silo, '/antivirus/config', method='PUT', body=payload)
def list_silo_antivirus_logs(cmd, resource_group_name, name, silo):
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_client.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_client.py
index 21e103fc8d7..d443648cb3b 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_client.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_client.py
@@ -14,10 +14,11 @@
import requests
-from azure.cli.core.azclierror import (AzureConnectionError, AzureResponseError, FileOperationError,
- InvalidArgumentValueError, ResourceNotFoundError)
+from azure.cli.core.azclierror import (AzureConnectionError, AzureInternalError, AzureResponseError, BadRequestError,
+ FileOperationError, ForbiddenError, InvalidArgumentValueError,
+ ResourceNotFoundError, UnauthorizedError, UnclassifiedUserFault)
from azure.cli.command_modules.appservice._active_backup_client import (
- ActiveBackupClient, API_VERSION, load_json, segment)
+ ActiveBackupClient, API_VERSION, _service_error, load_json, segment)
MODULE = 'azure.cli.command_modules.appservice._active_backup_client'
@@ -109,7 +110,10 @@ def test_request_uses_cli_auth_cloud_and_no_redirects(self):
resource='https://audience.example.test/', subscription='subscription')
def test_service_errors_preserve_code_and_request_id(self):
- for status, error_type in ((404, ResourceNotFoundError), (409, AzureResponseError), (500, AzureResponseError)):
+ for status, error_type in (
+ (400, BadRequestError), (401, UnauthorizedError), (403, ForbiddenError),
+ (404, ResourceNotFoundError), (409, UnclassifiedUserFault), (429, UnclassifiedUserFault),
+ (500, AzureInternalError), (503, AzureInternalError)):
failed = response(status, {'error': {'code': 'SiloBusy', 'message': 'Try again.'}},
{'x-ms-request-id': 'request-one'})
with self.subTest(status=status), patch(MODULE + '.Profile') as profile, \
@@ -125,7 +129,7 @@ def test_service_errors_preserve_code_and_request_id(self):
with patch(MODULE + '.Profile') as profile, patch(MODULE + '.requests.Session') as factory:
profile.return_value.get_raw_token.return_value = (('Bearer', 'token', {}), None, None)
factory.return_value.__enter__.return_value.request.return_value = legacy
- with self.assertRaises(AzureResponseError) as caught:
+ with self.assertRaises(BadRequestError) as caught:
self.client.request('GET', self.resource)
self.assertIn('LegacyFailure', str(caught.exception))
self.assertIn('Legacy detail.', str(caught.exception))
@@ -386,17 +390,17 @@ def test_http_date_retry_after(self):
def test_only_transient_polling_reads_are_retried(self):
for status in (429, 500, 502, 503, 504):
- error = AzureResponseError('Transient failure')
- error.response = response(status, headers={'Retry-After': '0'})
+ error = _service_error(response(status, headers={'Retry-After': '0'}))
with self.subTest(status=status), patch.object(self.client, 'request', side_effect=[
error, response(payload={'status': 'Succeeded'})]) as request, patch(MODULE + '.time.sleep'):
self.assertEqual(self.client.wait(self.resource)['status'], 'Succeeded')
self.assertEqual(request.call_count, 2)
- error = AzureResponseError('Bad input')
- error.response = response(400)
- with patch.object(self.client, 'request', side_effect=error) as request, self.assertRaises(AzureResponseError):
- self.client.wait(self.resource)
- request.assert_called_once()
+ for status in (400, 401, 403, 409):
+ error = _service_error(response(status))
+ with self.subTest(status=status), patch.object(self.client, 'request', side_effect=error) as request, \
+ self.assertRaises(type(error)):
+ self.client.wait(self.resource)
+ request.assert_called_once()
def test_operation_inspection_preserves_pending_and_success(self):
running = self.client.operation(self.resource, response(202))
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py
index f14fa7c0977..21a324ae729 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py
@@ -7,14 +7,17 @@
import inspect
import io
import json
+import shlex
import unittest
from unittest.mock import patch
from urllib.parse import parse_qs, urlsplit
-from azure.cli.core.azclierror import AzureResponseError, InvalidArgumentValueError, ResourceNotFoundError
+from azure.cli.core.azclierror import (AzureResponseError, InvalidArgumentValueError, MutuallyExclusiveArgumentError,
+ RequiredArgumentMissingError, ResourceNotFoundError)
from azure.cli.command_modules.appservice import active_backup as commands
from azure.cli.command_modules.appservice._active_backup_client import ActiveBackupClient, API_VERSION
-from azure.cli.command_modules.appservice._active_backup_commands import COMMAND_GROUPS
+from azure.cli.command_modules.appservice._active_backup_commands import (
+ ADDITIONAL_EXAMPLES, ARGUMENT_HELP, COMMAND_GROUPS, COMMAND_SUMMARIES)
from .test_active_backup_client import cli_context, response
@@ -294,6 +297,50 @@ def test_normal_not_found_is_not_empty_success_or_primary_fallback(self):
commands.show_silo_config(self.cmd, 'group', 'primary', 'backup')
self.request.assert_called_once()
+ def test_named_diagnostic_fields_build_the_service_envelopes(self):
+ commands.check_silo_dns(self.cmd, 'group', 'primary', 'backup', dns_name='example.com')
+ self.assertEqual(self.request.call_args.args[:3], (
+ 'POST', self.client.resource('backup') + '/dnsCheck', {'properties': {'dnsName': 'example.com'}}))
+ commands.check_silo_tcp(self.cmd, 'group', 'primary', 'backup', host='example.com', port=443)
+ self.assertEqual(self.request.call_args.args[:3], (
+ 'POST', self.client.resource('backup') + '/tcpPingCheck',
+ {'properties': {'host': 'example.com', 'port': '443'}}))
+ commands.update_silo_antivirus_config(
+ self.cmd, 'group', 'primary', 'backup', delete_infected_files=False)
+ self.assertEqual(self.request.call_args.args[:3], (
+ 'PUT', self.client.resource('backup') + '/antivirus/config',
+ {'properties': {'deleteInfectedFiles': False}}))
+
+ def test_named_request_fields_reject_ambiguous_or_incomplete_input(self):
+ cases = (
+ (commands.check_silo_dns, {}, RequiredArgumentMissingError),
+ (commands.check_silo_dns, {'dns_name': ''}, InvalidArgumentValueError),
+ (commands.check_silo_dns, {'dns_name': 'example.com', 'body': '{}'}, MutuallyExclusiveArgumentError),
+ (commands.check_silo_tcp, {'host': 'example.com'}, RequiredArgumentMissingError),
+ (commands.check_silo_tcp, {'host': '', 'port': 443}, InvalidArgumentValueError),
+ (commands.check_silo_tcp, {'host': 'example.com', 'port': 65536}, InvalidArgumentValueError),
+ (commands.check_silo_tcp, {'host': 'example.com', 'port': True}, InvalidArgumentValueError),
+ (commands.check_silo_tcp, {'host': 'example.com', 'body': '{}'}, MutuallyExclusiveArgumentError),
+ (commands.update_silo_antivirus_config, {}, RequiredArgumentMissingError),
+ (commands.update_silo_antivirus_config,
+ {'delete_infected_files': False, 'body': '{}'}, MutuallyExclusiveArgumentError),
+ )
+ for handler, kwargs, error in cases:
+ with self.subTest(handler=handler.__name__, kwargs=kwargs), self.assertRaises(error):
+ handler(self.cmd, 'group', 'primary', 'backup', **kwargs)
+ self.request.assert_not_called()
+
+ def test_operation_protocol_selection_is_explicit(self):
+ with patch.object(self.client, 'mutate') as mutate:
+ commands.start_site_silo(self.cmd, 'group', 'primary', 'backup', no_wait=True, timeout=7)
+ mutate.assert_called_once_with(
+ 'POST', self.client.resource('backup') + '/start', no_wait=True, timeout=7, query=None)
+ self.request.assert_not_called()
+ mutate.reset_mock()
+ commands.show_silo_config(self.cmd, 'group', 'primary', 'backup')
+ mutate.assert_not_called()
+ self.request.assert_called_once()
+
def test_plan_creation_and_explicit_false(self):
commands.create_plan_silo(self.cmd, 'group', 'primary', 'backup', 'westus2',
primary_location='eastus', number_of_workers=2, zone_redundant='false')
@@ -430,6 +477,45 @@ def test_all_registered_handlers_and_help_exist(self):
self.assertIn('examples:', helps[command])
self.assertIn('cmd', inspect.signature(getattr(commands, handler)).parameters)
self.assertEqual(len(seen), 104)
+ self.assertEqual({handler for entries in COMMAND_GROUPS.values() for _, handler in entries},
+ set(COMMAND_SUMMARIES))
+ self.assertEqual(set(COMMAND_SUMMARIES), set(ADDITIONAL_EXAMPLES))
+ arguments = {
+ argument for entries in COMMAND_GROUPS.values() for _, handler in entries
+ for argument in inspect.signature(getattr(commands, handler)).parameters
+ if argument not in ('cmd', 'name', 'resource_group_name', 'silo', 'no_wait')
+ }
+ self.assertFalse(arguments.difference(ARGUMENT_HELP))
+
+ def test_authored_examples_cover_every_handler_parameter(self):
+ import yaml
+ from knack.help_files import helps
+ aliases = {
+ 'resource_group_name': {'-g', '--resource-group'}, 'name': {'-n', '--name'},
+ 'filter_string': {'--filter'},
+ }
+ for group, entries in COMMAND_GROUPS.items():
+ for verb, handler in entries:
+ command = group + ' ' + verb
+ with self.subTest(command=command):
+ examples = yaml.safe_load(helps[command])['examples']
+ self.assertGreaterEqual(len(examples), 2)
+ flags = {token for example in examples for token in shlex.split(example['text'])}
+ for parameter in inspect.signature(getattr(commands, handler)).parameters:
+ if parameter == 'cmd':
+ continue
+ options = aliases.get(parameter, {'--' + parameter.replace('_', '-')})
+ self.assertTrue(options.intersection(flags), '{} lacks an example.'.format(parameter))
+
+ def test_no_wait_restart_examples_do_not_request_a_synchronous_service_restart(self):
+ import yaml
+ from knack.help_files import helps
+ examples = yaml.safe_load(helps['webapp silo restart'])['examples']
+ no_wait = [shlex.split(example['text']) for example in examples if '--no-wait' in example['text']]
+ self.assertTrue(no_wait)
+ for arguments in no_wait:
+ self.assertIn('--synchronous', arguments)
+ self.assertEqual(arguments[arguments.index('--synchronous') + 1], 'false')
class ActiveBackupHelpTests(unittest.TestCase):
@@ -478,6 +564,35 @@ def test_named_command_executes_exact_silo_request(self):
'webapp silo config show -g group -n primary --silo backup'.split()), 0)
self.assertEqual(request.call_args.args[:2], ('GET', client.resource('backup') + '/config/web'))
+ def test_cli_named_request_options_preserve_false_and_port_types(self):
+ cases = (
+ ('webapp silo network-check dns --dns-name example.com', {'dnsName': 'example.com'}),
+ ('webapp silo network-check tcp-ping --host example.com --port 443',
+ {'host': 'example.com', 'port': '443'}),
+ ('webapp silo antivirus config update --delete-infected-files false', {'deleteInfectedFiles': False}),
+ )
+ for command, properties in cases:
+ with self.subTest(command=command):
+ client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
+ with patch(MODULE + '._client', return_value=client), \
+ patch.object(client, 'request', return_value=response(payload={})) as request:
+ self.assertEqual(self.invoke(
+ command.split() + ['-g', 'group', '-n', 'primary', '--silo', 'backup']), 0)
+ self.assertEqual(request.call_args.args[2], {'properties': properties})
+
+ def test_cli_help_explains_units_and_endpoint_variants(self):
+ for command, terms in (
+ ('webapp silo log startup show-summary', ('hours', '24')),
+ ('webapp silo network-trace start', ('seconds', 'bytes', 'legacy', 'asynchronous')),
+ ('webapp silo auth v2 show', ('legacy', '/list')),
+ ('webapp silo network-check dns', ('--dns-name', 'example.com')),
+ ('webapp silo network-check tcp-ping', ('--host', '--port', '65535')),
+ ('webapp silo antivirus config update', ('--delete-infected-files', 'false'))):
+ with self.subTest(command=command):
+ _, output = self.invoke_output(command.split() + ['--help'])
+ for term in terms:
+ self.assertIn(term, output)
+
def test_cli_updates_ignore_configured_location_defaults(self):
for group, resource_type in (('appservice plan silo', 'serverfarms'), ('webapp silo', 'sites')):
for explicit_location in (False, True):
@@ -564,6 +679,42 @@ def test_nested_ids_are_typed_and_do_not_silently_override_names(self):
self.assertNotEqual(self.invoke(arguments), 0)
request.assert_not_called()
+ def test_cli_ids_reject_abbreviated_explicit_identity_options(self):
+ subscription = '00000000-0000-0000-0000-000000000001'
+ for group, resource_type in (('webapp silo', 'sites'), ('appservice plan silo', 'serverfarms')):
+ resource_id = '/subscriptions/{}/resourceGroups/group/providers/Microsoft.Web/{}/primary/silos/backup'.format(
+ subscription, resource_type)
+ for verb in ('show', 'delete'):
+ for option, value in (
+ ('--na', 'other'), ('--resource-g', 'other'), ('--sil', 'other'),
+ ('--sub', subscription), ('--subscription', subscription)):
+ with self.subTest(group=group, verb=verb, option=option), \
+ patch(MODULE + '._client') as factory, \
+ patch('azure.cli.core._profile.Profile.load_cached_subscriptions',
+ return_value=[{'id': subscription, 'name': 'test'}]):
+ arguments = (group + ' ' + verb).split() + ['--ids', resource_id, option, value]
+ if verb == 'delete':
+ arguments += ['--yes']
+ self.assertNotEqual(self.invoke(arguments), 0)
+ factory.assert_not_called()
+
+ def test_cli_id_only_show_and_delete_remain_valid_for_both_resource_families(self):
+ subscription = '00000000-0000-0000-0000-000000000001'
+ for group, resource_type in (('webapp silo', 'sites'), ('appservice plan silo', 'serverfarms')):
+ client = ActiveBackupClient(cli_context(), 'group', 'primary', resource_type, subscription=subscription)
+ resource_id = client.resource('backup')
+ for verb in ('show', 'delete'):
+ with self.subTest(group=group, verb=verb), \
+ patch(MODULE + '._client', return_value=client), \
+ patch.object(client, 'request', return_value=response(payload={'id': resource_id})) as request:
+ arguments = (group + ' ' + verb).split() + ['--ids', resource_id]
+ if verb == 'delete':
+ arguments += ['--yes']
+ self.assertEqual(self.invoke(arguments), 0)
+ request.assert_called_once()
+ self.assertEqual(request.call_args.args[:2],
+ ('DELETE' if verb == 'delete' else 'GET', resource_id))
+
def test_cli_explicit_false_and_no_wait(self):
client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
initial = response(202, headers={'Location': client.resource('backup') + '/operationresults/op'})
From 8995e9cf3cdf9c9c14fb046b15f5620a3f08d3cd Mon Sep 17 00:00:00 2001
From: Your Name
Date: Thu, 22 Oct 2026 16:02:04 -0500
Subject: [PATCH 6/6] [App Service] Generate Active Backup preview commands
with AAZ
Replace the handwritten transport and signature-driven command dispatch with the spec-limited 24-command AAZ preview. Retain narrow generated-command subclasses for observed resource-envelope, polling, origin-read, VNet-response and conflict-format compatibility. Keep portable offline and gated scenario tests, and remove developer-only PowerShell validation tooling from the feature PR while preserving it locally. Companion model publication, replay recordings and full azdev gates remain review-readiness work.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 224abf0a-0923-4347-a5ea-5c4a1b4e4824
---
doc/active_backup_preview.md | 398 ++--
.../appservice/ActiveBackupTestHarness.psm1 | 1782 -----------------
scripts/appservice/README.md | 326 ---
scripts/appservice/test_active_backup.ps1 | 152 --
.../tests/Test-ActiveBackupHarness.ps1 | 1129 -----------
.../appservice/_active_backup_client.py | 375 ----
.../appservice/_active_backup_commands.py | 861 --------
.../cli/command_modules/appservice/_help.py | 2 -
.../cli/command_modules/appservice/_params.py | 2 -
.../command_modules/appservice/_validators.py | 30 +-
.../appservice/plan/silo/__cmd_group.py | 24 +
.../latest/appservice/plan/silo/__init__.py | 17 +
.../latest/appservice/plan/silo/_create.py | 321 +++
.../latest/appservice/plan/silo/_delete.py | 180 ++
.../aaz/latest/appservice/plan/silo/_list.py | 252 +++
.../aaz/latest/appservice/plan/silo/_show.py | 253 +++
.../latest/appservice/plan/silo/_update.py | 469 +++++
.../aaz/latest/appservice/plan/silo/_wait.py | 254 +++
.../plan/silo/vnet_integration/__cmd_group.py | 24 +
.../plan/silo/vnet_integration/__init__.py | 12 +
.../plan/silo/vnet_integration/_list.py | 227 +++
.../aaz/latest/webapp/__cmd_group.py | 23 +
.../appservice/aaz/latest/webapp/__init__.py | 11 +
.../webapp/last_known_good/__cmd_group.py | 24 +
.../latest/webapp/last_known_good/__init__.py | 14 +
.../latest/webapp/last_known_good/_list.py | 228 +++
.../latest/webapp/last_known_good/_mark.py | 216 ++
.../latest/webapp/last_known_good/_show.py | 216 ++
.../webapp/managed_traffic/__cmd_group.py | 24 +
.../latest/webapp/managed_traffic/__init__.py | 14 +
.../latest/webapp/managed_traffic/_list.py | 240 +++
.../latest/webapp/managed_traffic/_show.py | 221 ++
.../latest/webapp/managed_traffic/_update.py | 442 ++++
.../aaz/latest/webapp/silo/__cmd_group.py | 24 +
.../aaz/latest/webapp/silo/__init__.py | 18 +
.../aaz/latest/webapp/silo/_create.py | 400 ++++
.../aaz/latest/webapp/silo/_delete.py | 180 ++
.../aaz/latest/webapp/silo/_list.py | 308 +++
.../aaz/latest/webapp/silo/_refresh.py | 137 ++
.../aaz/latest/webapp/silo/_show.py | 309 +++
.../aaz/latest/webapp/silo/_update.py | 543 +++++
.../aaz/latest/webapp/silo/_wait.py | 310 +++
.../silo/vnet_integration/__cmd_group.py | 24 +
.../webapp/silo/vnet_integration/__init__.py | 15 +
.../webapp/silo/vnet_integration/_add.py | 269 +++
.../webapp/silo/vnet_integration/_remove.py | 156 ++
.../webapp/silo/vnet_integration/_show.py | 226 +++
.../webapp/silo/vnet_integration/_update.py | 265 +++
.../appservice/active_backup.py | 718 -------
.../command_modules/appservice/commands.py | 32 +-
.../appservice/operations/__init__.py | 4 +
.../appservice/operations/active_backup.py | 319 +++
.../latest/active_backup_command_catalog.json | 30 +
.../tests/latest/test_active_backup_aaz.py | 897 +++++++++
.../tests/latest/test_active_backup_client.py | 497 -----
.../latest/test_active_backup_commands.py | 29 +-
.../test_active_backup_commands_thru_mock.py | 939 ---------
57 files changed, 8392 insertions(+), 7020 deletions(-)
delete mode 100644 scripts/appservice/ActiveBackupTestHarness.psm1
delete mode 100644 scripts/appservice/README.md
delete mode 100644 scripts/appservice/test_active_backup.ps1
delete mode 100644 scripts/appservice/tests/Test-ActiveBackupHarness.ps1
delete mode 100644 src/azure-cli/azure/cli/command_modules/appservice/_active_backup_client.py
delete mode 100644 src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/__cmd_group.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/__init__.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_create.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_delete.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_list.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_show.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_update.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_wait.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/vnet_integration/__cmd_group.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/vnet_integration/__init__.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/vnet_integration/_list.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/__cmd_group.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/__init__.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/__cmd_group.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/__init__.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/_list.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/_mark.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/_show.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/__cmd_group.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/__init__.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/_list.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/_show.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/_update.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/__cmd_group.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/__init__.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_create.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_delete.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_list.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_refresh.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_show.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_update.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_wait.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/__cmd_group.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/__init__.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/_add.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/_remove.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/_show.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/_update.py
delete mode 100644 src/azure-cli/azure/cli/command_modules/appservice/active_backup.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/operations/__init__.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/operations/active_backup.py
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/tests/latest/active_backup_command_catalog.json
create mode 100644 src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_aaz.py
delete mode 100644 src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_client.py
delete mode 100644 src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py
diff --git a/doc/active_backup_preview.md b/doc/active_backup_preview.md
index 018548014a7..2e54a6d1d63 100644
--- a/doc/active_backup_preview.md
+++ b/doc/active_backup_preview.md
@@ -1,257 +1,205 @@
# App Service Active Backup preview
-The preview commands manage plan and site **silos** through public ARM APIs. They
-do not configure storage-backed App Service backups, deployment slots, or slot
-traffic routing.
+The preview manages plan and site **silos** through public ARM APIs. It does not
+configure storage-backed backups, deployment slots, or slot traffic routing.
-## Availability and API version
+## Specification and scope
-New commands use `2026-08-01`, the version registered for the child resources in
-the inspected App Service ARM manifest. Ordinary app/plan commands retain their
-existing API versions. Direct GeoMaster tests using an older date do not establish
-public ARM availability. An unavailable version or feature is reported as a service
-error; the CLI does not silently downgrade or call internal GeoMaster endpoints.
+Commands are generated with AAZDev from the `2026-08-01` App Service contract in
+[azure-rest-api-specs PR 44330](https://github.com/Azure/azure-rest-api-specs/pull/44330),
+using source commit `33ee07b2b3d377b58c4d1a1c5be7e2339f43b879`.
+That specification is still under review. Existing ordinary app/plan commands
+retain their API versions and support levels.
-The intended creation preview is Windows/Linux dedicated multitenant plans in the
-PremiumV3, PremiumMV3, PremiumV4, and PremiumMV4 tier families. **P0v3 is excluded**.
-Linux requires service-side flavor enablement. Installing these commands does not
-enable a subscription or region, grant quota, or change service eligibility.
+The first implementation exposes 24 preview commands covering the contract's
+20 HTTP operations plus CLI update/wait behavior:
-Create a plan silo before a site silo in the same target region. Silo names need
-not match. Region uniqueness, eligible SKUs, slots, primary deletion restrictions,
-and plan placement remain service-enforced. Creating the first site silo can
-restart the primary app. Cross-resource-group/subscription source-plan placement
-is not established as supported by the current backend.
-
-## Create, inspect, and update
-
-Assume an existing eligible primary plan and app in `eastus`. Creating silos
-allocates billable resources; choose a region enabled for your subscription.
+| Group | Commands |
+|---|---|
+| `appservice plan silo` | create, update, show, list, delete, wait |
+| `appservice plan silo vnet-integration` | list |
+| `webapp silo` | create, update, show, list, delete, wait, refresh |
+| `webapp silo vnet-integration` | show, add, update, remove |
+| `webapp managed-traffic` | list, show, update |
+| `webapp last-known-good` | list, show, mark |
+
+Configuration, deployments, processes, containers, logs, WebJobs, diagnostics,
+metrics, antivirus, extension invocation and other silo pass-through APIs are
+deferred. Standalone operation-status commands and managed-traffic metrics are
+also deferred because this specification does not define them.
+
+AAZ supplies authentication, cloud selection, error handling, pagination,
+serialization and long-running-operation handling. The CLI does not downgrade
+the API version or call internal GeoMaster endpoints. Thin subclasses of the
+generated silo commands supply a missing top-level location field in the draft
+contract; they do not replace its HTTP client or poller. A small identity validator
+rejects plan, slot and extension-resource IDs that core ID expansion could
+otherwise reinterpret as a different silo.
+
+## Availability
+
+The intended creation preview is Windows/Linux dedicated multitenant plans in
+PremiumV3, PremiumMV3, PremiumV4 and PremiumMV4 tier families. P0v3 is excluded.
+Subscription, region, flavor, SKU, capacity, placement and origin restrictions
+remain service-enforced. Installing the CLI does not enable the feature.
+
+Create a plan silo before a site silo in the same target region. Creating the
+first site silo can restart the primary app. Silos allocate billable resources;
+there is no automatic plan-plus-app setup, cascading teardown or safe failover
+workflow.
+
+## Create and update
+
+Assume an existing eligible primary plan and app in `eastus`:
```azurecli
-az appservice plan silo create -g MyGroup -n MyPlan --silo WestPlan --location westus2 --primary-location eastus --number-of-workers 2
-az webapp silo create -g MyGroup -n MyApp --silo WestApp --location westus2 --primary-location eastus --traffic-mode Active
-az webapp silo show -g MyGroup -n MyApp --silo WestApp
-az webapp silo list -g MyGroup -n MyApp
-az appservice plan silo update -g MyGroup -n MyPlan --silo WestPlan --zone-redundant false
-az webapp silo update -g MyGroup -n MyApp --silo WestApp --follow-last-known-good false
+az appservice plan silo create --resource-group MyGroup --name MyPlan --silo WestPlan --location westus2 --primary-location eastus --number-of-workers 2
+az webapp silo create --resource-group MyGroup --name MyApp --silo WestApp --location westus2 --primary-location eastus --traffic-mode Active
+az webapp silo show --resource-group MyGroup --name MyApp --silo WestApp
+az webapp silo list --resource-group MyGroup --name MyApp
+az appservice plan silo update --resource-group MyGroup --name MyPlan --silo WestPlan --zone-redundant false
+az webapp silo update --resource-group MyGroup --name MyApp --silo WestApp --follow-last-known-good false
```
-Resource commands return the ARM envelope. This illustrates the shape, not a
-captured live response or a promised revision number:
+`--name` identifies the primary; `--silo` identifies the child. Both create
+commands require explicit target `--location` and `--primary-location`.
+A configured default location does not select a replica region. The draft
+specification maps these values to `properties.siloLocation` and
+`properties.primaryLocation`, but the deployed API also requires top-level
+`location`. The compatibility layer supplies that root field, retaining the
+generated property mappings until the service specification is corrected.
-```output
+For example, a site create request contains:
+
+```json
{
- "id": "/subscriptions/SUB/resourceGroups/MyGroup/providers/Microsoft.Web/sites/MyApp/silos/WestApp",
- "name": "WestApp",
- "type": "Microsoft.Web/sites/silos",
"location": "westus2",
"properties": {
- "provisioningState": "Succeeded",
- "revisionNumber": 7,
- "followLastKnownGood": false
+ "siloLocation": "westus2",
+ "primaryLocation": "eastus",
+ "trafficMode": "Active"
}
}
```
-`--name` always identifies the primary. `--silo` identifies the child. Supported
-non-create commands also accept a complete nested silo ARM ID through `--ids`;
-do not combine it with named resource arguments. No internal replica app or plan
-name is required.
-
-Create requires an explicit target `--location`; a configured default location
-does not choose the silo region. Update preserves the existing silo location when
-`--location` is omitted, even if a different CLI default location is configured.
-
-Create/update/delete wait for their API operation, not future content/configuration
-revision, probe-health, or DNS convergence. `--no-wait` returns an accepted-operation
-receipt when the service starts an asynchronous operation.
-After an asynchronous create/update succeeds, the CLI reads and returns the
-original target resource, rather than returning the operation-status envelope.
-
-The receipt contains `resourceId`, `operationId`, `operationUrl`, and
-`status: Accepted`. It is returned in the selected output format; `--output none`
-explicitly suppresses it.
-
-No-wait skips subsequent operation polling; the initial request must still
-complete. For restart, use `--synchronous false --no-wait` when you also want
-the service to avoid synchronous restart work in that initial request.
-
-```powershell
-$operation = az appservice plan silo create -g MyGroup -n MyPlan --silo WestPlan --location westus2 --primary-location eastus --no-wait | ConvertFrom-Json
-az appservice plan silo wait -g MyGroup -n MyPlan --silo WestPlan --operation-id $operation.operationId --timeout 7200
-```
-
-A synchronous/no-op result is already complete and does not need an operation
-wait. A CLI timeout does not cancel the service operation. `silo operation show`
-inspects a CRUD operation; `webapp silo runtime-operation show` inspects a runtime
-operation instead.
-
-Both plan and site `silo operation show`/`silo wait` request the API's asynchronous
-status representation (`azureAsyncOperation=1`). They report operation status
-without reading the target resource after success, including after deletion.
-Polling errors remain errors; a missing resource alone is not proof of successful
-operation completion.
-
-Worker overrides are 1-30. Omitted fields preserve service defaults/current values;
-explicit `false` is not omission. Location cannot be changed by update. The current
-API does not establish resetting existing plan overrides back to inheritance.
-
-## Traffic, origin, Last Known Good, and refresh
-
-For the site `origin` entry only, a point-read HTTP 404 can be worked around using
-the exact matching origin resource returned by the paginated silo collection.
-The CLI emits a warning when it does this for show, update pre-read, or update
-readback. The returned object is the service's collection entry, without invented
-fields; stamp-derived IP fields from point reads may be absent. Missing,
-duplicate, or malformed origin entries and list/permission failures remain
-errors. Ordinary replica, plan-origin and pass-through reads are unchanged.
-Origin writes and operation polling still target their original API routes;
-the workaround does not prove that the raw ARM point endpoint is working.
+Resource commands return the modeled ARM resource; collections return item
+arrays and follow `nextLink`. Generic GET+PUT updates preserve existing modeled
+writable fields, exclude read-only fields, and support `--set`, `--add` and
+`--remove`. Generic property paths include the envelope, for example
+`--set properties.instanceNumberOverride=2`. Explicit `false` is not omission.
+Updates preserve the region returned by the service, even if it is only present
+at the root of the response. Missing or conflicting region metadata is an error;
+the CLI does not guess from the primary region or a configured default.
+An existing silo's location cannot be changed or removed. Service validation
+remains authoritative for worker counts and other resource restrictions.
+
+Silo show/update/delete/wait and site networking commands support a complete silo
+resource ID through `--ids`. Plan VNet listing uses the named parent/silo arguments.
+App-level traffic/LKG/refresh use the primary app's
+identity, not an internal replica name. As with standard CLI ID expansion, use
+either `--ids` or named identity arguments.
+
+## Async behavior and waits
+
+Create/update/delete use standard AAZ long-running-operation handling.
+`--no-wait` submits the initial request without subsequent polling and returns
+no custom operation receipt. It does not make the initial HTTP request instant.
```azurecli
-az webapp managed-traffic show -g MyGroup -n MyApp
-az webapp managed-traffic list -g MyGroup -n MyApp --expand-endpoints
-az webapp managed-traffic update -g MyGroup -n MyApp --traffic-mode Passive
-az webapp managed-traffic update -g MyGroup -n MyApp --endpoints @endpoints.json
-az webapp silo update -g MyGroup -n MyApp --silo WestApp --endpoint-status Enabled
-az webapp silo update -g MyGroup -n MyApp --silo origin --endpoint-status Disabled
-az webapp last-known-good mark -g MyGroup -n MyApp
-az webapp last-known-good show -g MyGroup -n MyApp
-az webapp silo refresh -g MyGroup -n MyApp
-```
-
-An endpoint file is an array of partial, region-keyed overrides, for example:
-
-```json
-[
- {"region": "eastus", "priority": 1},
- {"region": "westus2", "priority": 2}
-]
+az webapp silo create --resource-group MyGroup --name MyApp --silo WestApp --location westus2 --primary-location eastus --traffic-mode Active --no-wait
+az webapp silo wait --resource-group MyGroup --name MyApp --silo WestApp --created --interval 10 --timeout 1800
```
-The first site silo requires `Active` or `Passive` traffic mode. Change subsequent
-mode on the app-wide managed-traffic resource. Active uses weights; Passive uses
-priorities. The service validates endpoint membership, readiness, uniqueness, and
-the merged state. Explicit weights switch those endpoints to manual weighting;
-the API does not currently establish a reset to automatic weighting.
-
-`origin` represents the primary endpoint. Only endpoint status is updatable on
-it through the silo update API; it is not a separately creatable/deletable replica.
-Verify that the target silo is healthy and enabled before disabling origin.
-These commands do not perform a safe failover workflow on the caller's behalf.
-
-LKG mark selects the latest ready revision; it is not arbitrary revision selection
-or a primary rollback. Refresh succeeds immediately on HTTP 204 **acceptance** and
-prints an asynchronous-propagation notice to stderr. It does not poll revisions,
-invent a target revision number, or guarantee completion within a fixed interval.
-Use silo show/list to observe revisions, provisioning state, and error details.
-
-## Networking
+The separate `wait` commands poll the **resource**, using standard conditions
+such as `--exists`, `--created`, `--updated`, `--deleted` and `--custom`.
+They do not accept an operation ID. Provisioning completion does not establish
+content/configuration revision, endpoint health or DNS convergence. Observe those
+separately. A CLI timeout does not cancel the service operation.
+
+Until the service fixes empty pending Azure async-status responses, silo
+create/update/delete use a per-operation response hook to prefer the provided
+`Location` monitor. The SDK's standard Location poller keeps waiting on empty
+202s; it does not treat acceptance as completion or hide failures. Azure-only
+responses without a Location header retain normal Azure async polling.
+
+Plan-silo deletion also supplies AAZ's existing case-insensitive error formatter
+for HTTP 409, preserving legacy App Service `Code`/`Message` details that core's
+default conflict mapping otherwise loses. The exception remains a conflict and
+the command still fails. Empty or unrecognized bodies remain generic failures;
+the CLI never invents a dependency-specific reason.
+
+Until the service fixes origin point reads, an origin-only compatibility fallback
+handles HTTP 404 by using the generated silo list command and its pagination.
+Show, update pre-reads and resource waits accept only a unique matching ARM ID
+with the expected resource type and properties, and warn that the collection
+response may omit stamp-derived IP fields. Missing origins and unrelated errors
+remain errors; no resource metadata is fabricated. This does not change delete
+operation polling or treat a failed mutation as success.
+
+## Traffic, Last Known Good and refresh
```azurecli
-az webapp silo vnet-integration add -g MyGroup -n MyApp --silo WestApp --subnet /subscriptions/SUB/resourceGroups/NETGROUP/providers/Microsoft.Network/virtualNetworks/WestNet/subnets/integration
-az webapp silo vnet-integration show -g MyGroup -n MyApp --silo WestApp
-az appservice plan silo vnet-integration list -g MyGroup -n MyPlan --silo WestPlan
-az webapp silo vnet-integration remove -g MyGroup -n MyApp --silo WestApp
+az webapp managed-traffic show --resource-group MyGroup --name MyApp
+az webapp managed-traffic list --resource-group MyGroup --name MyApp --expand endpoints
+az webapp managed-traffic update --resource-group MyGroup --name MyApp --traffic-mode Passive
+az webapp managed-traffic update --resource-group MyGroup --name MyApp --traffic-mode Active --endpoints "[{region:eastus,weight:700},{region:westus2,weight:300}]"
+az webapp last-known-good mark --resource-group MyGroup --name MyApp
+az webapp last-known-good show --resource-group MyGroup --name MyApp
+az webapp silo refresh --resource-group MyGroup --name MyApp
```
-The site API uses the dedicated silo `virtualNetworkConnections/virtualNetwork`
-route. `update` uses PATCH. For full connection properties, `--body @request.json`
-accepts the API envelope, for example `{"properties":{"subnetResourceId":"..."}}`.
-Plan-silo integration inspection is read-only. No normal-primary-region network
-validator runs before a silo call.
+Endpoint collections use typed AAZ shorthand or `@file` input. Weights/priorities
+are integers from 1 to 1000, as defined by the specification. `--expand endpoints`
+is available on traffic list, not point show.
-## Operational pass-through commands
+Active uses weights; Passive uses priorities. Mark selects the latest ready
+revision, not an arbitrary revision or a primary rollback. Refresh returns
+success with no output on HTTP 204 **acceptance**; revision propagation continues
+asynchronously. It does not poll revisions or invent a target revision.
-The `webapp silo` subgroups cover configuration reads, configuration snapshots,
-deployment history/status, instances/processes, logs, WebJobs, installed extensions,
-extension invocation, network checks/traces, metrics, antivirus, and site-container
-reads. There is **no CLI-side OS/SKU/flavor/provisioning filter**: the requested silo
-route is called directly, and service errors such as 404 are surfaced normally.
-
-ARM collection list commands follow `nextLink` and return the item array, so
-`--query "[].name"` and table output include all pages. Property-bag responses,
-such as app settings, connection strings, storage accounts and metadata, retain
-their service shape. The startup-log response also retains its file/latest-log
-wrapper rather than being flattened into an ARM collection.
+## Networking and teardown
```azurecli
-az webapp silo config show -g MyGroup -n MyApp --silo WestApp
-az webapp silo config appsettings list -g MyGroup -n MyApp --silo WestApp
-az webapp silo instance list -g MyGroup -n MyApp --silo WestApp
-az webapp silo restart -g MyGroup -n MyApp --silo WestApp
-az webapp silo log startup list -g MyGroup -n MyApp --silo WestApp --latest true
-az webapp silo log container download -g MyGroup -n MyApp --silo WestApp --format zip --file logs.zip
+az webapp silo vnet-integration add --resource-group MyGroup --name MyApp --silo WestApp --subnet /subscriptions/SUB/resourceGroups/NetworkGroup/providers/Microsoft.Network/virtualNetworks/WestNet/subnets/integration
+az webapp silo vnet-integration show --resource-group MyGroup --name MyApp --silo WestApp
+az appservice plan silo vnet-integration list --resource-group MyGroup --name MyPlan --silo WestPlan
+az webapp silo vnet-integration update --resource-group MyGroup --name MyApp --silo WestApp --swift-supported false
+az webapp silo vnet-integration remove --resource-group MyGroup --name MyApp --silo WestApp --yes
+az webapp silo delete --resource-group MyGroup --name MyApp --silo WestApp --yes
+az appservice plan silo delete --resource-group MyGroup --name MyPlan --silo WestPlan --yes
```
-Instance-qualified process/container operations accept `--instance`. Binary
-downloads require `--file`; an existing destination requires `--overwrite`.
-`log tail` streams text, or exact bytes with `--file`. Configuration/connection
-string/auth/private-snapshot reads intentionally return sensitive values: protect
-their output and do not put it in source control.
-
-Use named options for DNS checks, TCP checks and antivirus configuration:
-
-```azurecli
-az webapp silo network-check dns -g MyGroup -n MyApp --silo WestApp --dns-name example.com
-az webapp silo network-check tcp-ping -g MyGroup -n MyApp --silo WestApp --host example.com --port 443
-az webapp silo antivirus config update -g MyGroup -n MyApp --silo WestApp --delete-infected-files false
-```
-
-For those commands, `--body` remains an advanced alternative and cannot be
-combined with the named request fields. TCP ports are integers from 1 to 65535;
-the CLI serializes the port using the service's string-valued field.
-
-Other request-body operations accept JSON or `@file` containing the API envelope.
-The advanced-body forms of the commands above use the same envelopes:
-
-| Operation | Example body |
-|---|---|
-| `network-check dns` | `{"properties":{"dnsName":"example.com"}}` |
-| `network-check tcp-ping` | `{"properties":{"host":"example.com","port":"443"}}` |
-| `network-check connectivity` | `{"properties":{"providerType":"Web","credentials":{"credentialType":"AppSetting","appSetting":"MY_ENDPOINT"},"resourceMetadata":{"entityName":"example.com"}}}` |
-| `antivirus config update` | `{"properties":{"deleteInfectedFiles":false}}` |
-
-`extension invoke` requires an instance, relative `--path`, and supports
-GET/POST/PUT/PATCH/DELETE/HEAD with an optional body/content type/file. The service's
-extension-path restrictions still apply. Use `--uri-parameters KEY=VALUE` for
-extension query parameters; repeated keys are preserved and cannot override the
-ARM API version. It is not a general arbitrary-host REST client.
-
-The API has point process reads but no reviewed silo process-list route. No
-primary process-list fallback is used. Configuration snapshot reads do not imply
-snapshot restore. General configuration writes, direct deployment, identity
-assignment, and site-container writes are not invented where the service has no
-silo route. The publishing-profile silo route needs backend target-rewrite
-verification before it can be exposed safely.
-
-## Teardown
-
-Delete site silos before their backing plan silos; then use ordinary commands to
-delete the primary resources if desired. There is no automatic cascade.
-
-```azurecli
-az webapp silo delete -g MyGroup -n MyApp --silo WestApp --yes
-az appservice plan silo delete -g MyGroup -n MyPlan --silo WestPlan --yes
-```
-
-## Validation and release prerequisites
-
-Offline mock coverage exercises all 84 pass-through methods, management bodies,
-operation protocols, identity handling, file/stream behavior, and runtime help.
-It is not evidence that an API version/feature is deployed in a particular cloud
-or that a service-specific operation works for every silo.
-
-The opt-in scenario tests require an enabled preview subscription and explicit
-authorization to allocate resources. Set `AZURE_ACTIVE_BACKUP_LIVE=1` only for that
-purpose, along with `AZURE_ACTIVE_BACKUP_PRIMARY_LOCATION` and
-`AZURE_ACTIVE_BACKUP_SILO_LOCATION`. Linux also needs service-side enablement.
-No live recording or public ARM availability claim should be inferred from a
-passing mock suite.
-
-Use the repository's live runner only after enabling that opt-in:
-
-```powershell
-azdev test test_appservice_active_backup_windows test_appservice_active_backup_linux --live
-```
+Site networking uses `virtualNetworkConnections/virtualNetwork`; update sends
+PATCH. Plan networking inspection is read-only. Delete site silos before backing
+plan silos.
+
+The draft plan VNet-list contract declares an array, but the deployed API returns
+an ARM envelope whose `properties` is an array of subnet entries. A handwritten
+subclass accepts both shapes and maps the wrapped entries into the generated
+model, retaining normal list output without inventing resource IDs. Unexpected
+wrapped shapes are errors. Remove this adapter after correcting and regenerating
+the service contract; generated Python remains unchanged.
+
+## Validation and regeneration
+
+The independent test-owned command catalog is
+`tests\latest\active_backup_command_catalog.json` in the App Service module.
+Offline tests exercise actual CLI parsing and generated requests, outputs,
+pagination, async failures, resource waits and examples. They do not prove
+deployed availability or close service defects.
+
+The opt-in live scenarios cover only this limited surface. Live runs require
+explicit authorization, an enabled subscription and the expected regional
+capacity. These scenarios are currently live-only; replay recordings and full
+command/argument coverage checks remain publication work. Developer-local run
+reports, credentials and validation tooling are not committed test assets.
+
+AAZ command models must accompany eventual CLI publication in `Azure/aaz`.
+The local generation experiment retains those models under the ignored
+`.x\active-backup\aaz-spike\limited-models-v2` directory. Regenerate from command
+models; do not hand-edit the generated `aaz\latest` Python files. The generator's
+shared `SwiftVirtualNetwork` resource-ID inference warning and the unmerged
+service specification remain review dependencies. The location subclasses live
+in `operations\active_backup.py` and directly inherit the matching generated
+commands/operations. Remove this compatibility code after the service contract
+has been corrected and regenerated, with create/update/read regressions passing.
diff --git a/scripts/appservice/ActiveBackupTestHarness.psm1 b/scripts/appservice/ActiveBackupTestHarness.psm1
deleted file mode 100644
index d76efa9da36..00000000000
--- a/scripts/appservice/ActiveBackupTestHarness.psm1
+++ /dev/null
@@ -1,1782 +0,0 @@
-# Copyright (c) Microsoft Corporation. All rights reserved.
-# Licensed under the MIT License. See License.txt in the project root for license information.
-#requires -Version 7.4
-
-Set-StrictMode -Version Latest
-$ErrorActionPreference = 'Stop'
-
-function Get-AbValue {
- param($Object, [string]$Name, $Default = $null)
- if ($Object -is [System.Collections.IDictionary] -and $Object.Contains($Name)) { return $Object[$Name] }
- if ($null -ne $Object -and $null -ne $Object.PSObject.Properties[$Name]) { return $Object.$Name }
- return $Default
-}
-
-function Stop-Ab {
- param([string]$Message, [ValidateSet('Fail', 'BlockedEnvironment', 'BlockedFixture', 'Invalid', 'Cleanup')][string]$Kind = 'Fail',
- [Exception]$InnerException)
- $exception = [System.InvalidOperationException]::new($Message, $InnerException)
- $exception.Data['AbKind'] = $Kind
- $exception.Data['AbSafeMessage'] = $Message
- throw $exception
-}
-
-function Assert-Ab {
- param([bool]$Condition, [string]$Message)
- if (-not $Condition) { Stop-Ab $Message }
-}
-
-function Assert-AbLocalPath {
- param([string]$RepoRoot, [string]$Path, [switch]$RequireIgnored)
- $root = [IO.Path]::GetFullPath($RepoRoot).TrimEnd([IO.Path]::DirectorySeparatorChar)
- $full = [IO.Path]::GetFullPath($Path, $root)
- if (-not $full.StartsWith($root + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
- Stop-Ab 'Runtime paths must be inside the repository, not its root or an external directory.' Invalid
- }
- $item = $full
- while ($item -and $item.Length -ge $root.Length) {
- if (Test-Path -LiteralPath $item) {
- if ((Get-Item -Force -LiteralPath $item).Attributes -band [IO.FileAttributes]::ReparsePoint) {
- Stop-Ab 'Runtime paths must not traverse a reparse point.' Invalid
- }
- }
- $item = [IO.Path]::GetDirectoryName($item)
- }
- if ($RequireIgnored) {
- $relative = [IO.Path]::GetRelativePath($root, $full)
- & git -C $root check-ignore --quiet -- $relative
- if ($LASTEXITCODE -ne 0) { Stop-Ab 'Runtime/auth/output paths must be ignored by Git before use.' Invalid }
- }
- return $full
-}
-
-function Get-AbCommandCatalog {
- param([string]$RepoRoot)
- $path = Join-Path $RepoRoot 'src\azure-cli\azure\cli\command_modules\appservice\_active_backup_commands.py'
- if (-not (Test-Path -LiteralPath $path)) { Stop-Ab 'The checkout does not contain the Active Backup commands.' Invalid }
- $source = [IO.File]::ReadAllText($path)
- $section = ($source -split '(?m)^def load_command_table')[0]
- $groups = [regex]::Matches($section, "(?ms)^ '(?[^']+)': \((?.*?)(?=^ '[^']+':|\r?\n\})")
- $catalog = @()
- foreach ($group in $groups) {
- foreach ($entry in [regex]::Matches($group.Groups['entries'].Value, "\('(?[^']+)', '(?[^']+)'\)")) {
- $catalog += @{
- command = $group.Groups['group'].Value + ' ' + $entry.Groups['verb'].Value
- handler = $entry.Groups['handler'].Value
- }
- }
- }
- Assert-Ab ($catalog.Count -gt 0) 'Unable to read the explicit Active Backup command registry.'
- $tests = Join-Path $RepoRoot 'src\azure-cli\azure\cli\command_modules\appservice\tests\latest\test_active_backup_commands_thru_mock.py'
- $variants = @()
- foreach ($match in [regex]::Matches([IO.File]::ReadAllText($tests), "\('(?[PC]\d\d)', '(?[^']+)'")) {
- if ($match.Groups['handler'].Value -in @($catalog.handler) -and
- -not @($variants | Where-Object key -eq $match.Groups['key'].Value).Count) {
- $variants += @{ key = $match.Groups['key'].Value; handler = $match.Groups['handler'].Value }
- }
- }
- return @{ commands = @($catalog); variants = @($variants) }
-}
-
-function Invoke-AbProcess {
- param(
- [string]$Executable, [string[]]$Arguments, [string]$WorkingDirectory,
- [hashtable]$Environment = @{}, [int]$TimeoutSeconds = 120
- )
- $start = [Diagnostics.ProcessStartInfo]::new()
- $start.FileName = $Executable
- $start.WorkingDirectory = $WorkingDirectory
- $start.UseShellExecute = $false
- $start.RedirectStandardOutput = $true
- $start.RedirectStandardError = $true
- $start.StandardOutputEncoding = [Text.UTF8Encoding]::new($false)
- $start.StandardErrorEncoding = [Text.UTF8Encoding]::new($false)
- $start.Environment['PYTHONDONTWRITEBYTECODE'] = '1'
- $start.Environment['PYTHONIOENCODING'] = 'utf-8'
- foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) }
- foreach ($key in $Environment.Keys) { $start.Environment[$key] = [string]$Environment[$key] }
- $process = [Diagnostics.Process]::new()
- $process.StartInfo = $start
- $clock = [Diagnostics.Stopwatch]::StartNew()
- $started = $false
- try {
- if (-not $process.Start()) { Stop-Ab 'Unable to start the selected interpreter.' Invalid }
- $started = $true
- $stdout = $process.StandardOutput.ReadToEndAsync()
- $stderr = $process.StandardError.ReadToEndAsync()
- $completed = $process.WaitForExit([Math]::Min([int]::MaxValue, $TimeoutSeconds * 1000L))
- if (-not $completed) {
- $process.Kill($true)
- $process.WaitForExit()
- }
- return @{
- exitCode = $(if ($completed) { $process.ExitCode } else { 124 })
- stdout = $stdout.GetAwaiter().GetResult()
- stderr = $stderr.GetAwaiter().GetResult()
- elapsedMs = $clock.ElapsedMilliseconds
- timedOut = -not $completed
- }
- }
- finally {
- if ($started -and -not $process.HasExited) { $process.Kill($true); $process.WaitForExit() }
- $process.Dispose()
- }
-}
-
-function Resolve-AbPython {
- param([string]$RepoRoot, [string]$PythonExe)
- if ($PythonExe) {
- $candidate = [IO.Path]::GetFullPath($PythonExe, $RepoRoot)
- if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { Stop-Ab 'The selected Python executable does not exist.' Invalid }
- return $candidate
- }
- foreach ($relative in @('env\Scripts\python.exe', '.venv\Scripts\python.exe')) {
- $candidate = Join-Path $RepoRoot $relative
- if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate }
- }
- Stop-Ab 'Prepare a repository CLI environment or pass -PythonExe explicitly. The system az is not used.' Invalid
-}
-
-function New-AbContext {
- param(
- [string]$RepoRoot, [string]$Subscription, [string]$PrimaryRegion = 'eastus2euap',
- [string]$SiloRegion = 'centraluseuap', [string]$PythonExe,
- [string]$Profile = 'Core', [string]$OperatingSystem = 'Both',
- [string]$OutputRoot = '.x\active-backup\runs', [string]$AuthConfigDir = '.x\active-backup\auth',
- [string]$RunId, [int]$OperationTimeoutSeconds = 1800, [int]$ObservationTimeoutSeconds = 900,
- [int]$RunTimeoutMinutes = 120, [int]$CleanupTimeoutMinutes = 30, [int]$MaxWorkersPerPlan = 2,
- [bool]$IncludeSkuMatrix = $false, [bool]$IncludeInvasiveDiagnostics = $false,
- [bool]$IncludeZoneRedundancy = $false, [string]$Mode = 'Plan'
- )
- $root = [IO.Path]::GetFullPath($RepoRoot)
- if (-not (Test-Path -LiteralPath (Join-Path $root '.git'))) { Stop-Ab 'Select the azure-cli repository root.' Invalid }
- $guid = [guid]::Empty
- if (-not [guid]::TryParse($Subscription, [ref]$guid)) { Stop-Ab '-Subscription must be an explicit subscription GUID.' Invalid }
- if ($PrimaryRegion -notmatch '^[a-z0-9]+$' -or $SiloRegion -notmatch '^[a-z0-9]+$' -or $PrimaryRegion -eq $SiloRegion) {
- Stop-Ab 'Provide two distinct canonical Azure region names.' Invalid
- }
- if (-not $RunId) { $RunId = [DateTime]::UtcNow.ToString('yyyyMMddHHmmss') + '-' + [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(3)).ToLowerInvariant() }
- if ($RunId -notmatch '^\d{14}-[a-f0-9]{6}$') { Stop-Ab 'RunId must be the generated UTC timestamp and six hex characters.' Invalid }
- if ($MaxWorkersPerPlan -notin @(1, 2)) { Stop-Ab 'This harness supports a maximum of one or two workers per plan.' Invalid }
- $catalog = Get-AbCommandCatalog $root
- $out = Assert-AbLocalPath $root $OutputRoot -RequireIgnored
- $auth = Assert-AbLocalPath $root $AuthConfigDir -RequireIgnored
- $directory = Assert-AbLocalPath $root (Join-Path $out $RunId) -RequireIgnored
- if ($directory.StartsWith($auth + '\', [StringComparison]::OrdinalIgnoreCase) -or $auth.StartsWith($directory, [StringComparison]::OrdinalIgnoreCase)) {
- Stop-Ab 'Authentication and report directories must be separate.' Invalid
- }
- $sha = (& git -C $root rev-parse HEAD | Out-String).Trim()
- if ($LASTEXITCODE -ne 0) { Stop-Ab 'Cannot resolve the checkout commit.' Invalid }
- $client = Join-Path $root 'src\azure-cli\azure\cli\command_modules\appservice\_active_backup_client.py'
- $version = [regex]::Match([IO.File]::ReadAllText($client), "(?m)^API_VERSION = '([^']+)'").Groups[1].Value
- if ($version -ne '2026-08-01') { Stop-Ab 'The selected API version differs from the reviewed harness contract.' Invalid }
- $manifest = @{
- schemaVersion = 1; purpose = 'active-backup-cli-validation'; runId = $RunId; repoRoot = $root
- sourceCommit = $sha; subscription = $guid.ToString(); primaryRegion = $PrimaryRegion; siloRegion = $SiloRegion
- apiVersion = $version; profile = $Profile; operatingSystem = $OperatingSystem
- pythonExe = $PythonExe; authConfigDir = $auth; runDirectory = $directory
- createdUtc = [DateTime]::UtcNow.ToString('o'); owner = [Environment]::UserName
- state = 'Planned'; suites = @(); cases = @(); pendingMutation = $null
- cleanup = @{ status = 'NotStarted'; remaining = @() }
- options = @{
- operationTimeoutSeconds = $OperationTimeoutSeconds; observationTimeoutSeconds = $ObservationTimeoutSeconds
- runTimeoutMinutes = $RunTimeoutMinutes; cleanupTimeoutMinutes = $CleanupTimeoutMinutes
- maxWorkersPerPlan = $MaxWorkersPerPlan; includeSkuMatrix = $IncludeSkuMatrix
- includeInvasiveDiagnostics = $IncludeInvasiveDiagnostics; includeZoneRedundancy = $IncludeZoneRedundancy
- }
- }
- $manifest.sourceHashes = @{}
- foreach ($relative in @(
- 'src\azure-cli\azure\cli\command_modules\appservice\active_backup.py',
- 'src\azure-cli\azure\cli\command_modules\appservice\_active_backup_client.py',
- 'src\azure-cli\azure\cli\command_modules\appservice\_active_backup_commands.py',
- 'scripts\appservice\test_active_backup.ps1',
- 'scripts\appservice\ActiveBackupTestHarness.psm1')) {
- $manifest.sourceHashes[$relative] = (Get-FileHash -LiteralPath (Join-Path $root $relative) -Algorithm SHA256).Hash
- }
- return @{
- RepoRoot = $root; Subscription = $guid.ToString(); PrimaryRegion = $PrimaryRegion; SiloRegion = $SiloRegion
- PythonExe = $PythonExe; Profile = $Profile; OperatingSystem = $OperatingSystem; Mode = $Mode
- RunDirectory = $directory; ManifestPath = Join-Path $directory 'manifest.json'
- AuthConfigDir = $auth; Catalog = $catalog; Manifest = $manifest; CurrentCase = $null
- RunDeadline = [DateTime]::UtcNow.AddMinutes($RunTimeoutMinutes); CleanupDeadline = $null
- Executor = $null; HttpExecutor = $null; Lock = $null; ArmEndpoint = 'https://management.azure.com/'
- ApiVersion = $version; Environment = @{}; InCleanup = $false; ManifestReadHash = $null
- }
-}
-
-function Protect-AbDirectory {
- param([string]$Path)
- [IO.Directory]::CreateDirectory($Path) | Out-Null
- if ($IsWindows) {
- $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User
- $directory = [IO.DirectoryInfo]::new($Path)
- $acl = [IO.FileSystemAclExtensions]::GetAccessControl($directory, [Security.AccessControl.AccessControlSections]::Access)
- $acl.SetAccessRuleProtection($true, $false)
- foreach ($rule in $acl.GetAccessRules($true, $false, [Security.Principal.SecurityIdentifier])) {
- $acl.RemoveAccessRuleSpecific($rule)
- }
- foreach ($identity in @($sid, [Security.Principal.SecurityIdentifier]::new('S-1-5-18'))) {
- $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
- $identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
- }
- [IO.FileSystemAclExtensions]::SetAccessControl($directory, $acl)
- }
- else { [IO.File]::SetUnixFileMode($Path, [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor [IO.UnixFileMode]::UserExecute) }
-}
-
-function Initialize-AbLocalState {
- param([hashtable]$Context, [switch]$Existing)
- if (-not $Existing -and (Test-Path -LiteralPath $Context.RunDirectory)) { Stop-Ab 'The run directory already exists. Use its manifest with Resume or Cleanup.' Invalid }
- Protect-AbDirectory $Context.AuthConfigDir
- Protect-AbDirectory $Context.RunDirectory
- foreach ($name in @('temp', 'extensions', 'inputs', 'fixtures', 'downloads')) {
- Protect-AbDirectory (Join-Path $Context.RunDirectory $name)
- }
- $Context.Lock = [IO.File]::Open((Join-Path $Context.RunDirectory 'run.lock'), 'OpenOrCreate', 'ReadWrite', 'None')
- if ($Existing) {
- $currentBytes = [IO.File]::ReadAllBytes($Context.ManifestPath)
- $currentHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($currentBytes))
- if (-not $Context.ManifestReadHash -or $currentHash -cne $Context.ManifestReadHash) {
- Stop-Ab 'The recovery manifest changed before its lock was acquired. Reread the manifest and retry recovery.' Invalid
- }
- }
- $Context.PythonExe = Resolve-AbPython $Context.RepoRoot $Context.PythonExe
- $Context.Manifest.pythonExe = $Context.PythonExe
- $Context.Environment = @{
- PYTHONPATH = (Join-Path $Context.RepoRoot 'src\azure-cli') + [IO.Path]::PathSeparator + (Join-Path $Context.RepoRoot 'src\azure-cli-core')
- PYTHONDONTWRITEBYTECODE = '1'; PYTHONIOENCODING = 'utf-8'; AZURE_CONFIG_DIR = $Context.AuthConfigDir
- AZURE_EXTENSION_DIR = Join-Path $Context.RunDirectory 'extensions'; AZURE_CORE_COLLECT_TELEMETRY = 'false'
- AZURE_EXTENSION_USE_DYNAMIC_INSTALL = 'no'; TEMP = Join-Path $Context.RunDirectory 'temp'
- TMP = Join-Path $Context.RunDirectory 'temp'
- }
- Save-AbManifest $Context
-}
-
-function Write-AbJson {
- param([string]$Path, $Value)
- $temporary = $Path + '.' + [guid]::NewGuid().ToString('N') + '.tmp'
- try {
- [IO.File]::WriteAllText($temporary, (ConvertTo-Json -InputObject $Value -Depth 50), [Text.UTF8Encoding]::new($false))
- [IO.File]::Move($temporary, $Path, $true)
- }
- finally { if (Test-Path -LiteralPath $temporary) { [IO.File]::Delete($temporary) } }
-}
-
-function Save-AbManifest { param([hashtable]$Context) Write-AbJson $Context.ManifestPath $Context.Manifest }
-
-function Add-AbEvent {
- param([hashtable]$Context, [string]$Kind, [hashtable]$Data = @{})
- $record = @{ utc = [DateTime]::UtcNow.ToString('o'); kind = $Kind; data = $Data }
- [IO.File]::AppendAllText((Join-Path $Context.RunDirectory 'events.jsonl'),
- (ConvertTo-Json -InputObject $record -Depth 10 -Compress) + [Environment]::NewLine, [Text.UTF8Encoding]::new($false))
-}
-
-function Assert-AbProvenance {
- param([hashtable]$Context, [hashtable]$Provenance)
- foreach ($name in @('azure.cli', 'azure.cli.core', 'azure.cli.command_modules.appservice',
- 'azure.cli.command_modules.appservice.active_backup', 'azure.cli.command_modules.appservice._active_backup_client')) {
- $expected = Join-Path $Context.RepoRoot $(if ($name -eq 'azure.cli.core') { 'src\azure-cli-core' } else { 'src\azure-cli' })
- $path = [string](Get-AbValue $Provenance.modules $name '')
- if (-not $path -or -not ([IO.Path]::GetFullPath($path)).StartsWith($expected + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
- Stop-Ab 'Source provenance failed: an installed CLI module masked this checkout.' Invalid
- }
- }
- Assert-Ab ($Provenance.apiVersion -eq $Context.ApiVersion) 'Source probe API version mismatch.'
- Assert-Ab (@($Provenance.helpFailures).Count -eq 0) 'At least one Active Backup command help entry did not load.'
- Assert-Ab (@(Compare-Object @($Provenance.commands | Sort-Object) @($Context.Catalog.commands.command | Sort-Object)).Count -eq 0) 'Source command inventory mismatch.'
-}
-
-function Test-AbSource {
- param([hashtable]$Context)
- $code = @'
-import contextlib, importlib, io, json, sys
-names = ["azure.cli", "azure.cli.core", "azure.cli.command_modules.appservice",
- "azure.cli.command_modules.appservice.active_backup",
- "azure.cli.command_modules.appservice._active_backup_client"]
-modules = {name: importlib.import_module(name).__file__ for name in names}
-from azure.cli.command_modules.appservice._active_backup_client import API_VERSION
-from azure.cli.command_modules.appservice._active_backup_commands import COMMAND_GROUPS
-from azure.cli.core import get_default_cli
-cli = get_default_cli()
-failures = []
-for group, entries in COMMAND_GROUPS.items():
- for verb, _ in entries:
- command = group + " " + verb
- with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
- try:
- status = cli.invoke(command.split() + ["--help"])
- except SystemExit as ex:
- status = ex.code
- if status != 0:
- failures.append(command)
-print(json.dumps({"modules": modules, "apiVersion": API_VERSION, "helpFailures": failures,
- "commands": [g + " " + v for g, es in COMMAND_GROUPS.items() for v, _ in es],
- "pythonVersion": sys.version.split()[0]}))
-'@
- $result = Invoke-AbProcess $Context.PythonExe @('-c', $code) $Context.RepoRoot $Context.Environment 240
- if ($result.exitCode -ne 0) { Stop-Ab 'The interpreter cannot load the local CLI. Prepare the CLI development environment; no Azure call was made.' Invalid }
- try { $provenance = ConvertFrom-Json -InputObject $result.stdout -AsHashtable } catch { Stop-Ab 'The local source probe did not return valid JSON.' Invalid }
- Assert-AbProvenance $Context $provenance
- $Context.Manifest.provenance = $provenance
- Save-AbManifest $Context
-}
-
-function Get-AbCommandName {
- param([hashtable]$Context, [string[]]$Arguments)
- $joined = $Arguments -join ' '
- return @($Context.Catalog.commands.command | Where-Object {
- $joined -eq $_ -or $joined.StartsWith($_ + ' ', [StringComparison]::Ordinal)
- } | Sort-Object Length -Descending | Select-Object -First 1)
-}
-
-function Assert-AbResourceId {
- param([hashtable]$Context, [string]$Id, [string]$Group)
- $pattern = '^/subscriptions/(?[a-fA-F0-9-]{36})/resourceGroups/(?[^/]+)(?/.*)?$'
- if ($Id -notmatch $pattern -or $Matches.sub -ne $Context.Subscription) { Stop-Ab 'Resource ID is outside the selected subscription.' Invalid }
- if ($Group -and $Matches.group -ne $Group) { Stop-Ab 'Resource ID is outside the owned resource group.' Invalid }
-}
-
-function Get-AbSiloResponseMetadata {
- param([hashtable]$Context, $Payload)
- $metadata = @{ shape = $(if ($null -eq $Payload) { 'null' } elseif ($Payload -is [Collections.IDictionary]) { 'object' }
- elseif ($Payload -is [array]) { 'array' } else { 'scalar' }) }
- if ($Payload -isnot [Collections.IDictionary]) { return $metadata }
- foreach ($field in @('id', 'resourceId', 'status', 'type', 'location')) {
- if (-not $Payload.Contains($field)) { continue }
- $value = $Payload[$field]
- if ($null -eq $value) { $metadata[$field] = $null; continue }
- $allowed = $false
- if ($value -is [string] -and $value.Length -le 2048) {
- $allowed = switch ($field) {
- { $_ -in @('id', 'resourceId') } {
- $value.StartsWith("/subscriptions/$($Context.Subscription)/resourceGroups/", [StringComparison]::OrdinalIgnoreCase) -and
- $value -match '^/[A-Za-z0-9/_.%()-]+$'
- }
- 'status' { $value -in @('Accepted', 'Created', 'Waiting', 'InProgress', 'Running', 'Succeeded', 'Failed', 'Cancelled', 'Canceled', 'TimedOut') }
- 'type' { $value -in @('Microsoft.Web/serverfarms/silos', 'Microsoft.Web/sites/silos') }
- 'location' { $value -in @($Context.PrimaryRegion, $Context.SiloRegion) }
- }
- }
- $metadata[$field] = $(if ($allowed) { $value } else { '[omitted]' })
- }
- $state = Get-AbValue (Get-AbValue $Payload 'properties') 'provisioningState'
- if ($state -in @('Accepted', 'Creating', 'Updating', 'Deleting', 'InProgress', 'Succeeded', 'Failed', 'Degraded')) {
- $metadata.provisioningState = $state
- }
- return $metadata
-}
-
-function Invoke-AbAz {
- param(
- [hashtable]$Context, [string[]]$Arguments,
- [ValidateSet('Json', 'Empty', 'Text')][string]$OutputShape = 'Json',
- [int[]]$ExpectedExitCodes = @(0), [int]$TimeoutSeconds = 0,
- [switch]$Mutation, [ValidateSet('Observe', 'Create', 'Idempotent', 'NoReplay', 'Delete')][string]$Replay = 'Observe',
- [string]$IntentId, [string[]]$ApiKeys = @(), [switch]$AllowFailure
- )
- if ($Context.Mode -eq 'Plan') { Stop-Ab 'Plan mode must not invoke Azure.' Invalid }
- if ($Mutation -and $Context.Mode -eq 'Preflight') { Stop-Ab 'Preflight cannot mutate Azure.' Invalid }
- if ($Context.Mode -eq 'Preflight') {
- $allowed = @('account show', 'cloud show', 'provider show', 'appservice list-locations', 'webapp list-runtimes', 'rest')
- if (-not @($allowed | Where-Object { ($Arguments -join ' ').StartsWith($_ + ' ') -or ($Arguments -join ' ') -eq $_ }).Count) {
- Stop-Ab 'An undeclared command was attempted during read-only preflight.' Invalid
- }
- }
- $argsList = [Collections.Generic.List[string]]::new()
- foreach ($argument in $Arguments) { $argsList.Add($argument) }
- if ($argsList.Contains('--debug')) { Stop-Ab 'Debug body logging is not permitted in the harness.' Invalid }
- if (@($Arguments | Where-Object { $_.StartsWith('--ids=') -or $_.StartsWith('--subscription=') }).Count) {
- Stop-Ab 'Use separate identity flags and values so every target can be validated.' Invalid
- }
- foreach ($identityFlag in @('--subscription', '--ids')) {
- if (@($Arguments | Where-Object { $_ -eq $identityFlag }).Count -gt 1) { Stop-Ab 'Duplicate identity flags are not permitted.' Invalid }
- }
- if ($argsList.Count -ge 2 -and $argsList[0] -eq 'cloud' -and $argsList[1] -eq 'show') {
- if ($argsList.Contains('--subscription') -or $argsList.Contains('--ids')) {
- Stop-Ab 'Local cloud metadata does not accept subscription or resource ID arguments.' Invalid
- }
- }
- elseif ($argsList.Contains('--ids')) {
- foreach ($flag in @('--subscription', '-g', '--resource-group', '-n', '--name', '--silo')) {
- if ($argsList.Contains($flag)) { Stop-Ab 'Do not combine resource IDs with named subscription/resource arguments.' Invalid }
- }
- $idIndex = $argsList.IndexOf('--ids') + 1
- if ($idIndex -ge $argsList.Count) { Stop-Ab '--ids requires a resource ID.' Invalid }
- $ids = @()
- while ($idIndex -lt $argsList.Count -and -not $argsList[$idIndex].StartsWith('-')) {
- $ids += $argsList[$idIndex]
- $idIndex++
- }
- if (-not $ids.Count) { Stop-Ab '--ids requires at least one resource ID.' Invalid }
- foreach ($id in $ids) { Assert-AbResourceId $Context $id '' }
- }
- else {
- $index = $argsList.IndexOf('--subscription')
- if ($index -ge 0) {
- if ($index + 1 -ge $argsList.Count -or $argsList[$index + 1] -ne $Context.Subscription) {
- Stop-Ab 'Command subscription differs from the selected run.' Invalid
- }
- }
- else { $argsList.Add('--subscription'); $argsList.Add($Context.Subscription) }
- }
- if ($argsList.Count -ge 1 -and $argsList[0] -eq 'rest') {
- $urlIndex = $argsList.IndexOf('--url')
- $methodIndex = $argsList.IndexOf('--method')
- if ($urlIndex -lt 0 -or $urlIndex + 1 -ge $argsList.Count -or $argsList[$urlIndex + 1].StartsWith('-') -or
- $methodIndex -lt 0 -or $methodIndex + 1 -ge $argsList.Count -or $argsList[$methodIndex + 1] -ne 'get') {
- Stop-Ab 'Reference REST requests must be explicitly scoped GETs.' Invalid
- }
- $address = [uri]::new([uri]$Context.ArmEndpoint, [string]$argsList[$urlIndex + 1])
- if ($address.GetLeftPart('Authority') -ne ([uri]$Context.ArmEndpoint).GetLeftPart('Authority')) { Stop-Ab 'Reference REST origin mismatch.' Invalid }
- $subscriptionLocations = $address.AbsolutePath -ieq "/subscriptions/$($Context.Subscription)/locations" -and
- $address.Query -eq '?api-version=2022-12-01'
- if ($Context.Mode -eq 'Preflight' -and -not $subscriptionLocations) {
- Stop-Ab 'Preflight REST is restricted to the exact selected-subscription locations endpoint.' Invalid
- }
- if (-not $subscriptionLocations) { Assert-AbResourceId $Context $address.AbsolutePath '' }
- }
- if (-not $argsList.Contains('--output') -and -not $argsList.Contains('-o')) {
- $argsList.Add('--output'); $argsList.Add($(if ($OutputShape -eq 'Text') { 'tsv' } else { 'json' }))
- }
- $remaining = $(if ($Context.InCleanup) {
- ($Context.CleanupDeadline - [DateTime]::UtcNow).TotalSeconds
- } else { ($Context.RunDeadline - [DateTime]::UtcNow).TotalSeconds })
- if ($remaining -le 0) { Stop-Ab 'The run/cleanup deadline was reached; no additional request was sent.' $(if ($Context.InCleanup) { 'Cleanup' } else { 'Fail' }) }
- if ($TimeoutSeconds -le 0) { $TimeoutSeconds = $Context.Manifest.options.operationTimeoutSeconds + 90 }
- $TimeoutSeconds = [int][Math]::Max(1, [Math]::Min($remaining, $TimeoutSeconds))
- $command = @(Get-AbCommandName $Context $Arguments)
- $commandLabel = $(if ($command.Count) { $command[0] } else {
- $words = @()
- foreach ($argument in $Arguments) {
- if ($argument.StartsWith('-')) { break }
- $words += $argument
- }
- $words -join ' '
- })
- $timedMutations = @(
- 'appservice plan silo create', 'appservice plan silo update', 'appservice plan silo delete',
- 'webapp silo create', 'webapp silo update', 'webapp silo delete',
- 'webapp silo start', 'webapp silo stop', 'webapp silo restart', 'webapp silo network-trace start'
- )
- if ($Mutation -and $command.Count -and $command[0] -in $timedMutations -and -not $argsList.Contains('--timeout')) {
- $argsList.Add('--timeout')
- $argsList.Add([string][int][Math]::Max(1, [Math]::Min($Context.Manifest.options.operationTimeoutSeconds, $remaining)))
- }
- if ($Mutation) {
- if (-not $IntentId) { Stop-Ab 'Every mutation requires an exact recorded resource intent.' Invalid }
- Assert-AbResourceId $Context $IntentId ''
- if (-not @($Context.Manifest.suites | Where-Object { $IntentId -in @($_.intents) }).Count) {
- Stop-Ab 'Mutation was refused because its resource intent was not journaled.' Invalid
- }
- $Context.Manifest.pendingMutation = @{
- case = $(if ($Context.CurrentCase) { $Context.CurrentCase.id } else { 'cleanup' })
- command = $commandLabel
- resourceId = $IntentId; replay = $Replay; utc = [DateTime]::UtcNow.ToString('o')
- }
- Save-AbManifest $Context
- }
- if ($Context.Executor) { $raw = & $Context.Executor $argsList.ToArray() }
- else {
- $raw = Invoke-AbProcess $Context.PythonExe (@('-m', 'azure.cli') + $argsList.ToArray()) $Context.RepoRoot $Context.Environment $TimeoutSeconds
- }
- $serviceCode = ''
- $requestId = ''
- if ($raw.stderr -match '(?m)^\s*ERROR:\s*\(([A-Za-z0-9_.-]{1,80})\)' -and -not $Matches[1].StartsWith('eyJ')) { $serviceCode = $Matches[1] }
- if ($raw.stderr -match '(?i)Request ID:\s*([a-f0-9-]{36})') { $requestId = $Matches[1] }
- $result = @{
- exitCode = [int]$raw.exitCode; elapsedMs = [long]$raw.elapsedMs; timedOut = [bool]$raw.timedOut
- stdout = [string]$raw.stdout; stderr = [string]$raw.stderr; json = $null
- serviceCode = $serviceCode; requestId = $requestId; command = $commandLabel
- originCollectionFallback = $raw.stderr -match 'Origin point GET returned HTTP 404; using the matching origin from the silo collection'
- }
- if ($Context.CurrentCase) {
- $Context.CurrentCase.calls += @(@{ command = $result.command; apiKeys = $ApiKeys; exitCode = $result.exitCode
- mutationReplay = $(if ($Mutation) { $Replay } else { 'Observe' }); originCollectionFallback = $result.originCollectionFallback })
- }
- Add-AbEvent $Context 'command' @{
- command = $result.command; exitCode = $result.exitCode; elapsedMs = $result.elapsedMs
- serviceCode = $serviceCode; requestId = $requestId; timedOut = $result.timedOut
- originCollectionFallback = $result.originCollectionFallback
- }
- if ($result.exitCode -notin $ExpectedExitCodes) {
- if ($AllowFailure) { return $result }
- $environmentCodes = @('AuthorizationFailed', 'MissingSubscriptionRegistration', 'SubscriptionIsDisabled',
- 'QuotaExceeded', 'SkuNotAvailable', 'NotEnoughAvailableWorkers',
- 'MaxActiveBackupSitesInSubscription', 'RequestedFeaturesNotSupportedInThisRegion')
- $flavorBlocked = $commandLabel -eq 'appservice plan silo create' -and
- $result.stderr -match "The base App Service plan app flavor '(Windows|Linux)' is not supported for Active Backup silos"
- $message = $(if ($flavorBlocked) { 'The service flavor allowlist blocked Active Backup creation. No OS/SKU/region fallback was used.' }
- else { "CLI command failed (exit $($result.exitCode); service code '$serviceCode'). Raw output was not persisted." })
- $failure = [InvalidOperationException]::new($message)
- $failure.Data['AbKind'] = $(if ($serviceCode -in $environmentCodes -or $flavorBlocked) { 'BlockedEnvironment' } else { 'Fail' })
- $failure.Data['AbSafeMessage'] = $failure.Message
- $failure.Data['AbTransientRead'] = -not $Mutation -and $serviceCode -in @('TooManyRequests', 'Throttled', 'ServiceUnavailable', 'InternalServerError')
- throw $failure
- }
- if ($OutputShape -eq 'Empty') { Assert-Ab ([string]::IsNullOrWhiteSpace($result.stdout)) 'Expected an empty successful CLI result.' }
- elseif ($OutputShape -eq 'Json' -and $result.exitCode -eq 0) {
- if (-not [string]::IsNullOrWhiteSpace($result.stdout)) {
- try { $result.json = ConvertFrom-Json -InputObject $result.stdout -AsHashtable -NoEnumerate } catch { Stop-Ab 'The CLI did not return the expected JSON.' }
- }
- if ($Mutation -and $commandLabel -in @(
- 'appservice plan silo create', 'appservice plan silo update', 'webapp silo create', 'webapp silo update')) {
- Add-AbEvent $Context 'silo-response' @{
- command = $commandLabel; targetResourceId = $IntentId
- response = Get-AbSiloResponseMetadata $Context $result.json
- }
- }
- }
- if ($Mutation -and -not $result.timedOut -and $result.exitCode -eq 0) {
- $Context.Manifest.pendingMutation = $null
- Save-AbManifest $Context
- }
- return $result
-}
-
-function Invoke-AbCase {
- param([hashtable]$Context, [string]$Id, [scriptblock]$Action, [bool]$Required = $true,
- [ValidateSet('Behavioral', 'StructuralOnly', 'NegativeOnly')][string]$EvidenceKind = 'Behavioral')
- $previous = @($Context.Manifest.cases | Where-Object id -eq $Id | Select-Object -Last 1)
- if ($Context.Mode -eq 'Resume' -and $previous.Count -and $previous[0].status -eq 'Pass' -and -not $Id.StartsWith('preflight.')) { return }
- $record = @{
- id = $Id; required = $Required; status = 'Running'; evidenceKind = $EvidenceKind
- startedUtc = [DateTime]::UtcNow.ToString('o'); elapsedMs = 0; reason = ''; calls = @()
- }
- $Context.Manifest.cases += @($record)
- $Context.CurrentCase = $record
- Save-AbManifest $Context
- $clock = [Diagnostics.Stopwatch]::StartNew()
- try {
- & $Action | Out-Null
- $record.status = 'Pass'
- }
- catch {
- $record.status = $(if ($_.Exception.Data.Contains('AbKind')) { $_.Exception.Data['AbKind'] } else { 'Fail' })
- $record.reason = $(if ($_.Exception.Data.Contains('AbSafeMessage')) { $_.Exception.Data['AbSafeMessage'] } else { 'Unexpected local exception; raw output was withheld.' })
- if ($Required) { throw }
- }
- finally {
- $record.elapsedMs = $clock.ElapsedMilliseconds
- $Context.CurrentCase = $null
- Save-AbManifest $Context
- Write-Host ("{0}: {1}" -f $Id, $record.status)
- }
-}
-
-function Wait-AbCondition {
- param([hashtable]$Context, [scriptblock]$Probe, [string]$Description, [int]$TimeoutSeconds = 0, [int]$IntervalSeconds = 10)
- if ($TimeoutSeconds -le 0) { $TimeoutSeconds = $Context.Manifest.options.observationTimeoutSeconds }
- $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
- while ([DateTime]::UtcNow -lt $deadline) {
- try { if (& $Probe) { return } }
- catch { if (-not $_.Exception.Data['AbTransientRead']) { throw } }
- $outer = $(if ($Context.InCleanup) { $Context.CleanupDeadline } else { $Context.RunDeadline })
- if ([DateTime]::UtcNow -ge $outer) { break }
- Start-Sleep -Milliseconds ([int][Math]::Max(1, [Math]::Min($IntervalSeconds * 1000, ($deadline - [DateTime]::UtcNow).TotalMilliseconds)))
- }
- Stop-Ab ("Observation timed out: " + $Description) $(if ($Context.InCleanup) { 'Cleanup' } else { 'Fail' })
-}
-
-function Invoke-AbTargetCheck {
- param([hashtable]$Context)
- Invoke-AbCase $Context 'preflight.account' {
- $accountRead = Invoke-AbAz $Context @('account', 'show') -TimeoutSeconds 60 -AllowFailure
- if ($accountRead.exitCode -ne 0) { Stop-Ab 'The isolated CLI cache is not authenticated for the requested subscription. Follow the README login instructions, then repeat Preflight.' BlockedEnvironment }
- $account = $accountRead.json
- Assert-Ab ((Get-AbValue $account 'id') -eq $Context.Subscription) 'Account response selected the wrong subscription.'
- if ((Get-AbValue $account 'state') -ne 'Enabled') { Stop-Ab 'The requested subscription is not enabled.' BlockedEnvironment }
- $tenant = Get-AbValue $account 'tenantId'
- $recordedTenant = Get-AbValue $Context.Manifest 'tenantId'
- if ($recordedTenant -and $recordedTenant -ne $tenant) { Stop-Ab 'The subscription tenant differs from the recorded run; review the target before recovery.' BlockedEnvironment }
- $Context.Manifest.tenantId = $tenant
- $cloud = (Invoke-AbAz $Context @('cloud', 'show') -TimeoutSeconds 60).json
- if ((Get-AbValue $cloud 'name') -ne 'AzureCloud') { Stop-Ab 'The EUAP profile requires the public Azure cloud.' BlockedEnvironment }
- $Context.ArmEndpoint = [string](Get-AbValue (Get-AbValue $cloud 'endpoints') 'resourceManager')
- if ([string]::IsNullOrWhiteSpace($Context.ArmEndpoint)) { Stop-Ab 'Cloud metadata omitted the ARM endpoint.' BlockedEnvironment }
- $Context.Manifest.armEndpoint = $Context.ArmEndpoint
- }
-}
-
-function Invoke-AbPreflight {
- param([hashtable]$Context)
- Invoke-AbTargetCheck $Context
- Invoke-AbCase $Context 'preflight.regions-api' {
- $locationsUrl = "/subscriptions/$($Context.Subscription)/locations?api-version=2022-12-01"
- $locations = @(Get-AbValue (Invoke-AbAz $Context @('rest', '--method', 'get', '--url', $locationsUrl) -TimeoutSeconds 60).json 'value' @())
- foreach ($region in @($Context.PrimaryRegion, $Context.SiloRegion)) {
- if (-not @($locations | Where-Object { (Get-AbValue $_ 'name') -eq $region }).Count) { Stop-Ab 'The subscription does not expose a requested region.' BlockedEnvironment }
- }
- $provider = (Invoke-AbAz $Context @('provider', 'show', '--namespace', 'Microsoft.Web') -TimeoutSeconds 90).json
- if ((Get-AbValue $provider 'registrationState') -ne 'Registered') { Stop-Ab 'Microsoft.Web is not registered. The harness will not register it.' BlockedEnvironment }
- foreach ($type in @('serverFarms/silos', 'sites/silos', 'sites/managedTrafficConfig', 'sites/lastKnownGood')) {
- $types = Get-AbValue $provider 'resourceTypes' @()
- $entry = @($types | Where-Object { (Get-AbValue $_ 'resourceType') -ieq $type })
- if (-not $entry.Count -or $Context.ApiVersion -notin @(Get-AbValue $entry[0] 'apiVersions' @())) {
- Stop-Ab ("Public ARM metadata does not advertise the reviewed API for " + $type + '. No fallback was attempted.') BlockedEnvironment
- }
- }
- if ($Context.Profile -in @('Core', 'Extended')) {
- $network = (Invoke-AbAz $Context @('provider', 'show', '--namespace', 'Microsoft.Network') -TimeoutSeconds 60).json
- if ((Get-AbValue $network 'registrationState') -ne 'Registered') { Stop-Ab 'Core networking requires Microsoft.Network registration. The harness will not register it.' BlockedEnvironment }
- }
- }
- Invoke-AbCase $Context 'preflight.sku-runtime' {
- $skus = $(if ($Context.Manifest.options.includeSkuMatrix) { @('P1V3', 'P1MV3', 'P1V4', 'P1MV4') } else { @('P1V3') })
- foreach ($sku in $skus) {
- $regions = (Invoke-AbAz $Context @('appservice', 'list-locations', '--sku', $sku) -TimeoutSeconds 90).json
- $normalized = @($regions | ForEach-Object { ([string](Get-AbValue $_ 'name')).Replace(' ', '').ToLowerInvariant() })
- foreach ($region in @($Context.PrimaryRegion, $Context.SiloRegion)) {
- if ($region -notin $normalized) { Stop-Ab 'SKU discovery does not list both requested regions. Allocation was not attempted.' BlockedEnvironment }
- }
- }
- if ($Context.OperatingSystem -in @('Linux', 'Both')) {
- $runtimes = (Invoke-AbAz $Context @('webapp', 'list-runtimes', '--os-type', 'linux') -TimeoutSeconds 90).json
- if ('PYTHON:3.12' -notin @($runtimes)) { Stop-Ab 'The required PYTHON:3.12 fixture runtime is not advertised.' BlockedEnvironment }
- }
- }
-}
-
-function New-AbSuite {
- param([hashtable]$Context, [string]$OperatingSystem, [string]$Sku = 'P1V3')
- $short = $(if ($OperatingSystem -eq 'Windows') { 'win' } else { 'linux' })
- $key = $short + '-' + $Sku.ToLowerInvariant()
- $stem = 'abcli-' + $Context.Manifest.runId + '-' + $key
- $group = 'rg-' + $stem
- $groupId = '/subscriptions/' + $Context.Subscription + '/resourceGroups/' + $group
- $plan = $stem + '-plan'
- $app = $stem + '-app'
- return @{
- key = $key; operatingSystem = $OperatingSystem; sku = $Sku; group = $group; groupId = $groupId
- plan = $plan; app = $app; planSilo = 'secondary-plan'; siteSilo = 'secondary-app'
- planId = $groupId + '/providers/Microsoft.Web/serverfarms/' + $plan
- appId = $groupId + '/providers/Microsoft.Web/sites/' + $app
- planSiloId = $groupId + '/providers/Microsoft.Web/serverfarms/' + $plan + '/silos/secondary-plan'
- siteSiloId = $groupId + '/providers/Microsoft.Web/sites/' + $app + '/silos/secondary-app'
- vnet = $stem + '-vnet'; groupCreateAttempted = $false; groupOwned = $false
- intents = @(); receipts = @(); hosts = @(); cleanupComplete = $false
- }
-}
-
-function Register-AbIntent {
- param([hashtable]$Context, [hashtable]$Suite, [string]$ResourceId)
- Assert-AbResourceId $Context $ResourceId $Suite.group
- if ($ResourceId -notin @($Suite.intents)) { $Suite.intents += @($ResourceId) }
- Save-AbManifest $Context
-}
-
-function Get-AbTags {
- param([hashtable]$Context)
- return @(
- 'abcliRunId=' + $Context.Manifest.runId
- 'abcliOwner=' + $Context.Manifest.owner
- 'abcliCreatedUtc=' + $Context.Manifest.createdUtc
- 'abcliExpiresUtc=' + ([DateTime]::Parse($Context.Manifest.createdUtc).ToUniversalTime().AddMinutes(
- $Context.Manifest.options.runTimeoutMinutes + $Context.Manifest.options.cleanupTimeoutMinutes + 60).ToString('o'))
- 'abcliPurpose=local-cli-validation'
- 'abcliCommit=' + $Context.Manifest.sourceCommit
- )
-}
-
-function Assert-AbOwnedGroup {
- param([hashtable]$Context, [hashtable]$Suite)
- if (-not $Suite.groupCreateAttempted) { Stop-Ab 'The manifest has no creation intent for this group.' Cleanup }
- $result = Invoke-AbAz $Context @('group', 'show', '--name', $Suite.group) -AllowFailure -TimeoutSeconds 60
- if ($result.exitCode -eq 3) { return $false }
- if ($result.exitCode -ne 0) { Stop-Ab 'Cannot verify the resource-group ownership before cleanup.' Cleanup }
- $tags = Get-AbValue $result.json 'tags' @{}
- if ((Get-AbValue $result.json 'id') -ine $Suite.groupId -or
- (Get-AbValue $tags 'abcliRunId') -ne $Context.Manifest.runId -or
- (Get-AbValue $tags 'abcliPurpose') -ne 'local-cli-validation' -or
- (Get-AbValue $tags 'abcliOwner') -ne $Context.Manifest.owner -or
- (Get-AbValue $tags 'abcliCommit') -ne $Context.Manifest.sourceCommit) {
- Stop-Ab 'Resource-group identity or ownership tags do not match the recorded run.' Cleanup
- }
- return $true
-}
-
-function Read-AbManifest {
- param([string]$RepoRoot, [string]$ManifestPath, [string]$Subscription, [string]$Mode = 'Cleanup')
- $path = Assert-AbLocalPath $RepoRoot $ManifestPath -RequireIgnored
- if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { Stop-Ab 'The manifest file does not exist.' Invalid }
- try {
- $stream = [IO.File]::Open($path, 'Open', 'Read', ([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete))
- $buffer = [IO.MemoryStream]::new()
- try {
- $stream.CopyTo($buffer)
- $manifestBytes = $buffer.ToArray()
- }
- finally {
- $stream.Dispose()
- $buffer.Dispose()
- }
- $manifestText = [Text.Encoding]::UTF8.GetString($manifestBytes).TrimStart([char]0xfeff)
- $manifest = ConvertFrom-Json -InputObject $manifestText -AsHashtable
- }
- catch { Stop-Ab 'The manifest could not be read as valid JSON.' Invalid }
- if ($manifest.schemaVersion -ne 1 -or $manifest.purpose -ne 'active-backup-cli-validation' -or
- $manifest.subscription -ne $Subscription -or
- $manifest.repoRoot -ine [IO.Path]::GetFullPath($RepoRoot) -or
- $manifest.owner -ne [Environment]::UserName -or
- [IO.Path]::GetFileName($path) -ne 'manifest.json') {
- Stop-Ab 'Manifest schema, repository or subscription does not match the request.' Invalid
- }
- $options = $manifest.options
- $context = New-AbContext -RepoRoot $RepoRoot -Subscription $Subscription -PrimaryRegion $manifest.primaryRegion `
- -SiloRegion $manifest.siloRegion -PythonExe $manifest.pythonExe -Profile $manifest.profile `
- -OperatingSystem $manifest.operatingSystem -OutputRoot ([IO.Path]::GetDirectoryName($manifest.runDirectory)) `
- -AuthConfigDir $manifest.authConfigDir -RunId $manifest.runId -Mode $Mode `
- -OperationTimeoutSeconds $options.operationTimeoutSeconds -ObservationTimeoutSeconds $options.observationTimeoutSeconds `
- -RunTimeoutMinutes $options.runTimeoutMinutes -CleanupTimeoutMinutes $options.cleanupTimeoutMinutes `
- -MaxWorkersPerPlan $options.maxWorkersPerPlan -IncludeSkuMatrix $options.includeSkuMatrix `
- -IncludeInvasiveDiagnostics $options.includeInvasiveDiagnostics -IncludeZoneRedundancy $options.includeZoneRedundancy
- if ($context.ManifestPath -ine $path -or $manifest.runDirectory -ine $context.RunDirectory) {
- Stop-Ab 'Manifest path does not match the recorded run directory.' Invalid
- }
- if ($manifest.sourceCommit -ne $context.Manifest.sourceCommit -and $Mode -eq 'Resume') {
- Stop-Ab 'Resume requires the original source commit. Use ownership-checked Cleanup after a source change.' Invalid
- }
- if ($Mode -eq 'Resume') {
- $savedHashes = Get-AbValue $manifest 'sourceHashes' @{}
- foreach ($relative in $context.Manifest.sourceHashes.Keys) {
- if ((Get-AbValue $savedHashes $relative) -ne $context.Manifest.sourceHashes[$relative]) {
- Stop-Ab 'Resume refused changed local CLI or harness source files, including uncommitted edits.' Invalid
- }
- }
- }
- $seen = @()
- foreach ($suite in $manifest.suites) {
- if ($suite.operatingSystem -notin @('Windows', 'Linux') -or $suite.sku -notin @('P1V3', 'P1MV3', 'P1V4', 'P1MV4')) {
- Stop-Ab 'Manifest contains an unrecognized OS or SKU.' Invalid
- }
- $expected = New-AbSuite $context $suite.operatingSystem $suite.sku
- foreach ($field in @('key', 'group', 'groupId', 'plan', 'app', 'planId', 'appId', 'planSiloId', 'siteSiloId', 'planSilo', 'siteSilo', 'vnet')) {
- if ($suite[$field] -cne $expected[$field]) { Stop-Ab 'Manifest resource names or IDs have been changed.' Invalid }
- }
- if ($suite.key -in $seen) { Stop-Ab 'Manifest contains duplicate suites.' Invalid }
- $seen += $suite.key
- foreach ($intent in $suite.intents) {
- Assert-AbResourceId $context $intent $suite.group
- $allowed = @($suite.groupId, $suite.planId, $suite.appId, $suite.planSiloId, $suite.siteSiloId,
- ($suite.groupId + '/providers/Microsoft.Network/virtualNetworks/' + $suite.vnet))
- if ($intent -notin $allowed) { Stop-Ab 'Manifest contains an undeclared resource intent.' Invalid }
- }
- }
- $context.Manifest = $manifest
- $context.ManifestReadHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($manifestBytes))
- $context.ArmEndpoint = [string](Get-AbValue $manifest 'armEndpoint' 'https://management.azure.com/')
- if ($context.ArmEndpoint -ne 'https://management.azure.com/') { Stop-Ab 'The saved ARM origin is not the reviewed public cloud.' Invalid }
- return $context
-}
-
-function Complete-AbSiloOperation {
- param([hashtable]$Context, [hashtable]$Suite, [string]$Family, [string]$ResourceId, $Result,
- [string]$OperationApiKey, [switch]$Delete)
- $payload = $Result.json
- if (-not $Delete) {
- Assert-Ab ($payload -is [Collections.IDictionary]) 'The silo mutation returned no resource object or operation receipt.'
- }
- if ((Get-AbValue $payload 'status') -eq 'Accepted') {
- $operationId = [string](Get-AbValue $payload 'operationId')
- Assert-Ab (-not [string]::IsNullOrWhiteSpace($operationId)) 'The no-wait receipt omitted the operation ID.'
- Assert-Ab ((Get-AbValue $payload 'resourceId') -ieq $ResourceId) 'The no-wait receipt identifies the wrong resource.'
- $Suite.receipts += @(@{ resourceId = $ResourceId; operationId = $operationId })
- Save-AbManifest $Context
- $show = (Invoke-AbAz $Context ($Family.Split(' ') + @('operation', 'show', '--ids', $ResourceId, '--operation-id', $operationId)) -ApiKeys @($OperationApiKey)).json
- Assert-Ab ((Get-AbValue $show 'status') -in @('InProgress', 'Succeeded')) 'Operation inspection returned an invalid state.'
- $timeout = $Context.Manifest.options.operationTimeoutSeconds
- if ($Context.InCleanup) { $timeout = [int][Math]::Max(1, [Math]::Min($timeout, ($Context.CleanupDeadline - [DateTime]::UtcNow).TotalSeconds)) }
- $wait = (Invoke-AbAz $Context ($Family.Split(' ') + @('wait', '--ids', $ResourceId, '--operation-id', $operationId, '--timeout', [string]$timeout)) -ApiKeys @($OperationApiKey)).json
- Assert-Ab ((Get-AbValue $wait 'status') -eq 'Succeeded') 'Operation wait did not reach success.'
- }
- elseif (-not $Delete) { Assert-AbSilo $payload $ResourceId $Context.SiloRegion }
-}
-
-function Assert-AbSilo {
- param($Resource, [string]$Id, [string]$Location)
- Assert-Ab ((Get-AbValue $Resource 'id') -ieq $Id) 'Silo response identifies the wrong ARM resource.'
- $type = $(if ($Id -match '/serverfarms/') { 'Microsoft.Web/serverfarms/silos' } else { 'Microsoft.Web/sites/silos' })
- Assert-Ab ((Get-AbValue $Resource 'type') -ieq $type) 'Silo response has the wrong ARM resource type.'
- Assert-Ab (([string](Get-AbValue $Resource 'location')).Replace(' ', '').ToLowerInvariant() -eq $Location) 'Silo response has the wrong target region.'
- $properties = Get-AbValue $Resource 'properties'
- Assert-Ab ($properties -is [Collections.IDictionary]) 'Silo response omitted its properties object.'
- Assert-Ab ((Get-AbValue $properties 'provisioningState') -eq 'Succeeded') 'Silo API operation did not produce a Succeeded resource.'
-}
-
-function Invoke-AbReferenceGet {
- param([hashtable]$Context, [string]$Id)
- $url = $Id + '?api-version=' + $Context.ApiVersion
- return (Invoke-AbAz $Context @('rest', '--method', 'get', '--url', $url) -TimeoutSeconds 90).json
-}
-
-function Get-AbMarker {
- param([hashtable]$Context, [hashtable]$Suite, [string]$HostName)
- if ($HostName -notin @($Suite.hosts) -or $HostName -notmatch '^[a-zA-Z0-9.-]+$') { Stop-Ab 'HTTP probe hostname is not a verified owned fixture hostname.' Invalid }
- if ($Context.HttpExecutor) { return & $Context.HttpExecutor $Suite $HostName }
- $remaining = ($Context.RunDeadline - [DateTime]::UtcNow).TotalSeconds
- if ($remaining -le 0) { Stop-Ab 'Run deadline reached before the HTTP observation.' }
- try {
- return Invoke-RestMethod -Uri ('https://' + $HostName + '/version.json') -Method Get `
- -TimeoutSec ([int][Math]::Max(1, [Math]::Min(30, $remaining))) -MaximumRedirection 0 -ErrorAction Stop
- }
- catch [System.Net.Http.HttpRequestException] { return $null }
- catch [System.Threading.Tasks.TaskCanceledException] { return $null }
-}
-
-function Add-AbHost {
- param([hashtable]$Context, [hashtable]$Suite, $Resource)
- $properties = Get-AbValue $Resource 'properties' $Resource
- foreach ($key in @('defaultHostName', 'globalHostName')) {
- $hostName = [string](Get-AbValue $properties $key (Get-AbValue $Resource $key ''))
- if ($hostName) {
- if ($hostName -notmatch '^[a-zA-Z0-9.-]+$') { Stop-Ab 'Service returned an invalid fixture hostname.' }
- if ($hostName -notin @($Suite.hosts)) { $Suite.hosts += @($hostName) }
- }
- }
- Save-AbManifest $Context
-}
-
-function Wait-AbMarker {
- param([hashtable]$Context, [hashtable]$Suite, [string]$HostName, [string]$Generation)
- Wait-AbCondition $Context {
- $marker = Get-AbMarker $Context $Suite $HostName
- return (Get-AbValue $marker 'runId') -eq $Context.Manifest.runId -and (Get-AbValue $marker 'generation') -eq $Generation
- } ("fixture content " + $Generation)
-}
-
-function New-AbFixture {
- param([hashtable]$Context, [hashtable]$Suite, [string]$Generation)
- $directory = Join-Path $Context.RunDirectory ('fixtures\' + $Suite.key + '-' + $Generation)
- [IO.Directory]::CreateDirectory($directory) | Out-Null
- Write-AbJson (Join-Path $directory 'version.json') @{
- runId = $Context.Manifest.runId; generation = $Generation; fixture = 'abcli'
- }
- [IO.File]::WriteAllText((Join-Path $directory 'health.txt'), 'healthy', [Text.UTF8Encoding]::new($false))
- [IO.File]::WriteAllText((Join-Path $directory 'index.html'), 'Active Backup CLI validation fixture', [Text.UTF8Encoding]::new($false))
- if ($Suite.operatingSystem -eq 'Windows') {
- $config = ''
- [IO.File]::WriteAllText((Join-Path $directory 'web.config'), $config, [Text.UTF8Encoding]::new($false))
- }
- $zip = $directory + '.zip'
- if (Test-Path -LiteralPath $zip) { [IO.File]::Delete($zip) }
- [IO.Compression.ZipFile]::CreateFromDirectory($directory, $zip)
- return $zip
-}
-
-function Get-AbProps {
- param($Object)
- return ,(Get-AbValue $Object 'properties' $Object)
-}
-
-function Get-AbItems {
- param($Object)
- $items = Get-AbValue $Object 'value' $Object
- return $items
-}
-
-function Assert-AbContainer {
- param($Object)
- Assert-Ab ($Object -is [Collections.IDictionary] -or $Object -is [array]) 'Expected a JSON object or collection from the service.'
-}
-
-function Invoke-AbDeploy {
- param([hashtable]$Context, [hashtable]$Suite, [string]$Generation)
- $zip = New-AbFixture $Context $Suite $Generation
- Invoke-AbAz $Context @('webapp', 'config', 'appsettings', 'set', '-g', $Suite.group, '-n', $Suite.app,
- '--settings', ('ABCLI_RUN_ID=' + $Context.Manifest.runId), ('ABCLI_GENERATION=' + $Generation)) `
- -Mutation -Replay Idempotent -IntentId $Suite.appId | Out-Null
- Invoke-AbAz $Context @('webapp', 'deploy', '-g', $Suite.group, '-n', $Suite.app, '--src-path', $zip, '--type', 'zip') `
- -Mutation -Replay NoReplay -IntentId $Suite.appId | Out-Null
- Wait-AbMarker $Context $Suite $Suite.primaryHost $Generation
-}
-
-function Invoke-AbSmoke {
- param([hashtable]$Context, [hashtable]$Suite, [switch]$ProvisionOnly)
- $prefix = $Suite.key
- Invoke-AbCase $Context "$prefix.primary" {
- $exists = Invoke-AbAz $Context @('group', 'exists', '-n', $Suite.group) -TimeoutSeconds 60
- if ($exists.json -eq $true) {
- if ($Context.Mode -ne 'Resume' -or -not $Suite.groupCreateAttempted) { Stop-Ab 'Resource-group collision: existing resources will not be adopted.' Invalid }
- Assert-Ab (Assert-AbOwnedGroup $Context $Suite) 'Cannot reconcile the owned group.'
- }
- else {
- $Suite.groupCreateAttempted = $true
- Register-AbIntent $Context $Suite $Suite.groupId
- Invoke-AbAz $Context (@('group', 'create', '-n', $Suite.group, '--location', $Context.PrimaryRegion, '--tags') + (Get-AbTags $Context)) `
- -Mutation -Replay Create -IntentId $Suite.groupId | Out-Null
- }
- $Suite.groupOwned = Assert-AbOwnedGroup $Context $Suite
- $linux = $(if ($Suite.operatingSystem -eq 'Linux') { 'true' } else { 'false' })
- $createPlan = $true
- if ($Context.Mode -eq 'Resume') {
- $existingPlan = Invoke-AbAz $Context @('appservice', 'plan', 'show', '-g', $Suite.group, '-n', $Suite.plan) -AllowFailure
- if ($existingPlan.exitCode -eq 0) {
- Assert-Ab ($Suite.planId -in @($Suite.intents) -and (Get-AbValue $existingPlan.json 'id') -ieq $Suite.planId) 'Existing primary plan cannot be reconciled to a recorded create intent.'
- $createPlan = $false
- }
- elseif ($existingPlan.exitCode -ne 3) { Stop-Ab 'Cannot reconcile the primary plan before resuming.' }
- }
- if ($createPlan) {
- Register-AbIntent $Context $Suite $Suite.planId
- Invoke-AbAz $Context (@('appservice', 'plan', 'create', '-g', $Suite.group, '-n', $Suite.plan,
- '--location', $Context.PrimaryRegion, '--sku', $Suite.sku, '--number-of-workers', '1', '--is-linux', $linux, '--tags') + (Get-AbTags $Context)) `
- -Mutation -Replay Create -IntentId $Suite.planId | Out-Null
- }
- $arguments = @('webapp', 'create', '-g', $Suite.group, '-n', $Suite.app, '--plan', $Suite.planId)
- if ($Suite.operatingSystem -eq 'Linux') {
- $arguments += @('--runtime', 'PYTHON:3.12', '--startup-file', 'python -m http.server 8000 --bind 0.0.0.0 --directory /home/site/wwwroot')
- }
- $createApp = $true
- if ($Context.Mode -eq 'Resume') {
- $existingApp = Invoke-AbAz $Context @('webapp', 'show', '-g', $Suite.group, '-n', $Suite.app) -AllowFailure
- if ($existingApp.exitCode -eq 0) {
- Assert-Ab ($Suite.appId -in @($Suite.intents) -and (Get-AbValue $existingApp.json 'id') -ieq $Suite.appId) 'Existing primary app cannot be reconciled to a recorded create intent.'
- $primary = $existingApp.json
- $createApp = $false
- }
- elseif ($existingApp.exitCode -ne 3) { Stop-Ab 'Cannot reconcile the primary app before resuming.' }
- }
- if ($createApp) {
- Register-AbIntent $Context $Suite $Suite.appId
- $primary = (Invoke-AbAz $Context $arguments -Mutation -Replay Create -IntentId $Suite.appId).json
- }
- Add-AbHost $Context $Suite $primary
- $Suite.primaryHost = [string](Get-AbValue (Get-AbProps $primary) 'defaultHostName' (Get-AbValue $primary 'defaultHostName'))
- Assert-Ab (-not [string]::IsNullOrWhiteSpace($Suite.primaryHost)) 'Primary app omitted its hostname.'
- Save-AbManifest $Context
- }
- if (-not $ProvisionOnly) {
- Invoke-AbCase $Context "$prefix.deploy-v1" { Invoke-AbDeploy $Context $Suite 'v1' }
- }
- Invoke-AbCase $Context "$prefix.plan-create" {
- if ($Context.Mode -eq 'Resume') {
- $existing = Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'show', '--ids', $Suite.planSiloId) -AllowFailure -ApiKeys @('C02')
- if ($existing.exitCode -eq 0) {
- Assert-Ab ($Suite.planSiloId -in @($Suite.intents)) 'An existing plan silo has no previous run intent; adoption was refused.'
- Wait-AbCondition $Context {
- $observed = (Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'show', '--ids', $Suite.planSiloId) -ApiKeys @('C02')).json
- return (Get-AbValue (Get-AbProps $observed) 'provisioningState') -eq 'Succeeded'
- } 'reconciled plan silo readiness'
- return
- }
- if ($existing.exitCode -ne 3) { Stop-Ab 'Cannot reconcile the previous plan-silo create.' }
- }
- Register-AbIntent $Context $Suite $Suite.planSiloId
- $created = Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'create', '-g', $Suite.group, '-n', $Suite.plan,
- '--silo', $Suite.planSilo, '--location', $Context.SiloRegion, '--primary-location', $Context.PrimaryRegion,
- '--number-of-workers', '1', '--no-wait') -Mutation -Replay Create -IntentId $Suite.planSiloId -ApiKeys @('C03')
- Complete-AbSiloOperation $Context $Suite 'appservice plan silo' $Suite.planSiloId $created 'C06'
- }
- Invoke-AbCase $Context "$prefix.plan-read" {
- $plan = (Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'show', '-g', $Suite.group, '-n', $Suite.plan, '--silo', $Suite.planSilo) -ApiKeys @('C02')).json
- Assert-AbSilo $plan $Suite.planSiloId $Context.SiloRegion
- Assert-Ab (([string](Get-AbValue (Get-AbProps $plan) 'primaryLocation')).Replace(' ', '').ToLowerInvariant() -eq $Context.PrimaryRegion) 'Plan silo points at the wrong primary region.'
- Wait-AbCondition $Context {
- $items = (Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'list', '-g', $Suite.group, '-n', $Suite.plan) -ApiKeys @('C01')).json
- $count = @($items | Where-Object { (Get-AbValue $_ 'id') -ieq $Suite.planSiloId }).Count
- Assert-Ab ($count -le 1) 'Plan list contains duplicate silo identities.'
- return $count -eq 1
- } 'created plan silo appears in its parent collection'
- Assert-AbSilo (Invoke-AbReferenceGet $Context $Suite.planSiloId) $Suite.planSiloId $Context.SiloRegion
- }
- Invoke-AbCase $Context "$prefix.site-create" {
- if ($Context.Mode -eq 'Resume') {
- $existing = Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $Suite.siteSiloId) -AllowFailure -ApiKeys @('C09')
- if ($existing.exitCode -eq 0) {
- Assert-Ab ($Suite.siteSiloId -in @($Suite.intents)) 'An existing site silo has no previous run intent; adoption was refused.'
- Wait-AbCondition $Context {
- $observed = (Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $Suite.siteSiloId) -ApiKeys @('C09')).json
- return (Get-AbValue (Get-AbProps $observed) 'provisioningState') -eq 'Succeeded'
- } 'reconciled site silo readiness'
- return
- }
- if ($existing.exitCode -ne 3) { Stop-Ab 'Cannot reconcile the previous site-silo create.' }
- }
- Register-AbIntent $Context $Suite $Suite.siteSiloId
- $created = Invoke-AbAz $Context @('webapp', 'silo', 'create', '-g', $Suite.group, '-n', $Suite.app, '--silo', $Suite.siteSilo,
- '--location', $Context.SiloRegion, '--primary-location', $Context.PrimaryRegion, '--traffic-mode', 'Active',
- '--follow-last-known-good', 'false', '--no-wait') -Mutation -Replay Create -IntentId $Suite.siteSiloId -ApiKeys @('C10')
- Complete-AbSiloOperation $Context $Suite 'webapp silo' $Suite.siteSiloId $created 'C13'
- }
- if ($ProvisionOnly) { return }
- Invoke-AbCase $Context "$prefix.site-read" {
- $site = (Invoke-AbAz $Context @('webapp', 'silo', 'show', '-g', $Suite.group, '-n', $Suite.app, '--silo', $Suite.siteSilo) -ApiKeys @('C09')).json
- Assert-AbSilo $site $Suite.siteSiloId $Context.SiloRegion
- Add-AbHost $Context $Suite $site
- $Suite.siloHost = [string](Get-AbValue (Get-AbProps $site) 'defaultHostName')
- Assert-Ab (-not [string]::IsNullOrWhiteSpace($Suite.siloHost)) 'The site silo omitted its hostname.'
- Assert-Ab ($Suite.siloHost -ine $Suite.primaryHost) 'The silo API returned the primary hostname instead of a distinct replica hostname.'
- Assert-Ab (([string](Get-AbValue (Get-AbProps $site) 'primaryLocation')).Replace(' ', '').ToLowerInvariant() -eq $Context.PrimaryRegion) 'Site silo points at the wrong primary region.'
- Wait-AbCondition $Context {
- $items = (Invoke-AbAz $Context @('webapp', 'silo', 'list', '-g', $Suite.group, '-n', $Suite.app) -ApiKeys @('C08')).json
- $children = @($items | Where-Object { (Get-AbValue $_ 'id') -ieq $Suite.siteSiloId }).Count
- $origins = @($items | Where-Object { (Get-AbValue $_ 'name') -eq 'origin' }).Count
- Assert-Ab ($children -le 1 -and $origins -le 1) 'Site list contains duplicate child/origin identities.'
- return $children -eq 1 -and $origins -eq 1
- } 'site silo and origin appear in the parent collection'
- Assert-AbSilo (Invoke-AbReferenceGet $Context $Suite.siteSiloId) $Suite.siteSiloId $Context.SiloRegion
- Save-AbManifest $Context
- }
- Invoke-AbCase $Context "$prefix.replication-v1" {
- Wait-AbMarker $Context $Suite $Suite.siloHost 'v1'
- Wait-AbCondition $Context {
- $settings = Get-AbProps (Invoke-AbAz $Context @('webapp', 'silo', 'config', 'appsettings', 'list', '--ids', $Suite.siteSiloId) -ApiKeys @('P50')).json
- return (Get-AbValue $settings 'ABCLI_RUN_ID') -eq $Context.Manifest.runId -and (Get-AbValue $settings 'ABCLI_GENERATION') -eq 'v1'
- } 'replicated dummy app settings'
- }
- Invoke-AbCase $Context "$prefix.refresh-acceptance" {
- $refresh = Invoke-AbAz $Context @('webapp', 'silo', 'refresh', '-g', $Suite.group, '-n', $Suite.app) `
- -OutputShape Empty -Mutation -Replay NoReplay -IntentId $Suite.appId -ApiKeys @('C14')
- Assert-Ab ($refresh.stderr -match 'Refresh request accepted') 'Refresh did not emit its acceptance notice.'
- }
-}
-
-function Invoke-AbOriginVisibility {
- param([hashtable]$Context, [hashtable]$Suite)
- Invoke-AbCase $Context ($Suite.key + '.origin-visibility') {
- $started = [DateTime]::UtcNow
- $originId = $Suite.appId + '/silos/origin'
- $observations = @{
- startedUtc = $started.ToString('o'); windowSeconds = $Context.Manifest.options.observationTimeoutSeconds
- firstVisibleUtc = $null; firstVisibleAfterSeconds = $null; completedUtc = $null; samples = @()
- }
- $Suite.originVisibility = $observations
- Save-AbManifest $Context
- try {
- Wait-AbCondition $Context {
- if ($observations.samples.Count -and -not $Context.Executor) {
- $lastStart = [DateTime]::Parse($observations.samples[-1].startedUtc).ToUniversalTime()
- if (([DateTime]::UtcNow - $lastStart).TotalSeconds -lt 30) { return $false }
- }
- $sampleStart = [DateTime]::UtcNow
- $listed = (Invoke-AbAz $Context @('webapp', 'silo', 'list', '-g', $Suite.group, '-n', $Suite.app) `
- -TimeoutSeconds 30 -ApiKeys @('C08')).json
- $origins = @($listed | Where-Object { (Get-AbValue $_ 'name') -eq 'origin' })
- Assert-Ab ($origins.Count -le 1) 'Origin listing contains duplicate identities.'
- $point = Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $originId) `
- -TimeoutSeconds 30 -AllowFailure -ApiKeys @('C09')
- $raw = Invoke-AbAz $Context @('rest', '--method', 'get', '--url', ($originId + '?api-version=' + $Context.ApiVersion)) `
- -TimeoutSeconds 30 -AllowFailure
- $secondary = Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $Suite.siteSiloId) `
- -TimeoutSeconds 30 -AllowFailure -ApiKeys @('C09')
- foreach ($read in @($point, $raw, $secondary)) {
- if ($read.exitCode -eq 0) { continue }
- $missing = $(if ($read.command -eq 'rest') {
- $read.serviceCode -in @('ResourceNotFound', 'NotFound') -or
- $read.stderr -match '(?im)^ERROR:\s*Not Found(?:\(|\s|$)'
- } else { $read.exitCode -eq 3 })
- if (-not $missing -or $read.timedOut) {
- Stop-Ab ("Origin observation failed: " + $read.command + "; exit " + $read.exitCode +
- "; service code '" + $read.serviceCode + "'. Raw output was not persisted.")
- }
- }
- foreach ($read in @($point, $raw)) {
- if ($read.exitCode -eq 0) {
- Assert-Ab ((Get-AbValue $read.json 'id') -ieq $originId) 'Origin read returned a different resource identity.'
- }
- }
- if ($secondary.exitCode -eq 0) {
- Assert-Ab ((Get-AbValue $secondary.json 'id') -ieq $Suite.siteSiloId) 'Secondary control read returned a different identity.'
- }
- $now = [DateTime]::UtcNow
- $sample = @{
- startedUtc = $sampleStart.ToString('o'); observedUtc = $now.ToString('o')
- elapsedSeconds = [Math]::Round(($now - $started).TotalSeconds, 1)
- originListed = $origins.Count -eq 1
- cliExitCode = $point.exitCode; cliServiceCode = $point.serviceCode; cliRequestId = $point.requestId
- armExitCode = $raw.exitCode; armServiceCode = $raw.serviceCode; armRequestId = $raw.requestId
- secondaryExitCode = $secondary.exitCode
- cli = Get-AbSiloResponseMetadata $Context $point.json
- arm = Get-AbSiloResponseMetadata $Context $raw.json
- secondary = Get-AbSiloResponseMetadata $Context $secondary.json
- }
- $observations.samples += @($sample)
- Add-AbEvent $Context 'origin-observation' $sample
- $visible = $point.exitCode -eq 0 -and $raw.exitCode -eq 0 -and $secondary.exitCode -eq 0
- if ($visible) {
- $observations.firstVisibleUtc = $now.ToString('o')
- $observations.firstVisibleAfterSeconds = $sample.elapsedSeconds
- }
- Save-AbManifest $Context
- Write-Host ("Origin +{0}s: listed={1}; CLI exit={2}; ARM exit={3}; secondary exit={4}" -f
- $sample.elapsedSeconds, $sample.originListed, $point.exitCode, $raw.exitCode, $secondary.exitCode)
- return $visible
- } 'origin readable through CLI and raw ARM after completed creation' -IntervalSeconds 1
- }
- finally {
- $observations.completedUtc = [DateTime]::UtcNow.ToString('o')
- Save-AbManifest $Context
- }
- }
-}
-
-function Get-AbSiteArguments {
- param([hashtable]$Suite, [string]$Command)
- return $Command.Split(' ') + @('--ids', $Suite.siteSiloId)
-}
-
-function Invoke-AbSafeReads {
- param([hashtable]$Context, [hashtable]$Suite)
- $reads = @(
- @{ command = 'webapp silo config show'; keys = @('P42') }
- @{ command = 'webapp silo config list'; keys = @('P43') }
- @{ command = 'webapp silo config snapshot list'; keys = @('P44') }
- @{ command = 'webapp silo config connection-string show'; keys = @('P52') }
- @{ command = 'webapp silo config storage-account list'; keys = @('P51') }
- @{ command = 'webapp silo config metadata list'; keys = @('P60') }
- @{ command = 'webapp silo auth show'; keys = @('P56') }
- @{ command = 'webapp silo auth show'; keys = @('P55'); args = @('--metadata-only') }
- @{ command = 'webapp silo auth v2 show'; keys = @('P58') }
- @{ command = 'webapp silo auth v2 show'; keys = @('P57'); args = @('--legacy') }
- @{ command = 'webapp silo log config show'; keys = @('P61') }
- @{ command = 'webapp silo deployment list'; keys = @('P02') }
- @{ command = 'webapp silo deployment status list'; keys = @('P82') }
- @{ command = 'webapp silo config keyvault-reference list'; keys = @('P47') }
- @{ command = 'webapp silo webjob list'; keys = @('P21') }
- @{ command = 'webapp silo webjob continuous list'; keys = @('P23') }
- @{ command = 'webapp silo webjob triggered list'; keys = @('P27') }
- @{ command = 'webapp silo site-extension list'; keys = @('P32') }
- @{ command = 'webapp silo premier-addon list'; keys = @('P62') }
- @{ command = 'webapp silo metrics definition list'; keys = @('P65') }
- )
- if ($Context.Profile -eq 'Extended') {
- $reads += @(
- @{ command = 'webapp silo log php show'; keys = @('P01') }
- @{ command = 'webapp silo log startup list'; keys = @('P16'); args = @('--latest', 'true') }
- @{ command = 'webapp silo log startup show-summary'; keys = @('P17') }
- @{ command = 'webapp silo log list'; keys = @('P19') }
- @{ command = 'webapp silo runtime list-detected'; keys = @('P49') }
- @{ command = 'webapp silo config push-settings show'; keys = @('P59') }
- @{ command = 'webapp silo antivirus config show'; keys = @('P77') }
- @{ command = 'webapp silo antivirus log list'; keys = @('P79') }
- @{ command = 'webapp silo sitecontainers list'; keys = @('P83') }
- )
- }
- foreach ($read in $reads) {
- $definition = $read
- $id = $Suite.key + '.read.' + $read.keys[0]
- Invoke-AbCase $Context $id {
- $extra = Get-AbValue $definition 'args' @()
- $result = Invoke-AbAz $Context ((Get-AbSiteArguments $Suite $definition.command) + @($extra)) -ApiKeys $definition.keys
- Assert-AbContainer $result.json
- } -Required $false -EvidenceKind StructuralOnly
- }
- Invoke-AbCase $Context ($Suite.key + '.instances') {
- $instances = (Invoke-AbAz $Context (Get-AbSiteArguments $Suite 'webapp silo instance list') -ApiKeys @('P40')).json
- $items = @(Get-AbItems $instances)
- if (-not $items.Count) { Stop-Ab 'The service returned no instance identifier for a point-read fixture.' BlockedFixture }
- $instance = [string](Get-AbValue $items[0] 'name' (Get-AbValue $items[0] 'id'))
- $instance = ($instance -split '/')[-1]
- if (-not $instance) { Stop-Ab 'The instance collection omitted a usable identifier.' BlockedFixture }
- $result = Invoke-AbAz $Context ((Get-AbSiteArguments $Suite 'webapp silo instance show') + @('--instance', $instance)) -ApiKeys @('P41')
- Assert-AbContainer $result.json
- $Suite.instance = $instance
- Save-AbManifest $Context
- } -Required $false
- Invoke-AbCase $Context ($Suite.key + '.snapshot-point') {
- $snapshots = (Invoke-AbAz $Context (Get-AbSiteArguments $Suite 'webapp silo config snapshot list') -ApiKeys @('P44')).json
- $items = @(Get-AbItems $snapshots)
- if (-not $items.Count) { Stop-Ab 'No configuration snapshot identifier is available yet.' BlockedFixture }
- $snapshotValue = Get-AbValue (Get-AbProps $items[0]) 'snapshotId'
- if ($null -eq $snapshotValue) { $snapshotValue = Get-AbValue $items[0] 'id' }
- $snapshot = [string]$snapshotValue
- $snapshot = ($snapshot -split '/')[-1]
- if (-not $snapshot) { Stop-Ab 'The snapshot inventory has no usable identifier.' BlockedFixture }
- foreach ($variant in @(@{ args = @(); key = 'P45' }, @{ args = @('--include-secrets'); key = 'P46' })) {
- $result = Invoke-AbAz $Context ((Get-AbSiteArguments $Suite 'webapp silo config snapshot show') +
- @('--snapshot-id', $snapshot) + $variant.args) -ApiKeys @($variant.key)
- Assert-AbContainer $result.json
- }
- } -Required $false
- Invoke-AbCase $Context ($Suite.key + '.deployment-point') {
- $deployments = (Invoke-AbAz $Context (Get-AbSiteArguments $Suite 'webapp silo deployment list') -ApiKeys @('P02')).json
- $items = @(Get-AbItems $deployments)
- if (-not $items.Count) { Stop-Ab 'Silo deployment history has no point-read fixture. Primary history is not substituted.' BlockedFixture }
- $deployment = [string](Get-AbValue $items[0] 'name' (Get-AbValue $items[0] 'id'))
- $deployment = ($deployment -split '/')[-1]
- foreach ($variant in @(@{ command = 'webapp silo deployment show'; key = 'P03' },
- @{ command = 'webapp silo deployment log list'; key = 'P04' })) {
- $result = Invoke-AbAz $Context ((Get-AbSiteArguments $Suite $variant.command) + @('--deployment-id', $deployment)) -ApiKeys @($variant.key)
- Assert-AbContainer $result.json
- }
- } -Required $false
-}
-
-function Invoke-AbNetworking {
- param([hashtable]$Context, [hashtable]$Suite)
- Invoke-AbCase $Context ($Suite.key + '.networking') {
- $vnetId = $Suite.groupId + '/providers/Microsoft.Network/virtualNetworks/' + $Suite.vnet
- Register-AbIntent $Context $Suite $vnetId
- Invoke-AbAz $Context (@('network', 'vnet', 'create', '-g', $Suite.group, '-n', $Suite.vnet,
- '--location', $Context.SiloRegion, '--address-prefixes', '10.237.16.0/24', '--subnet-name', 'integration',
- '--subnet-prefixes', '10.237.16.0/26', '--tags') + (Get-AbTags $Context)) `
- -Mutation -Replay Create -IntentId $vnetId | Out-Null
- Invoke-AbAz $Context @('network', 'vnet', 'subnet', 'update', '-g', $Suite.group, '--vnet-name', $Suite.vnet,
- '-n', 'integration', '--delegations', 'Microsoft.Web/serverFarms') -Mutation -Replay Idempotent -IntentId $vnetId | Out-Null
- $subnetId = $vnetId + '/subnets/integration'
- Invoke-AbAz $Context ((Get-AbSiteArguments $Suite 'webapp silo vnet-integration add') + @('--subnet', $subnetId)) `
- -Mutation -Replay Idempotent -IntentId $Suite.siteSiloId -ApiKeys @('C23') | Out-Null
- try {
- $integration = (Invoke-AbAz $Context (Get-AbSiteArguments $Suite 'webapp silo vnet-integration show') -ApiKeys @('C22')).json
- Assert-Ab ((Get-AbValue (Get-AbProps $integration) 'subnetResourceId') -ieq $subnetId) 'Silo networking read returned the wrong subnet.'
- Invoke-AbAz $Context ((Get-AbSiteArguments $Suite 'webapp silo vnet-integration update') + @('--subnet', $subnetId)) `
- -Mutation -Replay Idempotent -IntentId $Suite.siteSiloId -ApiKeys @('C24') | Out-Null
- $planIntegrations = (Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'vnet-integration', 'list',
- '--ids', $Suite.planSiloId) -ApiKeys @('C07')).json
- Assert-AbContainer $planIntegrations
- Assert-Ab ((ConvertTo-Json -InputObject $planIntegrations -Depth 30) -like ('*' + $subnetId + '*')) 'Plan integration inventory omitted the owned subnet.'
- }
- finally {
- Invoke-AbAz $Context (Get-AbSiteArguments $Suite 'webapp silo vnet-integration remove') `
- -Mutation -Replay Delete -IntentId $Suite.siteSiloId -ApiKeys @('C25') | Out-Null
- }
- $disconnected = Invoke-AbAz $Context (Get-AbSiteArguments $Suite 'webapp silo vnet-integration show') -AllowFailure -ApiKeys @('C22')
- if ($disconnected.exitCode -eq 0) {
- Assert-Ab (-not (Get-AbValue (Get-AbProps $disconnected.json) 'subnetResourceId')) 'Silo integration remained connected after removal.'
- }
- elseif ($disconnected.exitCode -ne 3) { Stop-Ab 'Silo integration absence could not be verified.' }
- }
-}
-
-function Invoke-AbCore {
- param([hashtable]$Context, [hashtable]$Suite)
- $prefix = $Suite.key
- Invoke-AbCase $Context "$prefix.outputs-and-ids" {
- $byId = (Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $Suite.siteSiloId) -ApiKeys @('C09')).json
- Assert-AbSilo $byId $Suite.siteSiloId $Context.SiloRegion
- foreach ($format in @('table', 'tsv')) {
- $output = Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $Suite.siteSiloId, '--output', $format) -OutputShape Text -ApiKeys @('C09')
- Assert-Ab (-not [string]::IsNullOrWhiteSpace($output.stdout)) 'Formatted silo output was empty.'
- }
- $rejected = $false
- try { Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $Suite.siteSiloId, '-n', 'conflicting-name') -AllowFailure | Out-Null }
- catch {
- if ($_.Exception.Data['AbKind'] -ne 'Invalid') { throw }
- $rejected = $true
- }
- Assert-Ab $rejected 'The runner did not reject conflicting ID/named identity arguments.'
- $wrongType = Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $Suite.planSiloId) -AllowFailure
- Assert-Ab ($wrongType.exitCode -ne 0) 'A plan ID was accepted by the site-silo command.'
- }
- Invoke-AbCase $Context "$prefix.plan-updates" {
- $counts = $(if ($Context.Manifest.options.maxWorkersPerPlan -ge 2) { @(1, 2, 1) } else { @(1, 1) })
- foreach ($count in $counts) {
- $updated = (Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'update', '--ids', $Suite.planSiloId,
- '--number-of-workers', [string]$count, '--zone-redundant', 'false') `
- -Mutation -Replay Idempotent -IntentId $Suite.planSiloId -ApiKeys @('C04')).json
- Assert-AbSilo $updated $Suite.planSiloId $Context.SiloRegion
- Assert-Ab ((Get-AbValue (Get-AbProps $updated) 'instanceNumberOverride') -eq $count) 'Worker-count override did not match the request.'
- Assert-Ab ((Get-AbValue (Get-AbProps $updated) 'zoneRedundantOverride') -eq $false) 'Explicit false zone override was not preserved.'
- }
- $replayed = (Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'create', '-g', $Suite.group, '-n', $Suite.plan,
- '--silo', $Suite.planSilo, '--location', $Context.SiloRegion, '--primary-location', $Context.PrimaryRegion,
- '--number-of-workers', '1') -Mutation -Replay Idempotent -IntentId $Suite.planSiloId -ApiKeys @('C03')).json
- Assert-AbSilo $replayed $Suite.planSiloId $Context.SiloRegion
- }
- Invoke-AbCase $Context "$prefix.site-policy" {
- $updated = (Invoke-AbAz $Context @('webapp', 'silo', 'update', '--ids', $Suite.siteSiloId,
- '--follow-last-known-good', 'false', '--endpoint-status', 'Enabled') `
- -Mutation -Replay Idempotent -IntentId $Suite.siteSiloId -ApiKeys @('C11')).json
- Assert-AbSilo $updated $Suite.siteSiloId $Context.SiloRegion
- Assert-Ab ((Get-AbValue (Get-AbProps $updated) 'followLastKnownGood') -eq $false) 'Explicit false revision policy was not preserved.'
- }
- Invoke-AbCase $Context "$prefix.traffic" {
- $current = (Invoke-AbAz $Context @('webapp', 'managed-traffic', 'show', '-g', $Suite.group, '-n', $Suite.app) -ApiKeys @('C19')).json
- Assert-AbContainer $current
- $expanded = (Invoke-AbAz $Context @('webapp', 'managed-traffic', 'list', '-g', $Suite.group, '-n', $Suite.app, '--expand-endpoints') -ApiKeys @('C18')).json
- Assert-Ab (@($expanded).Count -eq 1) 'Managed traffic collection is not a singleton.'
- $endpoints = @(Get-AbValue (Get-AbProps $expanded[0]) 'endpoints' @())
- foreach ($region in @($Context.PrimaryRegion, $Context.SiloRegion)) {
- Assert-Ab (@($endpoints | Where-Object { (Get-AbValue $_ 'region') -eq $region }).Count -eq 1) 'Traffic config omitted a fixture region.'
- }
- $input = Join-Path $Context.RunDirectory ('inputs\' + $prefix + '-traffic.json')
- Write-AbJson $input @(@{ region = $Context.SiloRegion; weight = 2 })
- $active = (Invoke-AbAz $Context @('webapp', 'managed-traffic', 'update', '-g', $Suite.group, '-n', $Suite.app,
- '--traffic-mode', 'Active', '--endpoints', ('@' + $input)) -Mutation -Replay Idempotent -IntentId $Suite.appId -ApiKeys @('C20')).json
- Assert-Ab ((Get-AbValue (Get-AbProps $active) 'trafficMode') -eq 'Active') 'Active traffic mode was not applied.'
- Write-AbJson $input @(@{ region = $Context.PrimaryRegion; priority = 1 }, @{ region = $Context.SiloRegion; priority = 2 })
- $passive = (Invoke-AbAz $Context @('webapp', 'managed-traffic', 'update', '-g', $Suite.group, '-n', $Suite.app,
- '--traffic-mode', 'Passive', '--endpoints', ('@' + $input)) -Mutation -Replay Idempotent -IntentId $Suite.appId -ApiKeys @('C20')).json
- Assert-Ab ((Get-AbValue (Get-AbProps $passive) 'trafficMode') -eq 'Passive') 'Passive traffic mode was not applied.'
- $final = (Invoke-AbAz $Context @('webapp', 'managed-traffic', 'list', '-g', $Suite.group, '-n', $Suite.app, '--expand-endpoints') -ApiKeys @('C18')).json
- $values = @(Get-AbValue (Get-AbProps $final[0]) 'endpoints' @())
- foreach ($entry in @(@{ region = $Context.PrimaryRegion; priority = 1 }, @{ region = $Context.SiloRegion; priority = 2 })) {
- $match = @($values | Where-Object { (Get-AbValue $_ 'region') -eq $entry.region })
- Assert-Ab ($match.Count -eq 1 -and (Get-AbValue $match[0] 'priority') -eq $entry.priority) 'A requested endpoint priority was not applied.'
- }
- $secondary = @($values | Where-Object { (Get-AbValue $_ 'region') -eq $Context.SiloRegion })[0]
- Assert-Ab ((Get-AbValue $secondary 'weight') -eq 2) 'Mode-only/partial traffic updates lost the explicit weight.'
- }
- Invoke-AbCase $Context "$prefix.origin-control" {
- Wait-AbMarker $Context $Suite $Suite.siloHost 'v1'
- $originId = $Suite.appId + '/silos/origin'
- $before = (Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $originId) -ApiKeys @('C09')).json
- $status = [string](Get-AbValue (Get-AbProps $before) 'endpointStatus')
- Assert-Ab ($status -in @('Enabled', 'Disabled')) 'Origin endpoint state is not recognized.'
- try {
- Invoke-AbAz $Context @('webapp', 'silo', 'update', '--ids', $originId, '--endpoint-status', 'Disabled') `
- -Mutation -Replay Idempotent -IntentId $Suite.appId -ApiKeys @('C11') | Out-Null
- $disabled = (Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $originId) -ApiKeys @('C09')).json
- Assert-Ab ((Get-AbValue (Get-AbProps $disabled) 'endpointStatus') -eq 'Disabled') 'Origin endpoint was not disabled.'
- Wait-AbMarker $Context $Suite $Suite.siloHost 'v1'
- }
- finally {
- Invoke-AbAz $Context @('webapp', 'silo', 'update', '--ids', $originId, '--endpoint-status', $status) `
- -Mutation -Replay Idempotent -IntentId $Suite.appId -ApiKeys @('C11') | Out-Null
- }
- }
- Invoke-AbCase $Context "$prefix.dummy-connection-string" {
- $dummy = 'abcli-dummy-' + $Context.Manifest.runId
- Invoke-AbAz $Context @('webapp', 'config', 'connection-string', 'set', '--ids', $Suite.appId,
- '--connection-string-type', 'Custom', '--settings', ('ABCLI_DUMMY=' + $dummy)) `
- -Mutation -Replay Idempotent -IntentId $Suite.appId | Out-Null
- Wait-AbCondition $Context {
- $bag = Get-AbProps (Invoke-AbAz $Context (Get-AbSiteArguments $Suite 'webapp silo config connection-string list') -ApiKeys @('P53')).json
- return (Get-AbValue (Get-AbValue $bag 'ABCLI_DUMMY' @{}) 'value') -eq $dummy
- } 'replicated dummy connection-string marker'
- $point = (Invoke-AbAz $Context ((Get-AbSiteArguments $Suite 'webapp silo config connection-string show') +
- @('--key', 'ABCLI_DUMMY')) -ApiKeys @('P54')).json
- Assert-Ab ((Get-AbValue (Get-AbProps $point) 'value') -eq $dummy) 'Connection-string point read did not return the dummy fixture value.'
- }
- Invoke-AbSafeReads $Context $Suite
- Invoke-AbNetworking $Context $Suite
- Invoke-AbCase $Context "$prefix.latest-replication" {
- Invoke-AbDeploy $Context $Suite 'v2'
- Wait-AbMarker $Context $Suite $Suite.siloHost 'v2'
- Wait-AbCondition $Context {
- $settings = Get-AbProps (Invoke-AbAz $Context (Get-AbSiteArguments $Suite 'webapp silo config appsettings list') -ApiKeys @('P50')).json
- return (Get-AbValue $settings 'ABCLI_GENERATION') -eq 'v2'
- } 'latest config marker v2'
- }
- Invoke-AbCase $Context "$prefix.lkg-baseline" {
- $lkg = (Invoke-AbAz $Context @('webapp', 'last-known-good', 'mark', '-g', $Suite.group, '-n', $Suite.app) `
- -Mutation -Replay NoReplay -IntentId $Suite.appId -ApiKeys @('C17')).json
- $Suite.lkgRevision = Get-AbValue (Get-AbProps $lkg) 'revisionNumber'
- Assert-Ab ($null -ne $Suite.lkgRevision) 'LKG mark did not return an actual ready revision.'
- $read = (Invoke-AbAz $Context @('webapp', 'last-known-good', 'show', '-g', $Suite.group, '-n', $Suite.app) -ApiKeys @('C16')).json
- Assert-Ab ((Get-AbValue (Get-AbProps $read) 'revisionNumber') -eq $Suite.lkgRevision) 'LKG point read differs from the marked revision.'
- $list = (Invoke-AbAz $Context @('webapp', 'last-known-good', 'list', '-g', $Suite.group, '-n', $Suite.app) -ApiKeys @('C15')).json
- Assert-Ab (@($list).Count -eq 1) 'LKG list is not a singleton.'
- Invoke-AbAz $Context @('webapp', 'silo', 'update', '--ids', $Suite.siteSiloId, '--follow-last-known-good', 'true') `
- -Mutation -Replay Idempotent -IntentId $Suite.siteSiloId -ApiKeys @('C11') | Out-Null
- Save-AbManifest $Context
- }
- Invoke-AbCase $Context "$prefix.lkg-pin" {
- Invoke-AbDeploy $Context $Suite 'v3'
- $accepted = Invoke-AbAz $Context @('webapp', 'silo', 'refresh', '-g', $Suite.group, '-n', $Suite.app) `
- -Mutation -Replay NoReplay -IntentId $Suite.appId -OutputShape Empty -ApiKeys @('C14')
- Assert-Ab ($accepted.stderr -match 'Refresh request accepted') 'Refresh acceptance notice was not present.'
- for ($sample = 0; $sample -lt 3; $sample++) {
- $marker = Get-AbMarker $Context $Suite $Suite.siloHost
- Assert-Ab ((Get-AbValue $marker 'runId') -eq $Context.Manifest.runId -and (Get-AbValue $marker 'generation') -eq 'v2') 'LKG-following silo did not retain the verified baseline marker.'
- if (-not $Context.Executor) { Start-Sleep -Seconds 10 }
- }
- }
- Invoke-AbCase $Context "$prefix.lkg-advance" {
- $deadline = [DateTime]::UtcNow.AddSeconds($Context.Manifest.options.observationTimeoutSeconds)
- $newRevision = $null
- do {
- $marked = (Invoke-AbAz $Context @('webapp', 'last-known-good', 'mark', '-g', $Suite.group, '-n', $Suite.app) `
- -Mutation -Replay NoReplay -IntentId $Suite.appId -ApiKeys @('C17')).json
- $newRevision = Get-AbValue (Get-AbProps $marked) 'revisionNumber'
- if ($null -ne $newRevision -and [long]$newRevision -gt [long]$Suite.lkgRevision) { break }
- if (-not $Context.Executor) { Start-Sleep -Seconds 15 }
- } while ([DateTime]::UtcNow -lt $deadline)
- Assert-Ab ($null -ne $newRevision -and [long]$newRevision -gt [long]$Suite.lkgRevision) 'No new ready revision was returned by a later explicit LKG mark.'
- Wait-AbMarker $Context $Suite $Suite.siloHost 'v3'
- Wait-AbCondition $Context {
- $site = (Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $Suite.siteSiloId) -ApiKeys @('C09')).json
- return (Get-AbValue (Get-AbProps $site) 'revisionNumber') -eq $newRevision
- } 'silo reaches the actual newly marked revision'
- $Suite.lkgRevision = $newRevision
- Save-AbManifest $Context
- }
- Invoke-AbCase $Context "$prefix.runtime" {
- foreach ($entry in @(@{ verb = 'stop'; key = 'P69' }, @{ verb = 'start'; key = 'P68' }, @{ verb = 'restart'; key = 'P67' })) {
- $arguments = @('webapp', 'silo', $entry.verb, '--ids', $Suite.siteSiloId)
- if ($entry.verb -eq 'restart') { $arguments += @('--soft-restart', 'false', '--synchronous', 'true') }
- Invoke-AbAz $Context $arguments -Mutation -Replay Idempotent -IntentId $Suite.siteSiloId -ApiKeys @($entry.key) | Out-Null
- }
- Wait-AbMarker $Context $Suite $Suite.siloHost 'v3'
- }
- Invoke-AbCase $Context "$prefix.metrics" {
- $start = [DateTime]::UtcNow.AddHours(-1).ToString('yyyy-MM-ddTHH:mm:ssZ')
- $end = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
- $traffic = (Invoke-AbAz $Context @('webapp', 'managed-traffic', 'metrics', 'list', '-g', $Suite.group, '-n', $Suite.app,
- '--start-time', $start, '--end-time', $end, '--time-grain', 'PT1M') -ApiKeys @('C21')).json
- Assert-AbContainer $traffic
- $metrics = (Invoke-AbAz $Context ((Get-AbSiteArguments $Suite 'webapp silo metrics list') +
- @('--start-time', $start, '--end-time', $end, '--time-grain', 'PT1H')) -ApiKeys @('P64')).json
- Assert-AbContainer $metrics
- } -Required $false -EvidenceKind StructuralOnly
- Invoke-AbNegatives $Context $Suite
- if ($Context.Profile -eq 'Extended') { Invoke-AbExtended $Context $Suite }
-}
-
-function Invoke-AbNegatives {
- param([hashtable]$Context, [hashtable]$Suite)
- Invoke-AbCase $Context ($Suite.key + '.not-found') {
- $missing = $Suite.appId + '/silos/missing-' + $Context.Manifest.runId.Substring(15)
- $result = Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $missing) -ExpectedExitCodes @(3)
- Assert-Ab ($result.exitCode -eq 3) 'A nonexistent silo did not return the show not-found exit code.'
- } -EvidenceKind NegativeOnly
- Invoke-AbCase $Context ($Suite.key + '.invalid-workers') {
- foreach ($count in @('0', '31')) {
- $result = Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'update', '--ids', $Suite.planSiloId,
- '--number-of-workers', $count) -AllowFailure -Mutation -Replay Idempotent -IntentId $Suite.planSiloId
- Assert-Ab ($result.exitCode -eq 1 -and $result.stderr -match 'number-of-workers must be between 1 and 30') 'Worker boundary validation did not produce the specific expected error.'
- $Context.Manifest.pendingMutation = $null
- Save-AbManifest $Context
- }
- $unchanged = (Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'show', '--ids', $Suite.planSiloId) -ApiKeys @('C02')).json
- Assert-Ab ((Get-AbValue (Get-AbProps $unchanged) 'instanceNumberOverride') -eq 1) 'Rejected worker counts changed the plan override.'
- } -EvidenceKind NegativeOnly
- Invoke-AbCase $Context ($Suite.key + '.origin-delete-rejected') {
- $result = Invoke-AbAz $Context @('webapp', 'silo', 'delete', '--ids', ($Suite.appId + '/silos/origin'), '--yes') `
- -Mutation -Replay Delete -IntentId $Suite.appId -AllowFailure -ApiKeys @('C12')
- Assert-Ab ($result.exitCode -eq 1 -and $result.stderr -match '(?i)origin.+cannot be deleted') 'Origin delete did not produce the specific expected rejection.'
- $Context.Manifest.pendingMutation = $null
- Save-AbManifest $Context
- } -EvidenceKind NegativeOnly
- Invoke-AbCase $Context ($Suite.key + '.dependency-delete-rejected') {
- $result = Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'delete', '--ids', $Suite.planSiloId, '--yes') `
- -Mutation -Replay Delete -IntentId $Suite.planSiloId -AllowFailure -ApiKeys @('C05')
- Assert-Ab ($result.exitCode -eq 1 -and $result.stderr -match '(?i)(site silos|site silo).+(associated|first)|associated.+site silos') 'Plan dependency delete did not produce the specific expected conflict.'
- $Context.Manifest.pendingMutation = $null
- Save-AbManifest $Context
- Assert-AbSilo (Invoke-AbAz $Context @('webapp', 'silo', 'show', '--ids', $Suite.siteSiloId) -ApiKeys @('C09')).json $Suite.siteSiloId $Context.SiloRegion
- } -EvidenceKind NegativeOnly
-}
-
-function Invoke-AbExtended {
- param([hashtable]$Context, [hashtable]$Suite)
- foreach ($test in @(
- @{ name = 'dns'; key = 'P35'; body = @{ properties = @{ dnsName = $Suite.primaryHost } } },
- @{ name = 'tcp-ping'; key = 'P36'; body = @{ properties = @{ host = $Suite.primaryHost; port = '443' } } })) {
- $definition = $test
- Invoke-AbCase $Context ($Suite.key + '.network-check.' + $test.name) {
- $input = Join-Path $Context.RunDirectory ('inputs\' + $Suite.key + '-' + $definition.name + '.json')
- Write-AbJson $input $definition.body
- $result = Invoke-AbAz $Context ((Get-AbSiteArguments $Suite ('webapp silo network-check ' + $definition.name)) +
- @('--body', ('@' + $input))) -ApiKeys @($definition.key)
- Assert-AbContainer $result.json
- } -Required $false -EvidenceKind StructuralOnly
- }
- Invoke-AbCase $Context ($Suite.key + '.keyvault-reference-refresh') {
- Invoke-AbAz $Context ((Get-AbSiteArguments $Suite 'webapp silo config keyvault-reference refresh') +
- @('--force-restart', 'false')) -Mutation -Replay NoReplay -IntentId $Suite.siteSiloId -ApiKeys @('P48') | Out-Null
- } -Required $false -EvidenceKind StructuralOnly
- if ($Context.Manifest.options.includeZoneRedundancy) {
- Invoke-AbCase $Context ($Suite.key + '.zone-redundancy') {
- if ($Context.Manifest.options.maxWorkersPerPlan -lt 2) { Stop-Ab 'Zone testing requires authorization for two workers.' BlockedFixture }
- try {
- $updated = (Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'update', '--ids', $Suite.planSiloId,
- '--number-of-workers', '2', '--zone-redundant', 'true') -Mutation -Replay Idempotent -IntentId $Suite.planSiloId -ApiKeys @('C04')).json
- Assert-Ab ((Get-AbValue (Get-AbProps $updated) 'zoneRedundantOverride') -eq $true) 'Zone override was not enabled.'
- }
- finally {
- Invoke-AbAz $Context @('appservice', 'plan', 'silo', 'update', '--ids', $Suite.planSiloId,
- '--zone-redundant', 'false', '--number-of-workers', '1') -Mutation -Replay Idempotent -IntentId $Suite.planSiloId -ApiKeys @('C04') | Out-Null
- }
- }
- }
- if ($Context.Manifest.options.includeInvasiveDiagnostics) {
- Invoke-AbCase $Context ($Suite.key + '.invasive-fixtures') {
- Stop-Ab 'No reviewed disposable process/container/trace-storage fixture is configured. Invasive actions were not sent.' BlockedFixture
- } -Required $false
- }
-}
-
-function Remove-AbSilo {
- param([hashtable]$Context, [hashtable]$Suite, [string]$Family, [string]$Id, [string]$DeleteKey, [string]$OperationKey)
- $current = Invoke-AbAz $Context ($Family.Split(' ') + @('show', '--ids', $Id)) -AllowFailure -TimeoutSeconds 60
- if ($current.exitCode -eq 3) { return }
- if ($current.exitCode -ne 0) { Stop-Ab 'Cannot verify a recorded silo before deletion; the group was retained.' Cleanup }
- Assert-Ab ((Get-AbValue $current.json 'id') -ieq $Id) 'A cleanup read returned the wrong silo identity.'
- $deleted = Invoke-AbAz $Context ($Family.Split(' ') + @('delete', '--ids', $Id, '--yes', '--no-wait')) `
- -Mutation -Replay Delete -IntentId $Id -ApiKeys @($DeleteKey)
- Complete-AbSiloOperation $Context $Suite $Family $Id $deleted $OperationKey -Delete
- Wait-AbCondition $Context {
- $read = Invoke-AbAz $Context ($Family.Split(' ') + @('show', '--ids', $Id)) -AllowFailure -TimeoutSeconds 60
- if ($read.exitCode -notin @(0, 3)) { Stop-Ab 'Cannot verify silo absence after deletion.' Cleanup }
- return $read.exitCode -eq 3
- } 'owned silo deletion' -TimeoutSeconds ($Context.Manifest.options.cleanupTimeoutMinutes * 60)
- $again = Invoke-AbAz $Context ($Family.Split(' ') + @('delete', '--ids', $Id, '--yes', '--no-wait')) `
- -Mutation -Replay Delete -IntentId $Id -ApiKeys @($DeleteKey)
- Complete-AbSiloOperation $Context $Suite $Family $Id $again $OperationKey -Delete
-}
-
-function Remove-AbOwnedResources {
- param([hashtable]$Context, [hashtable]$Suite)
- $previous = $Context.InCleanup
- $Context.InCleanup = $true
- if ($null -eq $Context.CleanupDeadline -or -not $previous) {
- $Context.CleanupDeadline = [DateTime]::UtcNow.AddMinutes($Context.Manifest.options.cleanupTimeoutMinutes)
- }
- $targets = $(if ($null -ne $Suite) { @($Suite) } else { @($Context.Manifest.suites) })
- $Context.Manifest.cleanup.status = 'Running'
- Save-AbManifest $Context
- try {
- foreach ($item in $targets) {
- if ($item.cleanupComplete -or -not $item.groupCreateAttempted) { continue }
- Invoke-AbCase $Context ($item.key + '.cleanup') {
- if (-not (Assert-AbOwnedGroup $Context $item)) {
- $item.cleanupComplete = $true
- return
- }
- if ($item.siteSiloId -in @($item.intents)) {
- Remove-AbSilo $Context $item 'webapp silo' $item.siteSiloId 'C12' 'C13'
- }
- if ($item.planSiloId -in @($item.intents)) {
- Remove-AbSilo $Context $item 'appservice plan silo' $item.planSiloId 'C05' 'C06'
- }
- foreach ($parent in @(@{ family = 'webapp silo'; name = $item.app; id = $item.appId; child = $item.siteSiloId },
- @{ family = 'appservice plan silo'; name = $item.plan; id = $item.planId; child = $item.planSiloId })) {
- if ($parent.child -notin @($item.intents)) { continue }
- $listed = Invoke-AbAz $Context ($parent.family.Split(' ') + @('list', '-g', $item.group, '-n', $parent.name)) -AllowFailure
- if ($listed.exitCode -eq 3) { continue }
- if ($listed.exitCode -ne 0) { Stop-Ab 'Cannot inspect the remaining silo dependencies; group deletion was refused.' Cleanup }
- $remaining = @($listed.json | Where-Object { (Get-AbValue $_ 'name') -ne 'origin' })
- if ($remaining.Count) { Stop-Ab 'Silo dependencies remain; group deletion was refused.' Cleanup }
- }
- $resources = (Invoke-AbAz $Context @('resource', 'list', '-g', $item.group) -TimeoutSeconds 90).json
- foreach ($resource in $resources) {
- $id = [string](Get-AbValue $resource 'id')
- if ($id -notin @($item.intents)) { Stop-Ab 'The group contains a resource not recorded by this run; deletion was refused.' Cleanup }
- }
- if (-not (Assert-AbOwnedGroup $Context $item)) { $item.cleanupComplete = $true; return }
- Invoke-AbAz $Context @('group', 'delete', '--name', $item.group, '--yes', '--no-wait') `
- -OutputShape Empty -Mutation -Replay Delete -IntentId $item.groupId | Out-Null
- Wait-AbCondition $Context {
- $exists = (Invoke-AbAz $Context @('group', 'exists', '-n', $item.group) -TimeoutSeconds 60).json
- return $exists -eq $false
- } 'owned resource-group deletion' -TimeoutSeconds ($Context.Manifest.options.cleanupTimeoutMinutes * 60)
- $item.cleanupComplete = $true
- Save-AbManifest $Context
- }
- }
- $Context.Manifest.cleanup.remaining = @($Context.Manifest.suites | Where-Object { $_.groupCreateAttempted -and -not $_.cleanupComplete } | ForEach-Object { $_.groupId })
- $Context.Manifest.cleanup.status = $(if ($Context.Manifest.cleanup.remaining.Count) { 'Incomplete' } else { 'Complete' })
- $Context.Manifest.pendingMutation = $null
- Save-AbManifest $Context
- }
- catch {
- $Context.Manifest.cleanup.status = 'Failed'
- $Context.Manifest.cleanup.remaining = @($Context.Manifest.suites | Where-Object { $_.groupCreateAttempted -and -not $_.cleanupComplete } | ForEach-Object { $_.groupId })
- Save-AbManifest $Context
- Stop-Ab 'Cleanup did not complete. Inspect the manifest and remaining resource IDs; no unsafe group fallback was used.' Cleanup -InnerException $_.Exception
- }
- finally { $Context.InCleanup = $previous }
-}
-
-function Get-AbUnavailableReason {
- param([string]$Command)
- if ($Command -match 'process|container restart|extension invoke|network-trace|antivirus scan|antivirus config update') {
- return 'Requires a reviewed disposable process/container/extension or trace fixture and diagnostic authorization.'
- }
- if ($Command -match 'webjob.+(show|start|stop|run|history)|premier-addon show|site-extension show|sitecontainers show') {
- return 'Requires a real owned fixture identifier; an empty list does not establish point/action coverage.'
- }
- if ($Command -match 'log (tail|container download|startup show)|deployment (status show|log show)|runtime-operation') {
- return 'Requires returned service log/deployment/operation identifiers and a bounded file/stream fixture.'
- }
- return 'Not selected or no successful semantic assertion was completed for this command.'
-}
-
-function Get-AbLatestCases {
- param([hashtable]$Context)
- return @($Context.Manifest.cases | Group-Object id | ForEach-Object { $_.Group[-1] })
-}
-
-function Get-AbCoverage {
- param([hashtable]$Context)
- $commands = @()
- foreach ($entry in $Context.Catalog.commands) {
- $hits = @()
- foreach ($case in @(Get-AbLatestCases $Context)) {
- foreach ($call in $case.calls) {
- if ($call.command -eq $entry.command) { $hits += @(@{ case = $case; call = $call }) }
- }
- }
- $positive = @($hits | Where-Object { $_.case.status -eq 'Pass' -and $_.call.exitCode -eq 0 -and $_.case.evidenceKind -eq 'Behavioral' })
- $structural = @($hits | Where-Object { $_.case.status -eq 'Pass' -and $_.case.evidenceKind -eq 'StructuralOnly' })
- $negative = @($hits | Where-Object { $_.case.status -eq 'Pass' -and $_.case.evidenceKind -eq 'NegativeOnly' })
- $status = $(if ($positive.Count) { 'Pass' } elseif ($structural.Count) { 'StructuralOnly' } elseif ($negative.Count) { 'NegativeOnly' } elseif ($hits.Count) { 'Incomplete' } else { 'NotRun' })
- $commands += @{ command = $entry.command; status = $status; caseIds = @($hits | ForEach-Object { $_.case.id } | Select-Object -Unique)
- reason = $(if ($status -eq 'NotRun') { Get-AbUnavailableReason $entry.command } else { '' }) }
- }
- $variants = @()
- foreach ($variant in $Context.Catalog.variants) {
- $positive = @(Get-AbLatestCases $Context | Where-Object {
- $_.status -eq 'Pass' -and $_.evidenceKind -eq 'Behavioral' -and
- @($_.calls | Where-Object { $_.exitCode -eq 0 -and $variant.key -in $_.apiKeys }).Count
- })
- $structural = @(Get-AbLatestCases $Context | Where-Object {
- $_.status -eq 'Pass' -and $_.evidenceKind -eq 'StructuralOnly' -and
- @($_.calls | Where-Object { $_.exitCode -eq 0 -and $variant.key -in $_.apiKeys }).Count
- })
- $mappedCommands = @($Context.Catalog.commands | Where-Object handler -eq $variant.handler | ForEach-Object command)
- $variants += @{ key = $variant.key; commands = $mappedCommands
- status = $(if ($positive.Count) { 'Pass' } elseif ($structural.Count) { 'StructuralOnly' } else { 'NotVerified' })
- reason = $(if (-not $positive.Count -and -not $structural.Count) { 'No successful assertion for this exact API/flag variant; see command dispositions and fixture blockers.' } else { '' }) }
- }
- return @{
- commands = $commands; apiVariants = $variants; totalCommands = $commands.Count
- successfulPaths = @($commands | Where-Object status -eq 'Pass').Count
- structuralOnly = @($commands | Where-Object status -eq 'StructuralOnly').Count
- negativeOnly = @($commands | Where-Object status -eq 'NegativeOnly').Count
- exhaustiveLiveCoverage = @($commands | Where-Object status -ne 'Pass').Count -eq 0 -and @($variants | Where-Object status -ne 'Pass').Count -eq 0
- }
-}
-
-function Get-AbExitCode {
- param([hashtable]$Context)
- if ($Context.Manifest.cleanup.status -in @('Failed', 'Incomplete', 'Retained')) { return 3 }
- $rootFailure = Get-AbValue $Context.Manifest 'failure' @{}
- $rootKind = Get-AbValue $rootFailure 'kind' ''
- if ($rootKind -eq 'Cleanup') { return 3 }
- if ($rootKind -eq 'Invalid') { return 4 }
- if ($rootKind -eq 'Fail') { return 1 }
- if ($rootKind -in @('BlockedEnvironment', 'BlockedFixture')) { return 2 }
- if ($Context.Mode -eq 'Cleanup' -and $Context.Manifest.cleanup.status -eq 'Complete') { return 0 }
- $latest = @(Get-AbLatestCases $Context)
- if ($rootKind -eq 'Fail' -or @($latest | Where-Object { $_.status -in @('Fail', 'Cleanup') }).Count) { return 1 }
- if ($rootKind -in @('BlockedEnvironment', 'BlockedFixture') -or @($latest | Where-Object { $_.required -and $_.status -in @('BlockedEnvironment', 'BlockedFixture', 'Running') }).Count) { return 2 }
- if (@($latest | Where-Object status -eq 'Invalid').Count) { return 4 }
- if ($Context.Manifest.options.includeInvasiveDiagnostics -and
- @($latest | Where-Object { $_.id -like '*.invasive-fixtures' -and $_.status -eq 'BlockedFixture' }).Count) { return 2 }
- return 0
-}
-
-function Write-AbReport {
- param([hashtable]$Context)
- $coverage = Get-AbCoverage $Context
- $exitCode = Get-AbExitCode $Context
- $report = @{
- schemaVersion = 1; runId = $Context.Manifest.runId; sourceCommit = $Context.Manifest.sourceCommit
- subscription = $Context.Subscription; primaryRegion = $Context.PrimaryRegion; siloRegion = $Context.SiloRegion
- mode = $Context.Mode; profile = $Context.Profile; operatingSystem = $Context.OperatingSystem; exitCode = $exitCode
- cases = @(Get-AbLatestCases $Context); caseHistory = @($Context.Manifest.cases)
- caseHistoryCount = $Context.Manifest.cases.Count
- suites = @($Context.Manifest.suites | ForEach-Object {
- @{ key = $_.key; operatingSystem = $_.operatingSystem; sku = $_.sku; cleanupComplete = $_.cleanupComplete
- allocated = $_.groupCreateAttempted; originVisibility = Get-AbValue $_ 'originVisibility' }
- })
- cleanup = $Context.Manifest.cleanup; coverage = $coverage; failure = (Get-AbValue $Context.Manifest 'failure' @{})
- generatedUtc = [DateTime]::UtcNow.ToString('o')
- }
- Write-AbJson (Join-Path $Context.RunDirectory 'results.json') $report
- $summary = @(
- '# Active Backup CLI validation'
- ''
- "Run: $($Context.Manifest.runId)"
- "Mode: $($Context.Mode)"
- "Profile: $($Context.Profile); OS: $($Context.OperatingSystem)"
- "Target: $($Context.PrimaryRegion) -> $($Context.SiloRegion)"
- "Source: $($Context.Manifest.sourceCommit)"
- "Exit code: $exitCode"
- "Cleanup: $($Context.Manifest.cleanup.status)"
- "Successful command paths: $($coverage.successfulPaths)/$($coverage.totalCommands)"
- "Structural-only: $($coverage.structuralOnly); negative-only: $($coverage.negativeOnly)"
- "Exhaustive live coverage: $($coverage.exhaustiveLiveCoverage)"
- ''
- '| Case | Outcome | Reason |'
- '|---|---|---|'
- )
- foreach ($case in @(Get-AbLatestCases $Context)) { $summary += "| $($case.id) | $($case.status) | $($case.reason) |" }
- foreach ($suite in $Context.Manifest.suites) {
- $observation = Get-AbValue $suite 'originVisibility'
- if ($null -eq $observation) { continue }
- $summary += @('', "## Origin visibility ($($suite.key))", '',
- "Observation starts after creation completes: $($observation.startedUtc)",
- "Window: $($observation.windowSeconds) seconds; first readable after: $($observation.firstVisibleAfterSeconds)",
- '', '| UTC | Elapsed seconds | Origin listed | CLI exit | ARM exit | Secondary exit |',
- '|---|---|---|---|---|---|')
- foreach ($sample in $observation.samples) {
- $summary += "| $($sample.observedUtc) | $($sample.elapsedSeconds) | $($sample.originListed) | $($sample.cliExitCode) | $($sample.armExitCode) | $($sample.secondaryExitCode) |"
- }
- }
- $previousFailures = @($Context.Manifest.cases | Where-Object { $_.status -in @('Fail', 'Cleanup', 'Invalid') })
- if ($Context.Mode -eq 'Cleanup' -and $previousFailures.Count) {
- $summary += @('', '## Previous failed attempts', '',
- 'The exit code above is for cleanup only; it does not erase earlier failures.',
- '', '| Case | Outcome | Reason |', '|---|---|---|')
- foreach ($case in $previousFailures) { $summary += "| $($case.id) | $($case.status) | $($case.reason) |" }
- }
- $failure = Get-AbValue $Context.Manifest 'failure' @{}
- if (Get-AbValue $failure 'kind') { $summary += @('', 'Failure: ' + (Get-AbValue $failure 'reason')) }
- if (@($Context.Manifest.cleanup.remaining).Count) {
- $summary += @('', '## Remaining owned resources')
- $summary += @($Context.Manifest.cleanup.remaining | ForEach-Object { '- ' + $_ })
- }
- [IO.File]::WriteAllText((Join-Path $Context.RunDirectory 'summary.md'), ($summary -join [Environment]::NewLine), [Text.UTF8Encoding]::new($false))
- Save-AbManifest $Context
- return $report
-}
-
-function Invoke-AbRun {
- param([hashtable]$Context, [switch]$KeepResourcesOnFailure)
- if ($Context.Mode -eq 'Resume') {
- $unsafeCases = @(Get-AbLatestCases $Context | Where-Object {
- $_.status -ne 'Pass' -and @($_.calls | Where-Object { (Get-AbValue $_ 'mutationReplay') -eq 'NoReplay' }).Count
- })
- if ($unsafeCases.Count) { Stop-Ab 'A non-idempotent action in an incomplete case was already attempted. It was not replayed; inspect or clean up this run.' BlockedFixture }
- foreach ($suite in $Context.Manifest.suites) {
- if ($suite.groupCreateAttempted -and -not $suite.cleanupComplete) {
- if (-not (Assert-AbOwnedGroup $Context $suite)) {
- Stop-Ab 'Resume cannot reconcile a missing owned resource group. Use Cleanup or start a new run.' BlockedFixture
- }
- }
- }
- if ($null -ne $Context.Manifest.pendingMutation) {
- if ($Context.Manifest.pendingMutation.replay -eq 'NoReplay') {
- Stop-Ab 'A previous non-idempotent action has an uncertain outcome. It was not replayed. Use ownership-checked Cleanup or investigate the recorded case.' BlockedFixture
- }
- }
- if ($Context.Manifest.suites.Count -and @($Context.Manifest.suites | Where-Object { -not $_.cleanupComplete }).Count -eq 0) {
- Stop-Ab 'Resume will not recreate already cleaned fixtures. Start a new run for additional coverage.' Invalid
- }
- }
- if (-not $Context.Manifest.suites.Count) {
- $systems = $(if ($Context.OperatingSystem -eq 'Both') { @('Windows', 'Linux') } else { @($Context.OperatingSystem) })
- $skus = $(if ($Context.Manifest.options.includeSkuMatrix) { @('P1V3', 'P1MV3', 'P1V4', 'P1MV4') } else { @('P1V3') })
- foreach ($os in $systems) { foreach ($sku in $skus) { $Context.Manifest.suites += @(New-AbSuite $Context $os $sku) } }
- Save-AbManifest $Context
- }
- $Context.Manifest.state = 'Running'
- Save-AbManifest $Context
- foreach ($suite in $Context.Manifest.suites) {
- if ($suite.cleanupComplete) { continue }
- $failed = $false
- try {
- if ($Context.Profile -eq 'OriginVisibility') {
- Invoke-AbSmoke $Context $suite -ProvisionOnly
- Invoke-AbOriginVisibility $Context $suite
- }
- else {
- Invoke-AbSmoke $Context $suite
- if ($Context.Profile -ne 'Smoke') { Invoke-AbCore $Context $suite }
- }
- }
- catch {
- $failed = $true
- Write-Warning $(if ($_.Exception.Data.Contains('AbSafeMessage')) { $_.Exception.Data['AbSafeMessage'] } else { 'A local operation failed; raw output was withheld.' })
- }
- finally {
- $anyFailure = $failed -or @(Get-AbLatestCases $Context | Where-Object { $_.status -in @('Fail', 'BlockedEnvironment', 'Invalid') }).Count -gt 0
- if ($KeepResourcesOnFailure -and $anyFailure) {
- $Context.Manifest.cleanup.status = 'Retained'
- $Context.Manifest.cleanup.remaining = @($Context.Manifest.suites | Where-Object { $_.groupCreateAttempted -and -not $_.cleanupComplete } | ForEach-Object groupId)
- Save-AbManifest $Context
- }
- else { Remove-AbOwnedResources $Context $suite }
- }
- Write-AbReport $Context | Out-Null
- if ($failed -or $Context.Manifest.cleanup.status -eq 'Retained') { break }
- }
- $Context.Manifest.state = $(if ((Get-AbExitCode $Context) -eq 0) { 'Complete' } else { 'Incomplete' })
- Save-AbManifest $Context
- return Write-AbReport $Context
-}
-
-Export-ModuleMember -Function *-Ab*
diff --git a/scripts/appservice/README.md b/scripts/appservice/README.md
deleted file mode 100644
index 1512ae1090e..00000000000
--- a/scripts/appservice/README.md
+++ /dev/null
@@ -1,326 +0,0 @@
-# Test the local Active Backup CLI
-
-This PowerShell 7.4+ driver runs the **CLI from this checkout**, not the `az` found
-on PATH. It generates disposable app content, records exact resource ownership,
-and tests the new commands against a selected subscription.
-
-**Nothing runs live by default.** `Plan` does not contact Azure or create runtime
-files. `Preflight` is read-only. `Run` needs explicit creation/cleanup flags and
-interactive confirmation. No provider/feature registration, account switching,
-automatic login, package installation, or PR update is performed.
-
-## What is implemented
-
-| Profile | Coverage |
-|---|---|
-| Smoke | Primary fixture, plan/site silo creation, no-wait operation inspection/wait, show/list, reference ARM reads, actual HTTPS/config markers, accepted refresh, and ordered teardown. |
-| Core | Smoke plus output/ID checks, worker/zone-false overrides, traffic/endpoint control, networking, LKG/content propagation, runtime actions, safe operational reads, and specific error cases. |
-| Extended | Core plus additional service-availability reads, network checks and reference refresh; optional serial SKU and zone testing. |
-| OriginVisibility | Only primary/plan-silo/site-silo provisioning, followed by bounded origin-read sampling and owned cleanup. No deployment, refresh, traffic updates, or other Core mutations. |
-
-All current command and API-variant identifiers appear in the report. Some point
-reads need a real returned fixture identifier. Invasive process/container/trace
-tests do **not** run against an arbitrary worker. `-IncludeInvasiveDiagnostics`
-currently reports a fixture blocker rather than sending those actions. Passing
-Core is not a claim that every CLI path has been exercised live.
-
-This script has offline fake-service tests. The target subscription, API rollout,
-capacity and actual live behavior still require an authorized run.
-
-## 1. Select an interpreter
-
-Run from the repository root in PowerShell 7.4 or newer (.NET 8+):
-
-```powershell
-pwsh -NoProfile
-Set-Location C:\path\to\azure-cli
-
-$subscription = ''
-$python = (Resolve-Path .\env\Scripts\python.exe).Path
-```
-
-Use an existing repository CLI development environment prepared according to
-[`doc\configuring_your_machine.md`](../../doc/configuring_your_machine.md).
-The harness does not prepare one automatically.
-
-On a machine with the Windows Azure CLI installed, its embedded interpreter can
-be selected explicitly as a local-development fallback:
-
-```powershell
-$python = 'C:\Program Files\Microsoft SDKs\Azure\CLI2\python.exe'
-if (-not (Test-Path -LiteralPath $python)) {
- throw 'Select a Python executable that already has Azure CLI dependencies.'
-}
-```
-
-The driver puts repository CLI/core sources before installed packages and checks
-the actual import paths and every new command's help. It refuses an interpreter
-that loads a different CLI or cannot import the required dependencies. This
-checks source selection, not that an installed CLI's dependency versions exactly
-match the repository requirements; a configured development venv is preferable
-for release-style validation. Plain system Python usually lacks CLI dependencies.
-
-## 2. Check the harness offline and inspect the plan
-
-```powershell
-pwsh -NoProfile -File .\scripts\appservice\tests\Test-ActiveBackupHarness.ps1
-
-pwsh -NoProfile -File .\scripts\appservice\test_active_backup.ps1 `
- -Mode Plan -Subscription $subscription `
- -PrimaryRegion eastus2euap -SiloRegion centraluseuap
-```
-
-The offline tests use synthetic service responses and child Python processes.
-They do not contact Azure. `-WhatIf` also forces Plan behavior, even with `-Mode Run`.
-To include a real local CLI import/help probe without Azure access, add
-`-CliPythonExe $python` to the offline test command.
-
-Defaults are East US 2 EUAP (`eastus2euap`) as primary and Central US EUAP
-(`centraluseuap`) as secondary. Supply the intended subscription explicitly;
-it is not hard-coded in the checked-in script.
-
-## 3. Log in using an isolated local CLI cache
-
-This is a manual operator step. The following block changes only temporary
-environment values, restores them afterward, and writes authentication data only
-under the ignored `.x\active-backup\auth` directory. It does not change your normal
-CLI account selection or copy tokens from another account.
-
-```powershell
-Import-Module .\scripts\appservice\ActiveBackupTestHarness.psm1 -Force -DisableNameChecking
-$repo = (Get-Location).Path
-$auth = Assert-AbLocalPath $repo '.x\active-backup\auth' -RequireIgnored
-$extensions = Assert-AbLocalPath $repo '.x\active-backup\login-extensions' -RequireIgnored
-$temp = Assert-AbLocalPath $repo '.x\active-backup\login-temp' -RequireIgnored
-foreach ($directory in @($auth, $extensions, $temp)) {
- Protect-AbDirectory $directory
-}
-
-$changes = @{
- PYTHONPATH = "$repo\src\azure-cli;$repo\src\azure-cli-core"
- PYTHONDONTWRITEBYTECODE = '1'
- AZURE_CONFIG_DIR = $auth
- AZURE_EXTENSION_DIR = $extensions
- AZURE_EXTENSION_USE_DYNAMIC_INSTALL = 'no'
- AZURE_CORE_COLLECT_TELEMETRY = 'false'
- TEMP = $temp
- TMP = $temp
-}
-$old = @{}
-try {
- foreach ($key in $changes.Keys) {
- $old[$key] = [Environment]::GetEnvironmentVariable($key, 'Process')
- [Environment]::SetEnvironmentVariable($key, $changes[$key], 'Process')
- }
- & $python -m azure.cli login
- if ($LASTEXITCODE -ne 0) { throw 'Azure login failed.' }
- & $python -m azure.cli account show --subscription $subscription `
- --query '{id:id,name:name,state:state,tenantId:tenantId}' --output json
- if ($LASTEXITCODE -ne 0) { throw 'The account cannot access the requested subscription.' }
-}
-finally {
- foreach ($key in $old.Keys) {
- if ($null -eq $old[$key]) {
- if (Test-Path -LiteralPath "Env:\$key") {
- Remove-Item -LiteralPath "Env:\$key"
- }
- }
- else {
- [Environment]::SetEnvironmentVariable($key, $old[$key], 'Process')
- }
- }
-}
-```
-
-If tenant selection is necessary, add your tenant to the manual login command.
-Do not paste access tokens into parameters, logs, or GitHub. GitHub authentication
-for the PR is separate from Azure authentication for these tests.
-
-## 4. Run read-only preflight
-
-```powershell
-pwsh -NoProfile -File .\scripts\appservice\test_active_backup.ps1 `
- -Mode Preflight -PythonExe $python -Subscription $subscription `
- -PrimaryRegion eastus2euap -SiloRegion centraluseuap `
- -OperatingSystem Windows -Profile Smoke
-```
-
-Preflight checks source provenance/help, the exact account/cloud/regions,
-Microsoft.Web registration, new resource API metadata for `2026-08-01`, and
-regional P1V3 discovery. Core also checks Microsoft.Network registration; Linux
-checks the `PYTHON:3.12` fixture runtime.
-
-You need permission to create/delete resource groups, App Service plans/apps and
-silos, deploy the fixture, and optionally create/update the owned VNet/subnet.
-Regional discovery does not guarantee allocation capacity or Active Backup
-enablement. If API/feature/region/SKU metadata is missing, resolve it with the
-service/subscription owner; the driver does not downgrade or enable it for you.
-
-An unauthenticated cache or unavailable prerequisite returns nonzero before any
-resource allocation. Review `summary.md`/`results.json` at the printed path.
-
-## 5. Start with a Windows Smoke run
-
-**This allocates billable resources.** Confirm the subscription and regions
-shown before agreeing to the prompt:
-
-```powershell
-pwsh -NoProfile -File .\scripts\appservice\test_active_backup.ps1 `
- -Mode Run -PythonExe $python -Subscription $subscription `
- -PrimaryRegion eastus2euap -SiloRegion centraluseuap `
- -Profile Smoke -OperatingSystem Windows `
- -AllowResourceCreation -AllowCleanup
-```
-
-The baseline is one P1V3 primary worker and one secondary worker. Groups and app
-names are unique to the run; existing groups are never adopted. Cleanup deletes
-site silos before plan silos and refuses group deletion if ownership or remaining
-dependencies cannot be verified.
-
-For explicitly approved automation, `-Confirm:$false` suppresses the interactive
-prompt; it does not bypass the creation/cleanup flags or ownership checks.
-
-## 6. Expand to Core, then optional profiles
-
-After Smoke and its cleanup succeed:
-
-```powershell
-pwsh -NoProfile -File .\scripts\appservice\test_active_backup.ps1 `
- -Mode Run -PythonExe $python -Subscription $subscription `
- -PrimaryRegion eastus2euap -SiloRegion centraluseuap `
- -Profile Core -OperatingSystem Both `
- -AllowResourceCreation -AllowCleanup
-```
-
-Windows and Linux run sequentially, with cleanup between suites. Core can
-temporarily increase secondary workers from one to two. Linux needs service-side
-enablement; the client is not changed to hide unsupported calls.
-
-The Windows fixture includes an IIS `web.config` mapping for `.json` so its
-version marker does not depend on the worker's default MIME mappings.
-The Linux fixture serves generated static files using `PYTHON:3.12` and a stdlib
-HTTP server on port 8000. No package restore, external sample repository, real
-database, registry credential or Key Vault is needed for the baseline.
-
-Use `-Profile Extended -IncludeSkuMatrix` only after approving the additional
-serial P1V3/P1MV3/P1V4/P1MV4 allocations. `-IncludeZoneRedundancy` also requires
-Extended and a two-worker budget. Zone availability remains service-enforced.
-The script does not claim that its runtime/diagnostic inventory is exhaustive.
-
-## Isolate origin visibility after creation
-
-Use this profile to measure whether origin becomes readable after the create
-operation succeeds, without first changing workers, traffic, or site policy:
-
-```powershell
-pwsh -NoProfile -File .\scripts\appservice\test_active_backup.ps1 `
- -Mode Run -PythonExe $python -Subscription $subscription `
- -PrimaryRegion eastus2euap -SiloRegion centraluseuap `
- -Profile OriginVisibility -OperatingSystem Windows -MaxWorkersPerPlan 1 `
- -ObservationTimeoutSeconds 900 -AllowResourceCreation -AllowCleanup
-```
-
-It allocates one Windows P1V3 primary worker and one secondary worker.
-The observation window starts after successful silo creation. Samples are
-scheduled about 30 seconds apart and compare the origin collection entry,
-CLI point GET, raw public ARM point GET at the same API version, and secondary
-point GET. Only expected not-found responses are observed again; other errors
-fail explicitly. The first successful CLI/ARM/control sample ends the probe.
-The JSON report and summary retain sample times and allowlisted identities/states.
-Not appearing within the selected window does not prove origin will never appear.
-No production CLI retry or waiting behavior is changed.
-
-## Results and recovery
-
-Each run writes protected local files:
-
-```text
-.x\active-backup\runs\\
- manifest.json
- events.jsonl
- results.json
- summary.md
- fixtures\
- inputs\
- downloads\
-```
-
-Reports contain case outcomes and a command/API coverage ledger, not raw
-credential-bearing response bodies. Empty list reads are structural coverage;
-missing point-read fixtures are blockers, not passes. Refresh acceptance is
-tested separately from later HTTPS/config/revision observations.
-Create/update journal entries retain only allowlisted CLI-result identity and
-state fields, including null IDs; they do not record raw HTTP bodies or headers.
-Command events also record `originCollectionFallback` when the CLI emits its
-origin-list compatibility warning. The OriginVisibility profile still requires
-the raw ARM point read to succeed; a CLI collection fallback alone is not a pass.
-Recovery reports retain the full case history. A successful Cleanup exit code
-only confirms cleanup; earlier failures are also shown in the summary.
-
-| Exit | Meaning |
-|---|---|
-| 0 | Selected required checks and normal cleanup completed, or explicit Cleanup completed. Read coverage separately. |
-| 1 | An executed assertion/command failed. |
-| 2 | A required environment/fixture prerequisite is blocked. |
-| 3 | Cleanup failed, resources were retained, or ownership/dependencies could not be resolved. |
-| 4 | Invalid arguments, source/target mismatch, corrupt manifest, or local setup error. |
-
-The script prints an exact cleanup command naming the manifest. Use it after
-an interrupted/retained run, for example:
-
-```powershell
-pwsh -NoProfile -File .\scripts\appservice\test_active_backup.ps1 `
- -Mode Cleanup -PythonExe $python -Subscription $subscription `
- -Manifest '.x\active-backup\runs\\manifest.json' -AllowCleanup
-```
-
-Replace `` with the printed run; never delete groups by a name
-prefix. Cleanup revalidates the actual group tags, subscription and resource IDs
-and waits for absence. A source change prevents Resume but does not require
-using a stale/broken interpreter for ownership-checked Cleanup.
-Rejected RunId collisions and lock-acquisition failures leave the existing
-manifest and reports untouched. Reports are finalized only after local
-initialization succeeds while holding the run lock.
-Recovery also compares the exact snapshot read before confirmation with the
-manifest after acquiring the lock. If another invocation updated it in between,
-recovery stops without saving stale state; reread the manifest and retry.
-Some enterprise subscriptions add policy-generated NRMS NSGs during VNet creation.
-If these lack the run's tags/journal entries, automatic cleanup intentionally
-refuses group deletion. Review exact IDs and attachments and obtain explicit
-operator approval for those resources; do not bypass the guard by accepting an
-`NRMS-*` prefix or adopting every resource in the group.
-Resume/Cleanup select an explicit `-PythonExe` or a repository venv; an executable
-path embedded in a manifest is never automatically trusted for execution.
-
-`-KeepResourcesOnFailure` explicitly retains potentially billable resources for
-diagnosis. It does not produce a passing cleanup result. Resume requires the same
-Git commit and CLI/harness source fingerprints:
-
-```powershell
-pwsh -NoProfile -File .\scripts\appservice\test_active_backup.ps1 `
- -Mode Resume -PythonExe $python -Subscription $subscription `
- -Manifest '.x\active-backup\runs\\manifest.json' `
- -AllowResourceCreation -AllowCleanup
-```
-
-Resume does not recreate cleaned fixtures or blindly replay uncertain deployment,
-refresh, mark-LKG, or other non-idempotent actions. In those cases, investigate or
-clean up and start a fresh run.
-Before continuing, Resume revalidates every attempted, uncleared resource group,
-even when no mutation is pending. Missing groups and mismatched ownership tags
-stop resumption before further resource mutations.
-
-Successful commands clear their pending-mutation journal entry after output
-validation, including empty refresh results and text output. Failed, timed-out,
-or invalid-output mutations remain uncertain. A completed refresh case can be
-resumed without issuing refresh again; an incomplete case containing a
-non-idempotent action still requires investigation or cleanup.
-
-The operation, observation, run and cleanup timeout parameters bound orchestration,
-not billing. A host/power failure can leave resources until the recovery command
-is run; expiry tags are not an automatic reaper.
-
-Do not commit or automatically upload the auth cache, dumps, downloaded logs,
-generated bodies or raw local output. Review reports before sharing. This driver
-does not generate VCR recordings or replace the feature PR's full `azdev` release
-validation.
diff --git a/scripts/appservice/test_active_backup.ps1 b/scripts/appservice/test_active_backup.ps1
deleted file mode 100644
index bbeb51e7664..00000000000
--- a/scripts/appservice/test_active_backup.ps1
+++ /dev/null
@@ -1,152 +0,0 @@
-# Copyright (c) Microsoft Corporation. All rights reserved.
-# Licensed under the MIT License. See License.txt in the project root for license information.
-#requires -Version 7.4
-
-[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
-param(
- [ValidateSet('Plan', 'Preflight', 'Run', 'Resume', 'Cleanup')][string]$Mode = 'Plan',
- [Parameter(Mandatory)][string]$Subscription,
- [string]$PrimaryRegion = 'eastus2euap',
- [string]$SiloRegion = 'centraluseuap',
- [string]$PythonExe,
- [ValidateSet('Windows', 'Linux', 'Both')][string]$OperatingSystem = 'Both',
- [ValidateSet('Smoke', 'Core', 'Extended', 'OriginVisibility')][string]$Profile = 'Core',
- [string]$RunId,
- [string]$Manifest,
- [string]$OutputRoot = '.x\active-backup\runs',
- [string]$AuthConfigDir = '.x\active-backup\auth',
- [switch]$AllowResourceCreation,
- [switch]$AllowCleanup,
- [switch]$KeepResourcesOnFailure,
- [switch]$IncludeInvasiveDiagnostics,
- [switch]$IncludeSkuMatrix,
- [switch]$IncludeZoneRedundancy,
- [ValidateRange(1, 7200)][int]$OperationTimeoutSeconds = 1800,
- [ValidateRange(1, 7200)][int]$ObservationTimeoutSeconds = 900,
- [ValidateRange(1, 480)][int]$RunTimeoutMinutes = 120,
- [ValidateRange(1, 120)][int]$CleanupTimeoutMinutes = 30,
- [ValidateRange(1, 2)][int]$MaxWorkersPerPlan = 2
-)
-
-Set-StrictMode -Version Latest
-$ErrorActionPreference = 'Stop'
-Import-Module (Join-Path $PSScriptRoot 'ActiveBackupTestHarness.psm1') -Force -DisableNameChecking
-$context = $null
-$initialized = $false
-$exitCode = 0
-$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
-try {
- if ($WhatIfPreference) { $Mode = 'Plan' }
- if ($Mode -in @('Run', 'Resume') -and (-not $AllowResourceCreation -or -not $AllowCleanup)) {
- Stop-Ab 'Run/Resume requires both -AllowResourceCreation and -AllowCleanup. Plan/Preflight do not allocate resources.' Invalid
- }
- if ($Mode -eq 'Cleanup' -and -not $AllowCleanup) { Stop-Ab 'Cleanup requires -AllowCleanup and the owned run manifest.' Invalid }
- if (($IncludeSkuMatrix -or $IncludeInvasiveDiagnostics -or $IncludeZoneRedundancy) -and $Profile -ne 'Extended') {
- Stop-Ab 'Extra SKU/zone/diagnostic authorization is only supported with -Profile Extended.' Invalid
- }
- if ($IncludeZoneRedundancy -and $MaxWorkersPerPlan -lt 2) { Stop-Ab 'Zone testing requires a two-worker budget.' Invalid }
- if ($Mode -in @('Resume', 'Cleanup')) {
- if (-not $Manifest) { Stop-Ab 'Resume/Cleanup requires -Manifest pointing to the exact owned run.' Invalid }
- $context = Read-AbManifest $repo $Manifest $Subscription $Mode
- $context.PythonExe = Resolve-AbPython $repo $PythonExe
- foreach ($name in @('PrimaryRegion', 'SiloRegion', 'Profile', 'OperatingSystem')) {
- if ($PSBoundParameters.ContainsKey($name) -and (Get-Variable -Name $name -ValueOnly) -ne $context[$name]) {
- Stop-Ab 'Resume/Cleanup target and profile must match the original manifest.' Invalid
- }
- }
- }
- else {
- $context = New-AbContext -RepoRoot $repo -Subscription $Subscription -PrimaryRegion $PrimaryRegion `
- -SiloRegion $SiloRegion -PythonExe $PythonExe -Profile $Profile -OperatingSystem $OperatingSystem `
- -OutputRoot $OutputRoot -AuthConfigDir $AuthConfigDir -RunId $RunId -Mode $Mode `
- -OperationTimeoutSeconds $OperationTimeoutSeconds -ObservationTimeoutSeconds $ObservationTimeoutSeconds `
- -RunTimeoutMinutes $RunTimeoutMinutes -CleanupTimeoutMinutes $CleanupTimeoutMinutes -MaxWorkersPerPlan $MaxWorkersPerPlan `
- -IncludeSkuMatrix ([bool]$IncludeSkuMatrix) -IncludeInvasiveDiagnostics ([bool]$IncludeInvasiveDiagnostics) `
- -IncludeZoneRedundancy ([bool]$IncludeZoneRedundancy)
- }
- Write-Host "Mode: $Mode; profile: $($context.Profile); OS: $($context.OperatingSystem)"
- Write-Host "Subscription: $($context.Subscription)"
- Write-Host "Primary: $($context.PrimaryRegion); silo: $($context.SiloRegion); API: $($context.ApiVersion)"
- Write-Host "Source commit: $($context.Manifest.sourceCommit)"
- Write-Host "Command inventory: $($context.Catalog.commands.Count); API variants: $($context.Catalog.variants.Count)"
- Write-Host 'Baseline: one P1V3 primary worker plus one secondary worker; OS/SKU suites are serial.'
- Write-Host "Capacity ceiling per plan: $($context.Manifest.options.maxWorkersPerPlan); additional SKU matrix: $($context.Manifest.options.includeSkuMatrix)"
- Write-Host 'EUAP is not assumed free. Run timeout does not stop Azure billing after a host failure.'
- if ($Mode -eq 'Plan') {
- Write-Host "Planned manifest: $($context.ManifestPath)"
- $systems = $(if ($context.OperatingSystem -eq 'Both') { @('Windows', 'Linux') } else { @($context.OperatingSystem) })
- $skus = $(if ($context.Manifest.options.includeSkuMatrix) { @('P1V3', 'P1MV3', 'P1V4', 'P1MV4') } else { @('P1V3') })
- foreach ($system in $systems) {
- foreach ($sku in $skus) {
- $suite = New-AbSuite $context $system $sku
- Write-Host "Planned suite: $($suite.key); group: $($suite.group); plan: $($suite.plan); app: $($suite.app)"
- }
- }
- Write-Host 'Plan makes no Azure calls and creates no runtime files. Prepare the CLI interpreter/auth cache before Preflight.'
- if ($context.Profile -eq 'OriginVisibility') {
- Write-Host "OriginVisibility: provision primary/plan silo/site silo, then observe origin for $($context.Manifest.options.observationTimeoutSeconds) seconds after creation completes."
- Write-Host 'Compare list, CLI origin GET, raw ARM origin GET and secondary GET. No deployment, refresh, traffic or Core mutations.'
- }
- else {
- Write-Host 'Smoke: primary fixture -> plan silo -> site silo -> real marker/config checks -> accepted refresh -> owned cleanup.'
- Write-Host 'Core: Smoke + outputs/IDs, overrides, traffic/LKG, networking, safe reads, replication and negative cases.'
- Write-Host 'Extended: Core + optional reads/network checks. Unconfigured point/invasive fixtures remain explicitly incomplete.'
- }
- }
- else {
- if ($Mode -eq 'Cleanup' -and -not $PSCmdlet.ShouldProcess($context.Subscription, 'Delete only manifest-owned Active Backup test resources')) { return }
- Initialize-AbLocalState $context -Existing:($Mode -in @('Resume', 'Cleanup'))
- $initialized = $true
- Test-AbSource $context
- $context.Manifest.Remove('failure')
- Save-AbManifest $context
- if ($Mode -eq 'Cleanup') {
- Invoke-AbTargetCheck $context
- Remove-AbOwnedResources $context
- Write-AbReport $context | Out-Null
- }
- else {
- Invoke-AbPreflight $context
- if ($Mode -eq 'Preflight') {
- $context.Manifest.state = 'PreflightComplete'
- Write-AbReport $context | Out-Null
- }
- elseif ($PSCmdlet.ShouldProcess($context.Subscription, 'Allocate billable disposable fixtures and clean up their exact owned resource groups')) {
- Invoke-AbRun $context -KeepResourcesOnFailure:$KeepResourcesOnFailure | Out-Null
- }
- }
- $exitCode = Get-AbExitCode $context
- Write-Host "Results: $(Join-Path $context.RunDirectory 'results.json')"
- Write-Host "Summary: $(Join-Path $context.RunDirectory 'summary.md')"
- }
-}
-catch {
- $kind = $(if ($_.Exception.Data.Contains('AbKind')) { $_.Exception.Data['AbKind'] } else { 'Invalid' })
- $exitCode = switch ($kind) { 'Cleanup' { 3 } 'BlockedEnvironment' { 2 } 'BlockedFixture' { 2 } 'Fail' { 1 } default { 4 } }
- Write-Warning $(if ($_.Exception.Data.Contains('AbSafeMessage')) { $_.Exception.Data['AbSafeMessage'] } else { 'Unexpected local failure. Raw output was withheld to avoid persisting credentials.' })
- if ($initialized -and $context.Lock -and (Test-Path -LiteralPath $context.ManifestPath)) {
- $context.Manifest.failure = @{
- kind = $kind
- reason = $(if ($_.Exception.Data.Contains('AbSafeMessage')) { $_.Exception.Data['AbSafeMessage'] } else { 'Unexpected local failure; raw details withheld.' })
- }
- try {
- $report = Write-AbReport $context
- $exitCode = $report.exitCode
- }
- catch { Write-Warning 'Unable to finalize the local report; preserve the existing manifest.' }
- }
-}
-finally {
- if ($context) {
- if ($context.Lock) { $context.Lock.Dispose() }
- if ($initialized -and $context.Mode -ne 'Plan' -and (Test-Path -LiteralPath $context.ManifestPath)) {
- $relative = [IO.Path]::GetRelativePath($repo, $context.ManifestPath)
- Write-Host "Recovery manifest: $relative"
- $scriptPath = (Join-Path $PSScriptRoot 'test_active_backup.ps1').Replace("'", "''")
- $manifestPath = $context.ManifestPath.Replace("'", "''")
- $interpreter = ([string]$context.PythonExe).Replace("'", "''")
- Write-Host ("Cleanup: pwsh -NoProfile -File '{0}' -Mode Cleanup -PythonExe '{1}' -Subscription {2} -Manifest '{3}' -AllowCleanup" -f $scriptPath, $interpreter, $context.Subscription, $manifestPath)
- }
- }
-}
-exit $exitCode
diff --git a/scripts/appservice/tests/Test-ActiveBackupHarness.ps1 b/scripts/appservice/tests/Test-ActiveBackupHarness.ps1
deleted file mode 100644
index 6f8c59c4dd0..00000000000
--- a/scripts/appservice/tests/Test-ActiveBackupHarness.ps1
+++ /dev/null
@@ -1,1129 +0,0 @@
-# Copyright (c) Microsoft Corporation. All rights reserved.
-# Licensed under the MIT License. See License.txt in the project root for license information.
-#requires -Version 7.4
-
-param([string]$CliPythonExe)
-
-Set-StrictMode -Version Latest
-$ErrorActionPreference = 'Stop'
-Import-Module (Join-Path $PSScriptRoot '..\ActiveBackupTestHarness.psm1') -Force -DisableNameChecking
-$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..'))
-$subscription = '00000000-0000-0000-0000-000000000001'
-$python = (Get-Command python -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
-$testId = [guid]::NewGuid().ToString('N')
-$testRoot = Join-Path $repo ('.x\active-backup\tests\' + $testId)
-$contexts = [Collections.Generic.List[object]]::new()
-$script:passed = 0
-$script:failed = 0
-
-function Test-Case {
- param([string]$Name, [scriptblock]$Body)
- try {
- & $Body
- $script:passed++
- Write-Host "PASS $Name"
- }
- catch {
- $script:failed++
- Write-Host "FAIL $Name"
- Write-Host $_.Exception.Message
- if ($_.Exception.InnerException) { Write-Host $_.Exception.InnerException.Message }
- Write-Host $_.ScriptStackTrace
- }
-}
-
-function Assert-Test {
- param([bool]$Condition, [string]$Message = 'Assertion failed.')
- if (-not $Condition) { throw $Message }
-}
-
-function Assert-Throws {
- param([scriptblock]$Body, [string]$Pattern = '.')
- $caught = $false
- try { & $Body | Out-Null } catch {
- $caught = $true
- if ($_.Exception.Message -notmatch $Pattern) { throw "Wrong error: $($_.Exception.Message)" }
- }
- if (-not $caught) { throw 'Expected an exception.' }
-}
-
-function New-TestContext {
- param([string]$Profile = 'Smoke', [string]$Mode = 'Run')
- $context = New-AbContext -RepoRoot $repo -Subscription $subscription -PythonExe $python `
- -Profile $Profile -OperatingSystem Windows -Mode $Mode `
- -OutputRoot (Join-Path $testRoot ([guid]::NewGuid().ToString('N'))) -AuthConfigDir (Join-Path $testRoot 'auth') `
- -OperationTimeoutSeconds 10 -ObservationTimeoutSeconds 2
- $contexts.Add($context)
- Initialize-AbLocalState $context
- return $context
-}
-
-function New-FakeResponse {
- param($Payload = $null, [int]$ExitCode = 0, [string]$ErrorText = '')
- return @{
- exitCode = $ExitCode; stdout = $(if ($null -ne $Payload) { ConvertTo-Json -InputObject $Payload -Depth 40 -Compress } else { '' })
- stderr = $ErrorText; elapsedMs = 1; timedOut = $false
- }
-}
-
-function Get-FakeArgument {
- param([string[]]$Arguments, [string]$Flag, $Default = $null)
- $index = [array]::IndexOf($Arguments, $Flag)
- if ($index -ge 0 -and $index + 1 -lt $Arguments.Count) { return $Arguments[$index + 1] }
- return $Default
-}
-
-function Set-FakeAzure {
- param([hashtable]$Context, [hashtable]$Suite)
- $state = @{
- groupExists = $false; planExists = $false; appExists = $false; planSilo = $false; siteSilo = $false
- primaryGeneration = 'v1'; siloGeneration = 'v1'; followLkg = $false; revision = 1; lkg = 1
- workers = 1; zone = $false; endpoint = 'Enabled'; origin = 'Enabled'; subnet = ''
- mode = 'Active'; resources = @(); calls = @(); callsAfterDelete = @(); dummyConnection = ''
- endpoints = @(@{ region = $Context.PrimaryRegion; weight = 1; priority = 1 },
- @{ region = $Context.SiloRegion; weight = 1; priority = 2 })
- }
- $ctx = $Context
- $s = $Suite
- $Context.Executor = {
- param([string[]]$Arguments)
- $joined = $Arguments -join ' '
- $state.calls += @($joined)
- $id = Get-FakeArgument $Arguments '--ids'
- $groupTags = @{
- abcliRunId = $ctx.Manifest.runId; abcliOwner = $ctx.Manifest.owner
- abcliPurpose = 'local-cli-validation'; abcliCommit = $ctx.Manifest.sourceCommit
- }
- $planResource = @{
- id = $s.planSiloId; name = $s.planSilo; type = 'Microsoft.Web/serverfarms/silos'; location = $ctx.SiloRegion
- properties = @{ provisioningState = 'Succeeded'; primaryLocation = $ctx.PrimaryRegion
- instanceNumberOverride = $state.workers; zoneRedundantOverride = $state.zone }
- }
- $siteResource = @{
- id = $s.siteSiloId; name = $s.siteSilo; type = 'Microsoft.Web/sites/silos'; location = $ctx.SiloRegion
- properties = @{ provisioningState = 'Succeeded'; primaryLocation = $ctx.PrimaryRegion
- revisionNumber = $state.revision; followLastKnownGood = $state.followLkg
- endpointStatus = $state.endpoint; defaultHostName = 'silo.example.test'; monitorStatus = 'Healthy' }
- }
- $originResource = @{
- id = $s.appId + '/silos/origin'; name = 'origin'; type = 'Microsoft.Web/sites/silos'; location = $ctx.PrimaryRegion
- properties = @{ endpointStatus = $state.origin; provisioningState = 'Succeeded' }
- }
- if ($joined.StartsWith('account show ')) { return New-FakeResponse @{ id = $ctx.Subscription; state = 'Enabled'; tenantId = 'test-tenant' } }
- if ($joined.StartsWith('cloud show ')) {
- if ($Arguments -contains '--subscription') { return New-FakeResponse -ExitCode 2 -ErrorText 'ERROR: unrecognized arguments: --subscription' }
- return New-FakeResponse @{ name = 'AzureCloud'; endpoints = @{ resourceManager = 'https://management.azure.com/' } }
- }
- if ($joined.StartsWith('provider show ')) {
- return New-FakeResponse @{ registrationState = 'Registered'; resourceTypes = @(
- @('serverFarms/silos', 'sites/silos', 'sites/managedTrafficConfig', 'sites/lastKnownGood') | ForEach-Object {
- @{ resourceType = $_; apiVersions = @('2026-08-01') }
- }) }
- }
- if ($joined.StartsWith('appservice list-locations ')) { return New-FakeResponse @(@{ name = $ctx.PrimaryRegion }, @{ name = $ctx.SiloRegion }) }
- if ($joined.StartsWith('webapp list-runtimes ')) { return New-FakeResponse -Payload @('PYTHON:3.12') }
- if ($joined.StartsWith('group exists ')) { return New-FakeResponse $state.groupExists }
- if ($joined.StartsWith('group create ')) { $state.groupExists = $true; return New-FakeResponse @{ id = $s.groupId; tags = $groupTags } }
- if ($joined.StartsWith('group show ')) {
- if (-not $state.groupExists) { return New-FakeResponse -ExitCode 3 -ErrorText 'ERROR: (ResourceNotFound) Not found.' }
- return New-FakeResponse @{ id = $s.groupId; tags = $groupTags }
- }
- if ($joined.StartsWith('group delete ')) { $state.groupExists = $false; return New-FakeResponse }
- if ($joined.StartsWith('appservice plan create ')) {
- $state.planExists = $true; $state.resources += @(@{ id = $s.planId })
- return New-FakeResponse @{ id = $s.planId }
- }
- if ($joined.StartsWith('webapp create ')) {
- $state.appExists = $true; $state.resources += @(@{ id = $s.appId })
- return New-FakeResponse @{ id = $s.appId; defaultHostName = 'primary.example.test' }
- }
- if ($joined.StartsWith('webapp config appsettings set ')) { return New-FakeResponse @{ properties = @{} } }
- if ($joined.StartsWith('webapp config connection-string set ')) {
- $state.dummyConnection = (Get-FakeArgument $Arguments '--settings').Substring('ABCLI_DUMMY='.Length)
- return New-FakeResponse @{ properties = @{} }
- }
- if ($joined.StartsWith('webapp deploy ')) {
- $zip = [IO.Compression.ZipFile]::OpenRead((Get-FakeArgument $Arguments '--src-path'))
- try {
- $reader = [IO.StreamReader]::new($zip.GetEntry('version.json').Open())
- try { $marker = ConvertFrom-Json -InputObject $reader.ReadToEnd() -AsHashtable } finally { $reader.Dispose() }
- }
- finally { $zip.Dispose() }
- $state.primaryGeneration = $marker.generation
- if (-not $state.followLkg) { $state.siloGeneration = $marker.generation; $state.revision++ }
- return New-FakeResponse @{ status = 'Succeeded' }
- }
- if ($joined.StartsWith('appservice plan silo create ') -or $joined.StartsWith('appservice plan silo update ')) {
- $workers = Get-FakeArgument $Arguments '--number-of-workers' '1'
- if ($workers -in @('0', '31')) { return New-FakeResponse -ExitCode 1 -ErrorText 'ERROR: --number-of-workers must be between 1 and 30.' }
- $state.workers = [int]$workers; $state.planSilo = $true
- $state.zone = (Get-FakeArgument $Arguments '--zone-redundant' 'false') -eq 'true'
- if ($Arguments -contains '--no-wait') {
- return New-FakeResponse @{ resourceId = $s.planSiloId; operationId = 'plan-op'; status = 'Accepted' }
- }
- $planResource.properties.instanceNumberOverride = $state.workers
- $planResource.properties.zoneRedundantOverride = $state.zone
- return New-FakeResponse $planResource
- }
- if ($joined.StartsWith('appservice plan silo show ')) {
- if (-not $state.planSilo) { return New-FakeResponse -ExitCode 3 -ErrorText 'ERROR: (ResourceNotFound) Missing.' }
- return New-FakeResponse $planResource
- }
- if ($joined.StartsWith('appservice plan silo list ')) {
- if ($state.planSilo) { return New-FakeResponse -Payload @($planResource) }
- return New-FakeResponse -Payload @()
- }
- if ($joined.StartsWith('appservice plan silo delete ')) {
- if ($state.siteSilo) { return New-FakeResponse -ExitCode 1 -ErrorText "ERROR: (Conflict) The silo has site silos associated with it. Delete site silos first." }
- if (-not $state.planSilo) { return New-FakeResponse }
- $state.planSilo = $false
- return New-FakeResponse @{ resourceId = $s.planSiloId; operationId = 'plan-delete'; status = 'Accepted' }
- }
- if ($joined.StartsWith('webapp silo create ')) {
- $state.siteSilo = $true
- return New-FakeResponse @{ resourceId = $s.siteSiloId; operationId = 'site-op'; status = 'Accepted' }
- }
- if ($joined.StartsWith('webapp silo show ')) {
- if ($id -and $id.Contains('/serverfarms/')) { return New-FakeResponse -ExitCode 1 -ErrorText 'ERROR: Provide a Microsoft.Web/sites/silos resource ID.' }
- if ($id -like '*/silos/origin') {
- return New-FakeResponse $originResource
- }
- if (-not $state.siteSilo -or $id -like '*/silos/missing-*') { return New-FakeResponse -ExitCode 3 -ErrorText 'ERROR: (ResourceNotFound) Missing.' }
- if ((Get-FakeArgument $Arguments '--output') -in @('table', 'tsv')) { return @{ exitCode = 0; stdout = 'fixture output'; stderr = ''; elapsedMs = 1; timedOut = $false } }
- return New-FakeResponse $siteResource
- }
- if ($joined.StartsWith('webapp silo list ')) {
- if ($state.siteSilo) { return New-FakeResponse -Payload @($siteResource, @{ name = 'origin' }) }
- return New-FakeResponse -Payload @()
- }
- if ($joined.StartsWith('webapp silo delete ')) {
- if ($id -like '*/silos/origin') { return New-FakeResponse -ExitCode 1 -ErrorText "ERROR: (BadRequest) The origin silo cannot be deleted directly." }
- if (-not $state.siteSilo) { return New-FakeResponse }
- $state.siteSilo = $false
- return New-FakeResponse @{ resourceId = $s.siteSiloId; operationId = 'site-delete'; status = 'Accepted' }
- }
- if ($joined -match '^(appservice plan silo|webapp silo) (operation show|wait) ') { return New-FakeResponse @{ status = 'Succeeded' } }
- if ($joined.StartsWith('webapp silo config appsettings list ')) {
- return New-FakeResponse @{ properties = @{ ABCLI_RUN_ID = $ctx.Manifest.runId; ABCLI_GENERATION = $state.siloGeneration; PLATFORM_SECRET = 'synthetic-do-not-persist' } }
- }
- if ($joined.StartsWith('webapp silo config connection-string list ')) {
- return New-FakeResponse @{ properties = @{ ABCLI_DUMMY = @{ value = $state.dummyConnection; type = 'Custom' } } }
- }
- if ($joined.StartsWith('webapp silo config connection-string show ') -and $Arguments -contains '--key') {
- return New-FakeResponse @{ properties = @{ value = $state.dummyConnection; type = 'Custom' } }
- }
- if ($joined.StartsWith('webapp silo refresh ')) { return New-FakeResponse -ErrorText 'WARNING: Refresh request accepted. Revision propagation continues asynchronously.' }
- if ($joined.StartsWith('rest ')) {
- $url = Get-FakeArgument $Arguments '--url'
- if ($url -eq ($s.appId + '/silos/origin?api-version=' + $ctx.ApiVersion)) {
- return New-FakeResponse $originResource
- }
- if ($url -eq "/subscriptions/$($ctx.Subscription)/locations?api-version=2022-12-01") {
- return New-FakeResponse @{ value = @(@{ name = $ctx.PrimaryRegion }, @{ name = $ctx.SiloRegion }) }
- }
- if ($url -like ($s.planSiloId + '*')) { return New-FakeResponse $planResource }
- return New-FakeResponse $siteResource
- }
- if ($joined.StartsWith('resource list ')) { return New-FakeResponse @($state.resources) }
- if ($joined.StartsWith('webapp silo update ')) {
- if ($id -like '*/silos/origin') { $state.origin = Get-FakeArgument $Arguments '--endpoint-status'; return New-FakeResponse @{ properties = @{ endpointStatus = $state.origin } } }
- $state.followLkg = (Get-FakeArgument $Arguments '--follow-last-known-good' 'false') -eq 'true'
- $state.endpoint = Get-FakeArgument $Arguments '--endpoint-status' $state.endpoint
- $siteResource.properties.followLastKnownGood = $state.followLkg
- return New-FakeResponse $siteResource
- }
- if ($joined.StartsWith('webapp managed-traffic ')) {
- if ($Arguments -contains 'update') {
- $state.mode = Get-FakeArgument $Arguments '--traffic-mode' $state.mode
- $input = Get-FakeArgument $Arguments '--endpoints'
- if ($input) {
- $entries = ConvertFrom-Json -InputObject ([IO.File]::ReadAllText($input.Substring(1))) -AsHashtable -NoEnumerate
- foreach ($entry in $entries) {
- $target = @($state.endpoints | Where-Object region -eq $entry.region)[0]
- foreach ($key in @('priority', 'weight')) { if ($entry.Contains($key)) { $target[$key] = $entry[$key] } }
- }
- }
- }
- $traffic = @{ id = $s.appId + '/managedTrafficConfig/default'; properties = @{ trafficMode = $state.mode; endpoints = $state.endpoints } }
- if ($Arguments[2] -eq 'list') { return New-FakeResponse -Payload @($traffic) }
- return New-FakeResponse $traffic
- }
- if ($joined.StartsWith('network vnet create ')) {
- $state.resources += @(@{ id = $s.groupId + '/providers/Microsoft.Network/virtualNetworks/' + $s.vnet })
- return New-FakeResponse @{ newVNet = @{} }
- }
- if ($joined.StartsWith('network vnet subnet update ')) { return New-FakeResponse @{ properties = @{} } }
- if ($joined.StartsWith('webapp silo vnet-integration ')) {
- if ($Arguments[3] -in @('add', 'update')) { $state.subnet = Get-FakeArgument $Arguments '--subnet' }
- if ($Arguments[3] -eq 'remove') { $state.subnet = ''; return New-FakeResponse }
- return New-FakeResponse @{ properties = @{ subnetResourceId = $state.subnet } }
- }
- if ($joined.StartsWith('appservice plan silo vnet-integration list ')) { return New-FakeResponse @(@{ properties = @{ subnetResourceId = $state.subnet } }) }
- if ($joined.StartsWith('webapp last-known-good ')) {
- if ($Arguments[2] -eq 'mark') {
- $state.lkg = [int]$state.primaryGeneration.Substring(1)
- $state.revision = $state.lkg
- $state.siloGeneration = $state.primaryGeneration
- }
- $lkg = @{ properties = @{ revisionNumber = $state.lkg } }
- if ($Arguments[2] -eq 'list') { return New-FakeResponse -Payload @($lkg) }
- return New-FakeResponse $lkg
- }
- if ($joined.StartsWith('webapp silo instance list ')) { return New-FakeResponse @(@{ name = 'worker' }) }
- if ($joined.StartsWith('webapp silo config snapshot list ') -or $joined.StartsWith('webapp silo deployment list ')) { return New-FakeResponse @() }
- return New-FakeResponse @{ properties = @{}; value = @() }
- }.GetNewClosure()
- $Context.HttpExecutor = {
- param($suite, $hostname)
- return @{ runId = $ctx.Manifest.runId; generation = $(if ($hostname -eq 'primary.example.test') { $state.primaryGeneration } else { $state.siloGeneration }) }
- }.GetNewClosure()
- return $state
-}
-
-try {
- Test-Case 'command inventory and 109 API variants match source' {
- $catalog = Get-AbCommandCatalog $repo
- Assert-Test ($catalog.commands.Count -eq 104)
- Assert-Test ($catalog.variants.Count -eq 109)
- Assert-Test (@($catalog.commands.command | Select-Object -Unique).Count -eq 104)
- }
- Test-Case 'default Plan needs neither interpreter nor Azure access and creates no manifest' {
- $script = Join-Path $repo 'scripts\appservice\test_active_backup.ps1'
- $process = Invoke-AbProcess (Get-Command pwsh -CommandType Application).Source @('-NoProfile', '-File', $script,
- '-Subscription', $subscription, '-Mode', 'Plan') $repo @{} 30
- Assert-Test ($process.exitCode -eq 0) $process.stderr
- Assert-Test ($process.stdout -match 'Plan makes no Azure calls')
- }
- Test-Case 'WhatIf converts even Run into mutation-free Plan' {
- $script = Join-Path $repo 'scripts\appservice\test_active_backup.ps1'
- $process = Invoke-AbProcess (Get-Command pwsh -CommandType Application).Source @('-NoProfile', '-File', $script,
- '-Subscription', $subscription, '-Mode', 'Run', '-WhatIf') $repo @{} 30
- Assert-Test ($process.exitCode -eq 0)
- Assert-Test ($process.stdout -match 'Mode: Plan')
- }
- Test-Case 'Run requires resource and cleanup authorization before interpreter resolution' {
- $script = Join-Path $repo 'scripts\appservice\test_active_backup.ps1'
- $process = Invoke-AbProcess (Get-Command pwsh -CommandType Application).Source @('-NoProfile', '-File', $script,
- '-Subscription', $subscription, '-Mode', 'Run') $repo @{} 30
- Assert-Test ($process.exitCode -eq 4)
- }
- Test-Case 'path and region isolation' {
- Assert-Throws { Assert-AbLocalPath $repo '..\outside' } 'inside the repository'
- Assert-Throws { Assert-AbLocalPath $repo 'README.md' -RequireIgnored } 'ignored'
- Assert-Throws { New-AbContext $repo $subscription 'eastus2euap' 'eastus2euap' } 'distinct'
- Assert-Throws { New-AbContext $repo 'not-a-guid' } 'GUID'
- }
- Test-Case 'private cache ACLs do not grant access to unrelated users' {
- if (-not $IsWindows) { return }
- $c = New-TestContext
- $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
- $acl = [IO.FileSystemAclExtensions]::GetAccessControl([IO.DirectoryInfo]::new($c.AuthConfigDir),
- [Security.AccessControl.AccessControlSections]::Access)
- foreach ($rule in $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) {
- if ($rule.AccessControlType -eq 'Allow') {
- Assert-Test ($rule.IdentityReference.Value -in @($sid, 'S-1-5-18'))
- }
- }
- Test-Case 'reparse-point output paths are rejected before writing through them' {
- if (-not $IsWindows) { return }
- $c = New-TestContext
- $target = Join-Path $c.RunDirectory 'junction-target'
- $link = Join-Path $c.RunDirectory 'junction'
- [IO.Directory]::CreateDirectory($target) | Out-Null
- [IO.File]::WriteAllText((Join-Path $target 'keep.txt'), 'kept')
- try {
- New-Item -ItemType Junction -Path $link -Target $target | Out-Null
- Assert-Throws { Assert-AbLocalPath $repo (Join-Path $link 'child') -RequireIgnored } 'reparse'
- }
- finally { if (Test-Path -LiteralPath $link) { [IO.Directory]::Delete($link) } }
- Assert-Test ([IO.File]::ReadAllText((Join-Path $target 'keep.txt')) -eq 'kept')
- }
- }
- Test-Case 'per-run lock prevents another writer from changing a live manifest' {
- $c = New-TestContext
- $before = [IO.File]::ReadAllText($c.ManifestPath)
- $other = Read-AbManifest $repo $c.ManifestPath $subscription
- $contexts.Add($other)
- Assert-Throws { Initialize-AbLocalState $other -Existing }
- Assert-Test ([IO.File]::ReadAllText($c.ManifestPath) -ceq $before)
- }
- Test-Case 'recovery rejects a stale snapshot after the writer releases its lock' {
- $c = New-TestContext
- $stale = Read-AbManifest $repo $c.ManifestPath $subscription Cleanup
- $contexts.Add($stale)
- $suite = New-AbSuite $c Windows
- $suite.groupCreateAttempted = $true
- $suite.intents = @($suite.groupId, $suite.appId)
- $c.Manifest.suites = @($suite)
- $c.Manifest.pendingMutation = @{ replay = 'NoReplay'; command = 'webapp silo refresh'; resourceId = $suite.appId }
- $c.Manifest.cases = @(@{ id = 'retained-history'; status = 'Pass'; calls = @() })
- Save-AbManifest $c
- $newer = [IO.File]::ReadAllText($c.ManifestPath)
- $c.Lock.Dispose()
- $c.Lock = $null
- Assert-Throws { Initialize-AbLocalState $stale -Existing } 'changed before its lock'
- Assert-Test ([IO.File]::ReadAllText($c.ManifestPath) -ceq $newer)
- $stale.Lock.Dispose()
- $stale.Lock = $null
- $fresh = Read-AbManifest $repo $c.ManifestPath $subscription Cleanup
- $contexts.Add($fresh)
- Initialize-AbLocalState $fresh -Existing
- Assert-Test ($fresh.Manifest.suites.Count -eq 1)
- Assert-Test ($fresh.Manifest.pendingMutation.command -eq 'webapp silo refresh')
- Assert-Test ($fresh.Manifest.cases[0].id -eq 'retained-history')
- }
- Test-Case 'entrypoint collisions and rejected locks preserve existing recovery artifacts' {
- foreach ($mode in @('Preflight', 'Resume')) {
- $c = New-TestContext
- $suite = New-AbSuite $c Windows
- $suite.groupCreateAttempted = $true
- $suite.intents = @($suite.groupId, $suite.appId)
- $c.Manifest.suites = @($suite)
- $c.Manifest.pendingMutation = @{ replay = 'NoReplay'; case = 'refresh'; resourceId = $suite.appId }
- Save-AbManifest $c
- $reportPath = Join-Path $c.RunDirectory 'results.json'
- $eventsPath = Join-Path $c.RunDirectory 'events.jsonl'
- [IO.File]::WriteAllText($reportPath, '{"preserve":"report"}')
- [IO.File]::WriteAllText($eventsPath, '{"preserve":"events"}')
- $before = @{}
- foreach ($path in @($c.ManifestPath, $reportPath, $eventsPath)) {
- $before[$path] = [IO.File]::ReadAllText($path)
- }
- $arguments = @('-NoProfile', '-File', (Join-Path $repo 'scripts\appservice\test_active_backup.ps1'),
- '-Mode', $mode, '-Subscription', $subscription, '-PythonExe', $python)
- if ($mode -eq 'Preflight') {
- $arguments += @('-RunId', $c.Manifest.runId, '-OutputRoot', (Split-Path $c.RunDirectory),
- '-AuthConfigDir', $c.AuthConfigDir, '-OperatingSystem', 'Windows', '-Profile', 'Smoke')
- }
- else {
- $arguments += @('-Manifest', $c.ManifestPath, '-AllowResourceCreation', '-AllowCleanup')
- }
- $process = Invoke-AbProcess (Get-Command pwsh -CommandType Application).Source $arguments $repo @{} 60
- Assert-Test ($process.exitCode -eq 4)
- foreach ($path in $before.Keys) {
- Assert-Test ([IO.File]::ReadAllText($path) -ceq $before[$path]) "$mode changed a locked recovery artifact."
- }
- }
- }
- Test-Case 'native arguments preserve quotes and whitespace, warnings stay distinct' {
- $code = 'import json,sys; print(json.dumps(sys.argv[1:])); print("preview warning",file=sys.stderr)'
- $result = Invoke-AbProcess $python @('-c', $code, 'a path\with spaces', '{"x":"quoted value"}') $repo @{ PYTHONDONTWRITEBYTECODE = '1' } 30
- $values = ConvertFrom-Json -InputObject $result.stdout
- Assert-Test ($result.exitCode -eq 0 -and $values[0] -eq 'a path\with spaces' -and $values[1] -eq '{"x":"quoted value"}')
- Assert-Test ($result.stderr -match 'preview warning')
- }
- Test-Case 'native stdout and stderr are drained concurrently' {
- $result = Invoke-AbProcess $python @('-c', 'import sys; print("x"*100000); print("y"*100000,file=sys.stderr)') $repo @{} 30
- Assert-Test ($result.exitCode -eq 0 -and $result.stdout.Length -ge 100000 -and $result.stderr.Length -ge 100000)
- }
- Test-Case 'native timeout terminates only the launched child' {
- $result = Invoke-AbProcess $python @('-c', 'import time; time.sleep(30)') $repo @{} 1
- Assert-Test ($result.exitCode -eq 124 -and $result.timedOut)
- }
- Test-Case 'source provenance refuses installed modules and incomplete inventories' {
- $c = New-TestContext
- $provenance = @{
- modules = @{
- 'azure.cli' = Join-Path $repo 'src\azure-cli\azure\cli\__init__.py'
- 'azure.cli.core' = Join-Path $repo 'src\azure-cli-core\azure\cli\core\__init__.py'
- 'azure.cli.command_modules.appservice' = Join-Path $repo 'src\azure-cli\azure\cli\command_modules\appservice\__init__.py'
- 'azure.cli.command_modules.appservice.active_backup' = Join-Path $repo 'src\azure-cli\azure\cli\command_modules\appservice\active_backup.py'
- 'azure.cli.command_modules.appservice._active_backup_client' = Join-Path $repo 'src\azure-cli\azure\cli\command_modules\appservice\_active_backup_client.py'
- }
- apiVersion = '2026-08-01'; helpFailures = @(); commands = $c.Catalog.commands.command
- }
- Assert-AbProvenance $c $provenance
- $provenance.modules['azure.cli.core'] = Join-Path $repo 'installed\azure\cli\core\__init__.py'
- Assert-Throws { Assert-AbProvenance $c $provenance } 'provenance'
- $provenance.modules['azure.cli.core'] = Join-Path $repo 'src\azure-cli-core\azure\cli\core\__init__.py'
- $provenance.helpFailures = @('webapp silo show')
- Assert-Throws { Assert-AbProvenance $c $provenance } 'help'
- }
- if ($CliPythonExe) {
- Test-Case 'actual local CLI imports and every help entry load without Azure access' {
- $c = New-TestContext
- $c.PythonExe = Resolve-AbPython $repo $CliPythonExe
- Test-AbSource $c
- Assert-Test ($c.Manifest.provenance.commands.Count -eq 104)
- Assert-Test ($c.Manifest.provenance.helpFailures.Count -eq 0)
- }
- Test-Case 'actual local cloud metadata loads without subscription arguments or Azure access' {
- $c = New-TestContext
- $c.PythonExe = Resolve-AbPython $repo $CliPythonExe
- $cloud = (Invoke-AbAz $c @('cloud', 'show')).json
- Assert-Test ($cloud.name -eq 'AzureCloud')
- Assert-Test ($cloud.endpoints.resourceManager -eq 'https://management.azure.com/')
- }
- }
- Test-Case 'local cloud metadata does not receive resource identity arguments' {
- $c = New-TestContext -Mode Preflight
- $script:observed = @()
- $c.Executor = { param($arguments) $script:observed = $arguments; New-FakeResponse @{ name = 'AzureCloud' } }
- Invoke-AbAz $c @('cloud', 'show') | Out-Null
- Assert-Test ($script:observed -notcontains '--subscription')
- Assert-Test ($script:observed -notcontains '--ids')
- Assert-Throws { Invoke-AbAz $c @('cloud', 'show', '--subscription', $subscription) } 'Local cloud metadata'
- Assert-Throws { Invoke-AbAz $c @('cloud', 'show', '--ids', "/subscriptions/$subscription/resourceGroups/group") } 'Local cloud metadata'
- }
- Test-Case 'named subscription and ID scoping' {
- $c = New-TestContext
- $script:observed = @()
- $c.Executor = { param($arguments) $script:observed = $arguments; New-FakeResponse @{ id = 'ok' } }
- Invoke-AbAz $c @('webapp', 'silo', 'show', '-g', 'group', '-n', 'app', '--silo', 'silo') | Out-Null
- Assert-Test ($script:observed -contains '--subscription')
- $id = "/subscriptions/$subscription/resourceGroups/group/providers/Microsoft.Web/sites/app/silos/silo"
- Invoke-AbAz $c @('webapp', 'silo', 'show', '--ids', $id) | Out-Null
- Assert-Test ($script:observed -notcontains '--subscription')
- Assert-Throws { Invoke-AbAz $c @('webapp', 'silo', 'show', '--ids', $id, '--subscription', $subscription) } 'combine'
- Assert-Throws { Invoke-AbAz $c @('webapp', 'silo', 'show', '--ids', $id.Replace($subscription, '00000000-0000-0000-0000-000000000002')) } 'outside'
- Assert-Throws { Invoke-AbAz $c @('webapp', 'silo', 'show', '--ids', $id,
- $id.Replace($subscription, '00000000-0000-0000-0000-000000000002')) } 'outside'
- Assert-Throws { Invoke-AbAz $c @('account', 'show', '--subscription', $subscription, '--subscription', 'another') } 'Duplicate'
- Assert-Throws { Invoke-AbAz $c @('webapp', 'silo', 'show', ('--ids=' + $id)) } 'separate identity'
- }
- Test-Case 'preflight denies undeclared or mutating commands' {
- $c = New-TestContext -Mode Preflight
- $c.Executor = { throw 'Must not execute' }
- Assert-Throws { Invoke-AbAz $c @('group', 'create', '-n', 'unsafe') -Mutation } 'cannot mutate'
- Assert-Throws { Invoke-AbAz $c @('group', 'list') } 'undeclared'
- Assert-Throws { Invoke-AbAz $c @('account', 'show', '--debug') } 'Debug'
- }
- Test-Case 'preflight locations REST is scoped to the exact subscription, origin, method and API' {
- $c = New-TestContext -Mode Preflight
- $script:observed = @()
- $c.Executor = { param($arguments) $script:observed = $arguments; New-FakeResponse @{ value = @() } }
- $url = "/subscriptions/$subscription/locations?api-version=2022-12-01"
- Invoke-AbAz $c @('rest', '--method', 'get', '--url', $url) | Out-Null
- Assert-Test ((Get-FakeArgument $script:observed '--subscription') -eq $subscription)
- Assert-Throws { Invoke-AbAz $c @('rest', '--method', 'post', '--url', $url) } 'scoped GETs'
- Assert-Throws { Invoke-AbAz $c @('rest', '--method', 'get', '--url') } 'scoped GETs'
- Assert-Throws { Invoke-AbAz $c @('rest', '--method', 'get', '--url', ('https://example.test' + $url)) } 'origin mismatch'
- Assert-Throws { Invoke-AbAz $c @('rest', '--method', 'get', '--url',
- $url.Replace($subscription, '00000000-0000-0000-0000-000000000002')) } 'exact selected-subscription'
- Assert-Throws { Invoke-AbAz $c @('rest', '--method', 'get', '--url', $url.Replace('2022-12-01', '2026-08-01')) } 'exact selected-subscription'
- Assert-Throws { Invoke-AbAz $c @('rest', '--method', 'get', '--url',
- "/subscriptions/$subscription/resourceGroups/group?api-version=2022-12-01") } 'exact selected-subscription'
- }
- Test-Case 'no mutation can be sent without a write-ahead ownership intent' {
- $c = New-TestContext
- $c.Executor = { throw 'A mutation must not reach the executor.' }
- Assert-Throws {
- Invoke-AbAz $c @('webapp', 'silo', 'delete', '--ids',
- "/subscriptions/$subscription/resourceGroups/group/providers/Microsoft.Web/sites/app/silos/silo") `
- -Mutation -Replay Delete -IntentId "/subscriptions/$subscription/resourceGroups/group"
- } 'not journaled'
- }
- Test-Case 'read-only preflight succeeds on fake metadata' {
- $c = New-TestContext -Mode Preflight
- $suite = New-AbSuite $c Windows
- $state = Set-FakeAzure $c $suite
- Invoke-AbPreflight $c
- Assert-Test (@($c.Manifest.cases | Where-Object status -ne 'Pass').Count -eq 0)
- Assert-Test (-not $state.groupExists)
- }
- Test-Case 'missing advertised API blocks preflight without registration or allocation' {
- $c = New-TestContext -Mode Preflight
- $suite = New-AbSuite $c Windows
- $state = Set-FakeAzure $c $suite
- $original = $c.Executor
- $c.Executor = {
- param($arguments)
- if (($arguments -join ' ').StartsWith('provider show ')) {
- return New-FakeResponse @{ registrationState = 'Registered'; resourceTypes = @() }
- }
- return & $original $arguments
- }.GetNewClosure()
- Assert-Throws { Invoke-AbPreflight $c } 'does not advertise'
- Assert-Test (-not $state.groupExists)
- Assert-Test (@($state.calls | Where-Object { $_ -match 'register|group create' }).Count -eq 0)
- }
- Test-Case 'invalid JSON, expected empty output, and normal service errors fail explicitly' {
- $c = New-TestContext
- $c.Executor = { param($arguments) @{ exitCode = 0; stdout = 'not json'; stderr = 'warning'; elapsedMs = 1; timedOut = $false } }
- Assert-Throws { Invoke-AbAz $c @('webapp', 'silo', 'list') } 'expected JSON'
- Assert-Throws { Invoke-AbAz $c @('webapp', 'silo', 'refresh') -OutputShape Empty } 'empty'
- $c.Executor = { param($arguments) New-FakeResponse -ExitCode 3 -ErrorText 'ERROR: (ResourceNotFound) synthetic-secret' }
- Assert-Throws { Invoke-AbAz $c @('webapp', 'silo', 'show') } 'ResourceNotFound'
- Assert-Test (-not ([IO.File]::ReadAllText((Join-Path $c.RunDirectory 'events.jsonl'))).Contains('synthetic-secret'))
- }
- Test-Case 'successful validated mutations clear pending state for every output shape' {
- foreach ($shape in @('Empty', 'Text', 'Json')) {
- $c = New-TestContext
- $suite = New-AbSuite $c Windows
- $suite.intents = @($suite.appId)
- $c.Manifest.suites = @($suite)
- $stdout = $(if ($shape -eq 'Text') { 'accepted' } elseif ($shape -eq 'Json') { '{"accepted":true}' } else { '' })
- $c.Executor = {
- param($arguments)
- return @{ exitCode = 0; stdout = $stdout; stderr = ''; elapsedMs = 1; timedOut = $false }
- }.GetNewClosure()
- Invoke-AbAz $c @('webapp', 'silo', 'refresh', '--ids', $suite.appId) -OutputShape $shape `
- -Mutation -Replay NoReplay -IntentId $suite.appId | Out-Null
- Assert-Test ($null -eq $c.Manifest.pendingMutation) "$shape success left an uncertain mutation."
- $saved = ConvertFrom-Json -InputObject ([IO.File]::ReadAllText($c.ManifestPath)) -AsHashtable
- Assert-Test ($null -eq $saved.pendingMutation) "$shape success did not persist its completed state."
- }
- }
- Test-Case 'failed timed-out and invalid-output mutations retain uncertain state' {
- foreach ($sample in @(
- @{ shape = 'Empty'; exitCode = 1; stdout = ''; timedOut = $false },
- @{ shape = 'Empty'; exitCode = 124; stdout = ''; timedOut = $true },
- @{ shape = 'Empty'; exitCode = 0; stdout = 'unexpected output'; timedOut = $false },
- @{ shape = 'Json'; exitCode = 0; stdout = 'invalid JSON'; timedOut = $false })) {
- $c = New-TestContext
- $suite = New-AbSuite $c Windows
- $suite.intents = @($suite.appId)
- $c.Manifest.suites = @($suite)
- $c.Executor = {
- param($arguments)
- return @{ exitCode = $sample.exitCode; stdout = $sample.stdout; stderr = ''
- elapsedMs = 1; timedOut = $sample.timedOut }
- }.GetNewClosure()
- Assert-Throws {
- Invoke-AbAz $c @('webapp', 'silo', 'refresh', '--ids', $suite.appId) -OutputShape $sample.shape `
- -Mutation -Replay NoReplay -IntentId $suite.appId
- }
- Assert-Test ($c.Manifest.pendingMutation.command -eq 'webapp silo refresh')
- $saved = ConvertFrom-Json -InputObject ([IO.File]::ReadAllText($c.ManifestPath)) -AsHashtable
- Assert-Test ($saved.pendingMutation.replay -eq 'NoReplay')
- }
- }
- Test-Case 'recovery accepts only the exact generated VNet intent, not sibling resources' {
- $c = New-TestContext -Profile Core
- $suite = New-AbSuite $c Windows
- $vnetId = $suite.groupId + '/providers/Microsoft.Network/virtualNetworks/' + $suite.vnet
- $suite.intents = @($suite.groupId, $suite.planId, $suite.appId, $suite.planSiloId, $suite.siteSiloId, $vnetId)
- $c.Manifest.suites = @($suite)
- Save-AbManifest $c
- $read = Read-AbManifest $repo $c.ManifestPath $subscription Cleanup
- Assert-Test ($vnetId -in $read.Manifest.suites[0].intents)
- $suite.intents += @($vnetId + '-unrecorded')
- Save-AbManifest $c
- Assert-Throws { Read-AbManifest $repo $c.ManifestPath $subscription Cleanup } 'undeclared resource intent'
- }
- Test-Case 'manifest names and target cannot be changed to adopt other groups' {
- $c = New-TestContext
- $c.Manifest.suites = @(New-AbSuite $c Windows)
- Save-AbManifest $c
- $read = Read-AbManifest $repo $c.ManifestPath $subscription
- Assert-Test ($read.Manifest.runId -eq $c.Manifest.runId)
- $c.Manifest.suites[0].group = 'production'
- Save-AbManifest $c
- Assert-Throws { Read-AbManifest $repo $c.ManifestPath $subscription } 'changed'
- }
- Test-Case 'a manifest owned by another local operator is not adopted' {
- $c = New-TestContext
- $c.Manifest.owner = 'another-operator'
- Save-AbManifest $c
- Assert-Throws { Read-AbManifest $repo $c.ManifestPath $subscription } 'does not match'
- }
- Test-Case 'resume source fingerprints reject even uncommitted CLI changes' {
- $c = New-TestContext
- $key = @($c.Manifest.sourceHashes.Keys)[0]
- $c.Manifest.sourceHashes[$key] = 'incorrect'
- Save-AbManifest $c
- Assert-Throws { Read-AbManifest $repo $c.ManifestPath $subscription Resume } 'changed local CLI or harness source'
- }
- Test-Case 'cleanup checks actual tags before deleting anything' {
- $c = New-TestContext
- $suite = New-AbSuite $c Windows
- $suite.groupCreateAttempted = $true
- $c.Manifest.suites = @($suite)
- $script:deleted = $false
- $c.Executor = {
- param($arguments)
- if ($arguments -contains 'delete') { $script:deleted = $true }
- New-FakeResponse @{ id = 'wrong'; tags = @{} }
- }
- Assert-Throws { Remove-AbOwnedResources $c $suite } 'Cleanup did not complete'
- Assert-Test (-not $script:deleted)
- Assert-Test ((Get-AbExitCode $c) -eq 3)
- }
- Test-Case 'unexpected resources block group deletion' {
- $c = New-TestContext
- $suite = New-AbSuite $c Windows
- $suite.groupCreateAttempted = $true
- $c.Manifest.suites = @($suite)
- $state = Set-FakeAzure $c $suite
- $state.groupExists = $true
- $state.resources = @(@{ id = $suite.groupId + '/providers/Microsoft.Storage/storageAccounts/unrecorded' })
- Assert-Throws { Remove-AbOwnedResources $c $suite } 'Cleanup did not complete'
- Assert-Test ($state.groupExists)
- }
- Test-Case 'unresolved site-silo deletion prevents plan and group deletion' {
- $c = New-TestContext
- $suite = New-AbSuite $c Windows
- $suite.groupCreateAttempted = $true
- $suite.intents = @($suite.groupId, $suite.planId, $suite.appId, $suite.planSiloId, $suite.siteSiloId)
- $c.Manifest.suites = @($suite)
- $state = Set-FakeAzure $c $suite
- $state.groupExists = $true; $state.planSilo = $true; $state.siteSilo = $true
- $original = $c.Executor
- $c.Executor = {
- param($arguments)
- if (($arguments -join ' ').StartsWith('webapp silo delete ')) {
- return New-FakeResponse -ExitCode 1 -ErrorText 'ERROR: (Conflict) Synthetic deletion conflict.'
- }
- return & $original $arguments
- }.GetNewClosure()
- Assert-Throws { Remove-AbOwnedResources $c $suite } 'Cleanup did not complete'
- Assert-Test ($state.groupExists -and $state.planSilo -and $state.siteSilo)
- Assert-Test (@($state.calls | Where-Object { $_ -match '^(appservice plan silo delete|group delete)' }).Count -eq 0)
- }
- Test-Case 'silo result diagnostics retain only allowlisted identity and state metadata' {
- $c = New-TestContext
- $suite = New-AbSuite $c Windows
- $c.Manifest.suites = @($suite)
- Register-AbIntent $c $suite $suite.planSiloId
- $c.Executor = {
- param($arguments)
- New-FakeResponse @{ id = $null; status = 'Succeeded'; properties = @{ password = 'never-record-this' }
- accessToken = 'never-record-this' }
- }
- Invoke-AbAz $c @('appservice', 'plan', 'silo', 'update', '--ids', $suite.planSiloId) `
- -Mutation -Replay Idempotent -IntentId $suite.planSiloId | Out-Null
- $events = [IO.File]::ReadAllText((Join-Path $c.RunDirectory 'events.jsonl'))
- Assert-Test (-not $events.Contains('never-record-this'))
- $entry = @(($events -split '\r?\n' | Where-Object { $_ } | ForEach-Object { ConvertFrom-Json -InputObject $_ }) |
- Where-Object kind -eq 'silo-response')[0]
- Assert-Test ($entry.data.targetResourceId -eq $suite.planSiloId)
- Assert-Test ($entry.data.response.shape -eq 'object' -and $null -eq $entry.data.response.id)
- Assert-Test ($entry.data.response.status -eq 'Succeeded')
- $safe = Get-AbSiloResponseMetadata $c @{ id = $suite.planSiloId; resourceId = $suite.planSiloId
- type = 'Microsoft.Web/serverfarms/silos'; location = $c.SiloRegion
- properties = @{ provisioningState = 'Succeeded'; token = 'never-record-this' } }
- Assert-Test ($safe.id -eq $suite.planSiloId -and $safe.provisioningState -eq 'Succeeded')
- $redacted = Get-AbSiloResponseMetadata $c @{ id = 'https://example.test/?token=never-record-this'
- status = 'never-record-this'; type = 'never-record-this'; location = 'never-record-this' }
- Assert-Test (-not (ConvertTo-Json -InputObject $redacted).Contains('never-record-this'))
- Assert-Test ($redacted.id -eq '[omitted]')
- Assert-Test ((Get-AbSiloResponseMetadata $c $null).shape -eq 'null')
- Assert-Test ((Get-AbSiloResponseMetadata $c @()).shape -eq 'array')
- }
- Test-Case 'successful cleanup preserves earlier failure history in both reports' {
- $c = New-TestContext
- Assert-Throws { Invoke-AbCase $c 'win-p1v3.cleanup' { Stop-Ab 'Delete operation polling returned ResourceNotFound.' } } 'ResourceNotFound'
- Invoke-AbCase $c 'win-p1v3.cleanup' {}
- $c.Mode = 'Cleanup'
- $c.Manifest.cleanup.status = 'Complete'
- $report = Write-AbReport $c
- Assert-Test ($report.exitCode -eq 0 -and $report.caseHistory.Count -eq 2)
- Assert-Test ($report.caseHistory[0].status -eq 'Fail' -and $report.cases[0].status -eq 'Pass')
- $summary = [IO.File]::ReadAllText((Join-Path $c.RunDirectory 'summary.md'))
- Assert-Test ($summary.Contains('Previous failed attempts') -and $summary.Contains('ResourceNotFound'))
- Assert-Test ($summary.Contains('cleanup only; it does not erase earlier failures'))
- }
- Test-Case 'an empty successful create result is not mislabeled as a resource identity mismatch' {
- $c = New-TestContext
- $suite = New-AbSuite $c Windows
- Assert-Throws {
- Complete-AbSiloOperation $c $suite 'appservice plan silo' $suite.planSiloId @{ json = $null } 'C06'
- } 'returned no resource object or operation receipt'
- }
- Test-Case 'fixture archives serve Windows JSON explicitly without changing Linux content' {
- $c = New-TestContext
- foreach ($system in @('Windows', 'Linux')) {
- $suite = New-AbSuite $c $system
- $archive = [IO.Compression.ZipFile]::OpenRead((New-AbFixture $c $suite 'v1'))
- try {
- $reader = [IO.StreamReader]::new($archive.GetEntry('version.json').Open())
- try { $marker = ConvertFrom-Json -InputObject $reader.ReadToEnd() } finally { $reader.Dispose() }
- Assert-Test ($marker.runId -eq $c.Manifest.runId -and $marker.generation -eq 'v1')
- $configEntry = $archive.GetEntry('web.config')
- if ($system -eq 'Windows') {
- Assert-Test ($null -ne $configEntry)
- $reader = [IO.StreamReader]::new($configEntry.Open())
- try { $config = [xml]$reader.ReadToEnd() } finally { $reader.Dispose() }
- $staticContent = $config.configuration.'system.webServer'.staticContent
- Assert-Test ($staticContent.remove.fileExtension -eq '.json')
- Assert-Test ($staticContent.mimeMap.fileExtension -eq '.json' -and $staticContent.mimeMap.mimeType -eq 'application/json')
- }
- else { Assert-Test ($null -eq $configEntry) }
- }
- finally { $archive.Dispose() }
- }
- }
- Test-Case 'isolated origin profile skips unrelated mutations and reports immediate visibility' {
- $c = New-TestContext -Profile OriginVisibility
- $suite = New-AbSuite $c Windows
- $c.Manifest.suites = @($suite)
- $state = Set-FakeAzure $c $suite
- Invoke-AbPreflight $c
- $report = Invoke-AbRun $c
- Assert-Test ($report.exitCode -eq 0 -and $report.cleanup.status -eq 'Complete')
- Assert-Test ($suite.originVisibility.samples.Count -eq 1 -and $suite.originVisibility.firstVisibleUtc)
- Assert-Test ($suite.originVisibility.samples[0].originListed)
- Assert-Test (@($state.calls | Where-Object {
- $_ -match '^webapp (deploy|config appsettings set|silo refresh|managed-traffic update)|^network |^appservice plan silo update|--namespace Microsoft.Network'
- }).Count -eq 0)
- $summary = [IO.File]::ReadAllText((Join-Path $c.RunDirectory 'summary.md'))
- Assert-Test ($summary.Contains('Origin visibility') -and $summary.Contains('CLI exit'))
- $plan = Invoke-AbProcess (Get-Command pwsh -CommandType Application).Source @(
- '-NoProfile', '-File', (Join-Path $repo 'scripts\appservice\test_active_backup.ps1'),
- '-Mode', 'Plan', '-Subscription', $subscription, '-Profile', 'OriginVisibility', '-OperatingSystem', 'Windows') $repo @{} 30
- Assert-Test ($plan.exitCode -eq 0 -and $plan.stdout.Contains('No deployment, refresh, traffic or Core mutations'))
- }
- Test-Case 'origin polling records not-found samples before delayed visibility' {
- $c = New-TestContext -Profile OriginVisibility
- $suite = New-AbSuite $c Windows
- $c.Manifest.suites = @($suite)
- $state = Set-FakeAzure $c $suite
- $state.originReads = 0
- $original = $c.Executor
- $originId = $suite.appId + '/silos/origin'
- $c.Executor = {
- param($arguments)
- if (($arguments -join ' ').StartsWith('webapp silo show ') -and (Get-FakeArgument $arguments '--ids') -eq $originId) {
- $state.originReads++
- if ($state.originReads -lt 2) { return New-FakeResponse -ExitCode 3 -ErrorText 'ERROR: (ResourceNotFound) Missing.' }
- }
- if ($arguments[0] -eq 'rest' -and (Get-FakeArgument $arguments '--url') -like ($originId + '?*') -and $state.originReads -lt 2) {
- return New-FakeResponse -ExitCode 1 -ErrorText 'ERROR: Not Found({"Code":"ResourceNotFound"})'
- }
- return & $original $arguments
- }.GetNewClosure()
- $report = Invoke-AbRun $c
- Assert-Test ($report.exitCode -eq 0 -and $suite.originVisibility.samples.Count -eq 2)
- Assert-Test ($suite.originVisibility.samples[0].cliExitCode -eq 3 -and $suite.originVisibility.samples[0].armExitCode -eq 1)
- Assert-Test ($suite.originVisibility.samples[0].secondaryExitCode -eq 0)
- Assert-Test ($suite.originVisibility.samples[1].cliExitCode -eq 0 -and $suite.originVisibility.firstVisibleUtc)
- }
- Test-Case 'persistent origin not-found times out without a fabricated pass and still cleans up' {
- $c = New-TestContext -Profile OriginVisibility
- $c.Manifest.options.observationTimeoutSeconds = 1
- $suite = New-AbSuite $c Windows
- $c.Manifest.suites = @($suite)
- $state = Set-FakeAzure $c $suite
- $original = $c.Executor
- $originId = $suite.appId + '/silos/origin'
- $c.Executor = {
- param($arguments)
- if (($arguments -join ' ').StartsWith('webapp silo show ') -and (Get-FakeArgument $arguments '--ids') -eq $originId) {
- return New-FakeResponse -ExitCode 3 -ErrorText 'ERROR: (ResourceNotFound) Missing.'
- }
- if ($arguments[0] -eq 'rest' -and (Get-FakeArgument $arguments '--url') -like ($originId + '?*')) {
- return New-FakeResponse -ExitCode 1 -ErrorText 'ERROR: Not Found({"Code":"ResourceNotFound"})'
- }
- return & $original $arguments
- }.GetNewClosure()
- $report = Invoke-AbRun $c
- Assert-Test ($report.exitCode -eq 1 -and $report.cleanup.status -eq 'Complete')
- Assert-Test ($null -eq $suite.originVisibility.firstVisibleUtc -and $suite.originVisibility.samples.Count -ge 1)
- Assert-Test (-not $state.groupExists)
- }
- Test-Case 'CLI origin collection compatibility never makes the raw ARM visibility probe pass' {
- $c = New-TestContext -Profile OriginVisibility
- $c.Manifest.options.observationTimeoutSeconds = 1
- $suite = New-AbSuite $c Windows
- $c.Manifest.suites = @($suite)
- $state = Set-FakeAzure $c $suite
- $original = $c.Executor
- $originId = $suite.appId + '/silos/origin'
- $c.Executor = {
- param($arguments)
- if (($arguments -join ' ').StartsWith('webapp silo show ') -and (Get-FakeArgument $arguments '--ids') -eq $originId) {
- $r = & $original $arguments
- $r.stderr = 'WARNING: Origin point GET returned HTTP 404; using the matching origin from the silo collection.'
- return $r
- }
- if ($arguments[0] -eq 'rest' -and (Get-FakeArgument $arguments '--url') -like ($originId + '?*')) {
- return New-FakeResponse -ExitCode 1 -ErrorText 'ERROR: Not Found({"Code":"ResourceNotFound"})'
- }
- return & $original $arguments
- }.GetNewClosure()
- $report = Invoke-AbRun $c
- Assert-Test ($report.exitCode -eq 1 -and $report.cleanup.status -eq 'Complete')
- Assert-Test ($null -eq $suite.originVisibility.firstVisibleUtc)
- Assert-Test ($suite.originVisibility.samples[0].cliExitCode -eq 0 -and $suite.originVisibility.samples[0].armExitCode -eq 1)
- Assert-Test (@($report.caseHistory.calls | Where-Object originCollectionFallback -eq $true).Count -ge 1)
- Assert-Test ([IO.File]::ReadAllText((Join-Path $c.RunDirectory 'events.jsonl')).Contains('"originCollectionFallback":true'))
- }
- Test-Case 'origin probe does not retry authorization failures or accept a different resource' {
- foreach ($badResponse in @(
- (New-FakeResponse -ExitCode 1 -ErrorText 'ERROR: (Forbidden) Denied.'),
- (New-FakeResponse @{ id = 'wrong-resource' }))) {
- $c = New-TestContext -Profile OriginVisibility
- $suite = New-AbSuite $c Windows
- $c.Manifest.suites = @($suite)
- $state = Set-FakeAzure $c $suite
- $original = $c.Executor
- $originId = $suite.appId + '/silos/origin'
- $c.Executor = {
- param($arguments)
- if (($arguments -join ' ').StartsWith('webapp silo show ') -and (Get-FakeArgument $arguments '--ids') -eq $originId) {
- return $badResponse
- }
- return & $original $arguments
- }.GetNewClosure()
- $report = Invoke-AbRun $c
- Assert-Test ($report.exitCode -eq 1 -and $report.cleanup.status -eq 'Complete')
- Assert-Test ($null -eq $suite.originVisibility.firstVisibleUtc -and -not $state.groupExists)
- }
- }
- Test-Case 'Smoke fake flow validates markers, async receipts and cleanup order' {
- $c = New-TestContext
- $suite = New-AbSuite $c Windows
- $c.Manifest.suites = @($suite)
- $state = Set-FakeAzure $c $suite
- $report = Invoke-AbRun $c
- Assert-Test ($report.exitCode -eq 0)
- Assert-Test ($report.cleanup.status -eq 'Complete' -and -not $state.groupExists)
- $deleteCalls = @($state.calls | Where-Object { $_ -match '^(webapp silo delete|appservice plan silo delete|group delete)' })
- Assert-Test ($deleteCalls[0].StartsWith('webapp silo delete')) ('Fake cleanup ordering: ' + ($deleteCalls -join '; '))
- Assert-Test ($deleteCalls[-1].StartsWith('group delete'))
- Assert-Test (-not $report.coverage.exhaustiveLiveCoverage)
- $evidence = [IO.File]::ReadAllText((Join-Path $c.RunDirectory 'results.json')) + [IO.File]::ReadAllText((Join-Path $c.RunDirectory 'events.jsonl'))
- Assert-Test (-not $evidence.Contains('synthetic-do-not-persist'))
- }
- Test-Case 'snapshot point reads use returned identifiers rather than a resource-type name' {
- foreach ($useProperty in @($true, $false)) {
- $c = New-TestContext -Profile Core
- $suite = New-AbSuite $c Windows
- $c.Manifest.suites = @($suite)
- $state = Set-FakeAzure $c $suite
- $state.snapshotIds = @()
- $entry = @{ id = ($suite.siteSiloId + '/config/snapshot/22190800')
- name = 'Microsoft.Web/sites/config/snapshot'; properties = @{} }
- if ($useProperty) { $entry.id = '22190800'; $entry.properties.snapshotId = 22190800 }
- $original = $c.Executor
- $c.Executor = {
- param($arguments)
- $joined = $arguments -join ' '
- if ($joined.StartsWith('webapp silo config snapshot list ')) {
- return New-FakeResponse @{ value = @($entry) }
- }
- if ($joined.StartsWith('webapp silo config snapshot show ')) {
- $state.snapshotIds += @(Get-FakeArgument $arguments '--snapshot-id')
- return New-FakeResponse @{ properties = @{} }
- }
- return & $original $arguments
- }.GetNewClosure()
- Invoke-AbSafeReads $c $suite
- Assert-Test (@($c.Manifest.cases | Where-Object { $_.id -like '*.snapshot-point' -and $_.status -eq 'Pass' }).Count -eq 1)
- Assert-Test ($state.snapshotIds.Count -eq 2 -and @($state.snapshotIds | Where-Object { $_ -ne '22190800' }).Count -eq 0)
- }
- }
- Test-Case 'Core fake flow checks live behavior without contacting Azure' {
- $c = New-TestContext -Profile Core
- $suite = New-AbSuite $c Windows
- $c.Manifest.suites = @($suite)
- $state = Set-FakeAzure $c $suite
- $report = Invoke-AbRun $c
- Assert-Test ($report.exitCode -eq 0) ("Core cases: " + (ConvertTo-Json -InputObject @($c.Manifest.cases | Where-Object status -eq 'Fail') -Depth 10))
- Assert-Test ($state.primaryGeneration -eq 'v3' -and $state.siloGeneration -eq 'v3')
- Assert-Test ($state.origin -eq 'Enabled' -and -not $state.groupExists)
- }
- Test-Case 'a primary hostname returned as a silo cannot produce a false green run' {
- $c = New-TestContext
- $suite = New-AbSuite $c Windows
- $c.Manifest.suites = @($suite)
- $state = Set-FakeAzure $c $suite
- $original = $c.Executor
- $c.Executor = {
- param($arguments)
- $result = & $original $arguments
- if (($arguments -join ' ').StartsWith('webapp silo show ') -and $result.exitCode -eq 0) {
- $wrong = ConvertFrom-Json -InputObject $result.stdout -AsHashtable
- $wrong.properties.defaultHostName = 'primary.example.test'
- return New-FakeResponse $wrong
- }
- return $result
- }.GetNewClosure()
- $report = Invoke-AbRun $c
- Assert-Test ($report.exitCode -eq 1 -and $report.cleanup.status -eq 'Complete')
- Assert-Test (@($report.cases | Where-Object { $_.id -like '*.site-read' -and $_.status -eq 'Fail' }).Count -eq 1)
- Assert-Test (-not $state.groupExists)
- }
- Test-Case 'service flavor rejection is an explicit environment blocker, never a fallback' {
- $c = New-TestContext
- $c.OperatingSystem = 'Linux'; $c.Manifest.operatingSystem = 'Linux'
- $suite = New-AbSuite $c Linux
- $c.Manifest.suites = @($suite)
- $state = Set-FakeAzure $c $suite
- $original = $c.Executor
- $c.Executor = {
- param($arguments)
- if (($arguments -join ' ').StartsWith('appservice plan silo create ')) {
- return New-FakeResponse -ExitCode 1 -ErrorText "ERROR: (BadRequest) The base App Service plan app flavor 'Linux' is not supported for Active Backup silos."
- }
- return & $original $arguments
- }.GetNewClosure()
- $report = Invoke-AbRun $c
- Assert-Test ($report.exitCode -eq 2 -and $report.cleanup.status -eq 'Complete')
- Assert-Test (@($report.cases | Where-Object status -eq 'BlockedEnvironment').Count -eq 1)
- Assert-Test (-not $state.groupExists)
- Assert-Test (@($state.calls | Where-Object { $_ -match 'webapp silo create' }).Count -eq 0)
- }
- Test-Case 'Windows and Linux suites clean up serially before the next allocation' {
- $c = New-TestContext
- $c.OperatingSystem = 'Both'; $c.Manifest.operatingSystem = 'Both'
- $win = New-AbSuite $c Windows
- $linux = New-AbSuite $c Linux
- $c.Manifest.suites = @($win, $linux)
- $winState = Set-FakeAzure $c $win
- $winExecutor = $c.Executor
- $winHttp = $c.HttpExecutor
- $linuxState = Set-FakeAzure $c $linux
- $linuxExecutor = $c.Executor
- $linuxHttp = $c.HttpExecutor
- $c.Executor = {
- param($arguments)
- if (($arguments -join ' ').Contains($linux.group)) {
- if ($winState.groupExists) { throw 'Linux started before Windows cleanup.' }
- return & $linuxExecutor $arguments
- }
- return & $winExecutor $arguments
- }.GetNewClosure()
- $c.HttpExecutor = {
- param($suite, $hostname)
- if ($suite.key -eq $linux.key) { return & $linuxHttp $suite $hostname }
- return & $winHttp $suite $hostname
- }.GetNewClosure()
- $report = Invoke-AbRun $c
- Assert-Test ($report.exitCode -eq 0 -and -not $winState.groupExists -and -not $linuxState.groupExists)
- Assert-Test (@($report.suites | Where-Object cleanupComplete).Count -eq 2)
- Assert-Test (@($linuxState.calls | Where-Object { $_ -match 'webapp create.+--runtime PYTHON:3.12.+--startup-file' }).Count -eq 1)
- }
- Test-Case 'Extended zone testing honors the explicit two-worker envelope and restores it' {
- $c = New-TestContext -Profile Extended
- $c.Manifest.options.includeZoneRedundancy = $true
- $suite = New-AbSuite $c Windows
- $c.Manifest.suites = @($suite)
- $state = Set-FakeAzure $c $suite
- $report = Invoke-AbRun $c
- Assert-Test ($report.exitCode -eq 0)
- Assert-Test ($state.workers -eq 1 -and -not $state.zone -and -not $state.groupExists)
- Assert-Test (@($c.Manifest.cases | Where-Object { $_.id -like '*.zone-redundancy' -and $_.status -eq 'Pass' }).Count -eq 1)
- }
- Test-Case 'explicit SKU variants remain serial and clean every prior pair' {
- $c = New-TestContext
- $c.Manifest.options.includeSkuMatrix = $true
- $fixtures = @{}
- foreach ($sku in @('P1V3', 'P1MV3', 'P1V4', 'P1MV4')) {
- $suite = New-AbSuite $c Windows $sku
- $c.Manifest.suites += @($suite)
- $state = Set-FakeAzure $c $suite
- $fixtures[$suite.key] = @{ suite = $suite; state = $state; executor = $c.Executor; http = $c.HttpExecutor }
- }
- $c.Executor = {
- param($arguments)
- $joined = $arguments -join ' '
- $selected = @($fixtures.Values | Where-Object { $joined.Contains($_.suite.group) })
- if ($selected.Count -ne 1) { throw 'The fake command did not select exactly one SKU fixture.' }
- if ($joined.StartsWith('group create ')) {
- if (@($fixtures.Values | Where-Object { $_.state.groupExists }).Count) { throw 'A new SKU started before previous cleanup.' }
- }
- return & $selected[0].executor $arguments
- }.GetNewClosure()
- $c.HttpExecutor = {
- param($suite, $hostname)
- return & $fixtures[$suite.key].http $suite $hostname
- }.GetNewClosure()
- $report = Invoke-AbRun $c
- Assert-Test ($report.exitCode -eq 0 -and $report.suites.Count -eq 4)
- Assert-Test (@($fixtures.Values | Where-Object { $_.state.groupExists }).Count -eq 0)
- }
- Test-Case 'resume refuses an uncertain non-idempotent action' {
- $c = New-TestContext -Mode Resume
- $c.Manifest.pendingMutation = @{ replay = 'NoReplay'; case = 'refresh'; resourceId = ''; command = 'webapp silo refresh' }
- Assert-Throws { Invoke-AbRun $c } 'not replayed'
- }
- Test-Case 'resume validates all existing groups before mutation with an empty pending journal' {
- foreach ($missing in @($false, $true)) {
- $c = New-TestContext -Mode Resume
- $suite = New-AbSuite $c Windows
- $suite.groupCreateAttempted = $true
- $suite.intents = @($suite.groupId, $suite.planId, $suite.appId)
- $c.Manifest.suites = @($suite)
- $c.Manifest.cases = @(@{
- id = $suite.key + '.primary'; status = 'Pass'; required = $true
- evidenceKind = 'Behavioral'; calls = @(); reason = ''
- })
- $calls = [Collections.Generic.List[string]]::new()
- $c.Executor = {
- param($arguments)
- $calls.Add(($arguments -join ' '))
- if (($arguments -join ' ').StartsWith('group show ')) {
- if ($missing) { return New-FakeResponse -ExitCode 3 }
- return New-FakeResponse @{ id = $suite.groupId; tags = @{ abcliRunId = 'foreign-run' } }
- }
- throw 'Resume sent a command before validating group ownership.'
- }.GetNewClosure()
- Assert-Test ($null -eq $c.Manifest.pendingMutation)
- Assert-Throws { Invoke-AbRun $c }
- Assert-Test ($calls.Count -eq 1 -and $calls[0].StartsWith('group show '))
- }
- }
- Test-Case 'resume accepts a completed empty refresh without replaying it' {
- $c = New-TestContext
- $suite = New-AbSuite $c Windows
- $c.Manifest.suites = @($suite)
- $state = Set-FakeAzure $c $suite
- Invoke-AbSmoke $c $suite
- Assert-Test ($null -eq $c.Manifest.pendingMutation)
- $c.Mode = 'Resume'
- $report = Invoke-AbRun $c
- Assert-Test ($report.exitCode -eq 0)
- Assert-Test (@($state.calls | Where-Object { $_.StartsWith('webapp silo refresh ') }).Count -eq 1)
- }
- Test-Case 'resume never repeats a successful action in a later-failed case' {
- $c = New-TestContext -Mode Resume
- $c.Manifest.cases = @(@{
- id = 'refresh'; required = $true; status = 'Fail'; evidenceKind = 'Behavioral'
- calls = @(@{ command = 'webapp silo refresh'; exitCode = 0; apiKeys = @('C14'); mutationReplay = 'NoReplay' })
- reason = 'later assertion failed'
- })
- Assert-Throws { Invoke-AbRun $c } 'not replayed'
- }
- Test-Case 'resume reconciles failed creates without repeating passed deployment actions' {
- $c = New-TestContext
- $suite = New-AbSuite $c Windows
- $c.Manifest.suites = @($suite)
- $state = Set-FakeAzure $c $suite
- $original = $c.Executor
- $gate = @{ reject = $true }
- $c.Executor = {
- param($arguments)
- if (($arguments -join ' ').StartsWith('webapp silo create ') -and $gate.reject) {
- return New-FakeResponse -ExitCode 1 -ErrorText 'ERROR: (TemporaryFailure) Synthetic create failure.'
- }
- return & $original $arguments
- }.GetNewClosure()
- $retained = Invoke-AbRun $c -KeepResourcesOnFailure
- Assert-Test ($retained.exitCode -eq 3 -and $state.groupExists)
- $deployments = @($state.calls | Where-Object { $_.StartsWith('webapp deploy ') }).Count
- $gate.reject = $false
- $c.Mode = 'Resume'
- $resumed = Invoke-AbRun $c
- Assert-Test ($resumed.exitCode -eq 0 -and -not $state.groupExists)
- Assert-Test (@($state.calls | Where-Object { $_.StartsWith('webapp deploy ') }).Count -eq $deployments)
- Assert-Test ($resumed.caseHistoryCount -gt $resumed.cases.Count)
- }
- Test-Case 'completed cleanup returns success without erasing original test failures' {
- $c = New-TestContext
- $c.Manifest.cases = @(@{ id = 'failure'; status = 'Fail'; required = $true; evidenceKind = 'Behavioral'; calls = @(); reason = 'test' })
- $c.Mode = 'Cleanup'
- $c.Manifest.cleanup.status = 'Complete'
- $report = Write-AbReport $c
- Assert-Test ($report.exitCode -eq 0 -and @($report.cases | Where-Object status -eq 'Fail').Count -eq 1)
- }
- Test-Case 'cleanup failure has precedence over a test failure' {
- $c = New-TestContext
- $c.Manifest.cases = @(@{ id = 'failure'; status = 'Fail'; required = $true; evidenceKind = 'Behavioral'; calls = @(); reason = 'test' })
- $c.Manifest.cleanup.status = 'Failed'
- Assert-Test ((Get-AbExitCode $c) -eq 3)
- $report = Write-AbReport $c
- Assert-Test ($report.exitCode -eq 3 -and -not $report.coverage.exhaustiveLiveCoverage)
- }
-}
-finally {
- foreach ($context in $contexts) { if ($context.Lock) { $context.Lock.Dispose() } }
- if (Test-Path -LiteralPath $testRoot) {
- $verified = Assert-AbLocalPath $repo $testRoot -RequireIgnored
- if ([IO.Path]::GetFileName($verified) -ne $testId) { throw 'Test cleanup ownership mismatch.' }
- Remove-Item -LiteralPath $verified -Recurse -Force
- }
-}
-
-Write-Host "Offline harness tests: $script:passed passed; $script:failed failed. No Azure requests were made."
-if ($script:failed) { exit 1 }
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_client.py b/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_client.py
deleted file mode 100644
index 10abeb30899..00000000000
--- a/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_client.py
+++ /dev/null
@@ -1,375 +0,0 @@
-# --------------------------------------------------------------------------------------------
-# Copyright (c) Microsoft Corporation. All rights reserved.
-# Licensed under the MIT License. See License.txt in the project root for license information.
-# --------------------------------------------------------------------------------------------
-
-import codecs
-from contextlib import contextmanager
-from datetime import datetime, timezone
-from email.utils import parsedate_to_datetime
-import json
-import math
-import os
-import sys
-import tempfile
-import time
-from urllib.parse import parse_qsl, quote, unquote, urlencode, urljoin, urlsplit, urlunsplit
-import uuid
-
-import requests
-from knack.log import get_logger
-
-from azure.cli.core._profile import Profile
-from azure.cli.core.azclierror import (AzureConnectionError, AzureInternalError, AzureResponseError, FileOperationError,
- InvalidArgumentValueError, ResourceNotFoundError, UnclassifiedUserFault)
-from azure.cli.core.commands.client_factory import get_subscription_id
-from azure.cli.core.sdk.policies import get_custom_hook_policy
-from azure.cli.core.util import get_az_user_agent, get_error_type_by_status_code, should_disable_connection_verify
-
-
-API_VERSION = '2026-08-01'
-DEFAULT_TIMEOUT = 7200
-_RUNNING = {'accepted', 'created', 'inprogress', 'running', 'waiting'}
-_FAILED = {'failed', 'cancelled', 'canceled', 'timedout'}
-logger = get_logger(__name__)
-
-
-def segment(value):
- if not isinstance(value, str) or not value or value in ('.', '..') or any(
- char in value for char in ('/', '\\', '\r', '\n', '\x00')):
- raise InvalidArgumentValueError('Provide a non-empty resource name or identifier without path separators.')
- return quote(value, safe='')
-
-
-def load_json(value, expected_type=dict):
- if expected_type is None and isinstance(value, (dict, list, int, float, bool)):
- return value
- if expected_type is not None and isinstance(value, expected_type):
- return value
- try:
- if isinstance(value, str) and value.startswith('@'):
- with open(value[1:], encoding='utf-8-sig') as handle:
- result = json.load(handle)
- else:
- result = json.loads(value)
- except OSError as ex:
- raise FileOperationError('Unable to read the JSON input file: {}.'.format(ex.strerror)) from ex
- except (TypeError, ValueError) as ex:
- raise InvalidArgumentValueError('Provide valid JSON or an @file containing JSON.') from ex
- if expected_type is not None and not isinstance(result, expected_type):
- raise InvalidArgumentValueError(
- 'Expected a JSON {}.'.format('array' if expected_type is list else 'object'))
- return result
-
-
-def _origin(url):
- parsed = urlsplit(url)
- try:
- return parsed.scheme.lower(), parsed.hostname, 443 if parsed.port is None else parsed.port
- except ValueError as ex:
- raise AzureResponseError('Invalid ARM request address.') from ex
-
-
-def _service_error(response):
- try:
- payload = response.json()
- except ValueError:
- payload = {}
- detail = payload.get('error', payload.get('Error', payload)) if isinstance(payload, dict) else {}
- code = detail.get('code', detail.get('Code')) if isinstance(detail, dict) else None
- message = detail.get('message', detail.get('Message')) if isinstance(detail, dict) else None
- if isinstance(message, dict):
- message = message.get('value')
- message = message if isinstance(message, str) else 'The ARM request failed (HTTP {}).'.format(response.status_code)
- if code:
- message = '({}) {}'.format(code, message)
- request_id = response.headers.get('x-ms-request-id')
- if request_id:
- message += ' Request ID: {}.'.format(request_id)
- error_type = get_error_type_by_status_code(str(response.status_code))
- error = error_type(message)
- error.response = response
- return error
-
-
-def _retry_delay(headers):
- retry = headers.get('Retry-After', '5')
- try:
- delay = float(retry)
- except ValueError:
- try:
- delay = (parsedate_to_datetime(retry) - datetime.now(timezone.utc)).total_seconds()
- except (TypeError, ValueError, OverflowError):
- delay = 5
- return max(delay, 0.1) if math.isfinite(delay) else 5
-
-
-class ActiveBackupClient:
- """ARM-only transport for the silo APIs absent from the Web SDK."""
-
- def __init__(self, cli_ctx, resource_group_name, name, resource_type='sites', subscription=None):
- self.cli_ctx = cli_ctx
- self._resource_type = resource_type
- self._origin_fallback_warned = False
- self.subscription = subscription or get_subscription_id(cli_ctx)
- self.endpoint = cli_ctx.cloud.endpoints.resource_manager.rstrip('/') + '/'
- self.parent = '/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/{}/{}'.format(
- segment(self.subscription), segment(resource_group_name), resource_type, segment(name))
-
- def resource(self, silo=None):
- return self.parent + '/silos' + ('/' + segment(silo) if silo is not None else '')
-
- def operation_url(self, silo, operation_id):
- """Select the CRUD status response rather than the final-resource response."""
- return self.url(self.resource(silo) + '/operationresults/' + segment(operation_id),
- {'azureAsyncOperation': 1})
-
- def url(self, path, query=None):
- try:
- url = urljoin(self.endpoint, path)
- parsed = urlsplit(url)
- except ValueError as ex:
- raise AzureResponseError('The service returned an invalid ARM address.') from ex
- if (parsed.scheme != 'https' or parsed.username or parsed.password or parsed.fragment or
- _origin(url) != _origin(self.endpoint)):
- raise AzureResponseError('The service returned an address outside the configured ARM endpoint.')
- parameters = list(parse_qsl(parsed.query, keep_blank_values=True))
- if not any(key.lower() == 'api-version' for key, _ in parameters):
- parameters.append(('api-version', API_VERSION))
- for key, value in (query or {}).items():
- values = value if isinstance(value, (list, tuple)) else [value]
- for item in values:
- if item is not None:
- parameters.append((key, str(item).lower() if isinstance(item, bool) else str(item)))
- return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(parameters), ''))
-
- @contextmanager
- def _response(self, method, path, body=None, query=None, timeout=60, stream=False, content_type=None):
- url = self.url(path, query)
- token_type, token, _ = Profile(cli_ctx=self.cli_ctx).get_raw_token(
- resource=self.cli_ctx.cloud.endpoints.active_directory_resource_id,
- subscription=self.subscription)[0]
- headers = {
- 'Authorization': '{} {}'.format(token_type, token),
- 'User-Agent': get_az_user_agent(),
- 'x-ms-client-request-id': str(uuid.uuid4()),
- 'CommandName': self.cli_ctx.data.get('command', ''),
- }
- if body is not None:
- headers['Content-Type'] = content_type or 'application/json'
- if (not content_type or 'json' in content_type.lower()) and not isinstance(body, bytes):
- body = json.dumps(body)
- policy = get_custom_hook_policy(self.cli_ctx)
- if policy is not None:
- from azure.core.pipeline import PipelineContext, PipelineRequest
- from azure.core.pipeline.transport import HttpRequest
- policy_request = HttpRequest(method, url, headers=headers, data=body)
- policy.on_request(PipelineRequest(policy_request, PipelineContext(None)))
- headers = dict(policy_request.headers)
- # Do not log bodies or URLs: these operations can return secrets or accept SAS queries.
- try:
- with requests.Session() as session:
- with session.request(
- method, url, data=body, headers=headers, stream=stream, allow_redirects=False,
- timeout=(min(10, timeout), timeout),
- verify=not should_disable_connection_verify()) as response:
- if not 200 <= response.status_code < 300:
- raise _service_error(response)
- yield response
- except requests.RequestException as ex:
- raise AzureConnectionError(
- 'The Active Backup ARM request could not complete ({}).'.format(type(ex).__name__),
- recommendation='Check network connectivity and the configured cloud/TLS settings.') from None
-
- def request(self, method, path, body=None, query=None, timeout=60, content_type=None):
- with self._response(method, path, body, query, timeout, content_type=content_type) as response:
- return response
-
- @staticmethod
- def decode(response):
- if response.status_code == 204 or not response.content:
- return None
- content_type = response.headers.get('Content-Type', '').lower()
- if 'json' in content_type:
- try:
- return response.json()
- except ValueError as ex:
- raise AzureResponseError('ARM returned an invalid JSON response.') from ex
- if 'octet-stream' in content_type or 'zip' in content_type:
- raise InvalidArgumentValueError('Use --file to save the binary response.')
- if content_type.startswith('text/') or 'xml' in content_type:
- return {'content': response.text}
- try:
- return response.json()
- except ValueError as ex:
- raise AzureResponseError('ARM returned an unsupported response content type. Use --file.') from ex
-
- def get_resource(self, path):
- try:
- return self.decode(self.request('GET', path))
- except ResourceNotFoundError:
- if (self._resource_type != 'sites' or
- unquote(path).casefold() != unquote(self.resource('origin')).casefold()):
- raise
- matches = [
- resource for resource in self.list(self.resource())
- if isinstance(resource, dict) and isinstance(resource.get('id'), str) and
- unquote(resource['id']).casefold() == unquote(path).casefold()
- ]
- if not matches:
- raise
- if len(matches) != 1:
- raise AzureResponseError('ARM returned duplicate origin resources in the silo collection.')
- resource = matches[0]
- resource_type = resource.get('type')
- if (not isinstance(resource_type, str) or resource_type.casefold() != 'microsoft.web/sites/silos' or
- not isinstance(resource.get('properties'), dict)):
- raise AzureResponseError('ARM returned an invalid origin resource in the silo collection.')
- if not self._origin_fallback_warned:
- logger.warning(
- 'Origin point GET returned HTTP 404; using the matching origin from the silo collection. '
- 'Stamp-derived IP fields may be absent.')
- self._origin_fallback_warned = True
- return resource
-
- def list(self, path, query=None):
- result = []
- visited = set()
- while path:
- address = self.url(path, query)
- if address in visited:
- raise AzureResponseError('ARM returned a repeated pagination link.')
- visited.add(address)
- payload = self.decode(self.request('GET', address))
- if isinstance(payload, list):
- result.extend(payload)
- break
- if not isinstance(payload, dict) or not isinstance(payload.get('value'), list):
- raise AzureResponseError('ARM did not return a resource collection.')
- result.extend(payload['value'])
- path = payload.get('nextLink')
- if path is not None and not isinstance(path, str):
- raise AzureResponseError('ARM returned an invalid pagination link.')
- query = None
- return result
-
- def operation(self, path, response=None):
- response = response if response is not None else self.request('GET', path)
- payload = self.decode(response)
- state = payload.get('status') if isinstance(payload, dict) else None
- if isinstance(state, str) and state.lower() in _FAILED:
- detail = payload.get('error') or {}
- if not isinstance(detail, dict):
- detail = {}
- raise AzureResponseError('({}) {}'.format(
- detail.get('code', state), detail.get('message', 'The Active Backup operation failed.')))
- if isinstance(state, str) and state.lower() in _RUNNING:
- state = 'InProgress'
- elif response.status_code == 202:
- state = 'InProgress'
- elif state and str(state).lower() != 'succeeded':
- raise AzureResponseError('ARM returned an unrecognized operation status: {}.'.format(state))
- else:
- state = 'Succeeded'
- result = {
- 'operationId': urlsplit(path).path.rstrip('/').split('/')[-1],
- 'operationUrl': self.url(path),
- 'status': state,
- }
- if (isinstance(payload, dict) and isinstance(payload.get('id'), str) and payload['id'].strip() and
- not payload.get('status')):
- result['resource'] = payload
- return result
-
- def wait(self, path, timeout=DEFAULT_TIMEOUT, initial=None):
- if timeout <= 0:
- raise InvalidArgumentValueError('--timeout must be greater than zero.')
- deadline = time.monotonic() + timeout
- response = initial
- read_retries = 0
- while True:
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- raise AzureResponseError(
- 'Timed out waiting for operation {}. The service operation was not cancelled.'.format(
- urlsplit(path).path.rstrip('/').split('/')[-1]))
- if response is None:
- try:
- response = self.request('GET', path, timeout=min(60, remaining))
- read_retries = 0
- except (ResourceNotFoundError, AzureResponseError, AzureInternalError, UnclassifiedUserFault) as ex:
- failed_response = getattr(ex, 'response', None)
- status = getattr(failed_response, 'status_code', None)
- known_pending = isinstance(ex, ResourceNotFoundError) and initial is not None
- read_retries += 1
- if (not known_pending and status not in (429, 500, 502, 503, 504)) or read_retries > 3:
- raise
- delay = _retry_delay(failed_response.headers if failed_response is not None else {})
- time.sleep(min(delay, max(0, deadline - time.monotonic())))
- continue
- operation = self.operation(path, response)
- if operation['status'] == 'Succeeded':
- return operation
- delay = _retry_delay(response.headers)
- time.sleep(min(max(delay, 0.1), max(0, deadline - time.monotonic())))
- response = None
-
- def mutate(self, method, path, body=None, no_wait=False, timeout=DEFAULT_TIMEOUT, query=None):
- if timeout <= 0:
- raise InvalidArgumentValueError('--timeout must be greater than zero.')
- response = self.request(method, path, body, query)
- if response.status_code != 202:
- return None if method == 'DELETE' else self.decode(response)
- location = response.headers.get('Azure-AsyncOperation') or response.headers.get('Location')
- if not location:
- raise AzureResponseError('ARM accepted the operation without returning a polling address.')
- location = self.url(location)
- if no_wait:
- return {
- 'resourceId': path,
- 'operationId': urlsplit(location).path.rstrip('/').split('/')[-1],
- 'operationUrl': location,
- 'status': 'Accepted',
- }
- operation = self.wait(location, timeout, initial=response)
- if method in ('PUT', 'PATCH'):
- return self.get_resource(path)
- if method == 'DELETE':
- return None
- return operation.get('resource')
-
- def consume(self, method, path, filename=None, overwrite=False, query=None, body=None,
- content_type=None, tail=False):
- if filename is not None and not filename:
- raise InvalidArgumentValueError('--file must be a non-empty destination path.')
- destination = os.path.abspath(filename) if filename else None
- temporary = None
- if destination and os.path.exists(destination) and not overwrite:
- raise FileOperationError('The destination exists. Use --overwrite to replace it.')
- try:
- with self._response(method, path, body, query, stream=True, content_type=content_type) as response:
- if destination:
- descriptor, temporary = tempfile.mkstemp(dir=os.path.dirname(destination))
- with os.fdopen(descriptor, 'wb') as handle:
- for chunk in response.iter_content(chunk_size=65536):
- handle.write(chunk)
- if overwrite:
- os.replace(temporary, destination)
- temporary = None
- else:
- os.link(temporary, destination)
- return {'file': destination}
- if not tail:
- raise InvalidArgumentValueError('Provide --file for this download.')
- decoder = codecs.getincrementaldecoder('utf-8')(errors='replace')
- for chunk in response.iter_content(chunk_size=1024):
- sys.stdout.write(decoder.decode(chunk))
- sys.stdout.flush()
- sys.stdout.write(decoder.decode(b'', final=True))
- sys.stdout.flush()
- return None
- except OSError as ex:
- raise FileOperationError('Unable to save the response: {}.'.format(ex.strerror)) from ex
- finally:
- if temporary is not None:
- os.unlink(temporary)
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py b/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py
deleted file mode 100644
index d2b78e53758..00000000000
--- a/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py
+++ /dev/null
@@ -1,861 +0,0 @@
-# --------------------------------------------------------------------------------------------
-# Copyright (c) Microsoft Corporation. All rights reserved.
-# Licensed under the MIT License. See License.txt in the project root for license information.
-# --------------------------------------------------------------------------------------------
-
-import inspect
-
-
-def validate_identity(cmd, namespace):
- from azure.cli.core.azclierror import InvalidArgumentValueError, MutuallyExclusiveArgumentError
- from azure.mgmt.core.tools import parse_resource_id
- identifiers = getattr(namespace, '_ids', None)
- if not identifiers:
- return
- parser = cmd.cli_ctx.invocation.parser.subparser_map[cmd.name]
- specified = set(parser.specified_arguments)
- # Knack omits private destinations; argparse has already disambiguated accepted long-option prefixes.
- if any(option.startswith('--') and '--subscription'.startswith(option)
- for option in cmd.cli_ctx.data.get('safe_params', [])):
- specified.add('_subscription')
- if specified.intersection({'name', 'resource_group_name', 'silo', '_subscription'}):
- raise MutuallyExclusiveArgumentError('Use --ids or named resource arguments, not both.')
- expected_type = 'serverfarms' if cmd.name.startswith('appservice plan silo') else 'sites'
- child_required = hasattr(namespace, 'silo')
- for identifier in identifiers:
- parts = parse_resource_id(identifier)
- if (parts.get('namespace', '').lower() != 'microsoft.web' or
- parts.get('type', parts.get('resource_type', '')).lower() != expected_type or
- (child_required and parts.get('child_type_1', '').lower() != 'silos') or
- (not child_required and 'child_type_1' in parts) or 'child_type_2' in parts):
- raise InvalidArgumentValueError(
- 'Provide a Microsoft.Web/{}{} resource ID.'.format(expected_type, '/silos' if child_required else ''))
-
-
-COMMAND_GROUPS = {
- 'appservice plan silo': (
- ('create', 'create_plan_silo'), ('update', 'update_plan_silo'),
- ('show', 'show_plan_silo'), ('list', 'list_plan_silos'), ('delete', 'delete_plan_silo'),
- ('wait', 'wait_plan_silo')),
- 'appservice plan silo operation': (('show', 'show_plan_silo_operation'),),
- 'appservice plan silo vnet-integration': (('list', 'list_plan_silo_vnet_integrations'),),
- 'webapp silo': (
- ('create', 'create_site_silo'), ('update', 'update_site_silo'), ('show', 'show_site_silo'),
- ('list', 'list_site_silos'), ('delete', 'delete_site_silo'), ('wait', 'wait_site_silo'),
- ('refresh', 'refresh_site_silos'), ('start', 'start_site_silo'),
- ('stop', 'stop_site_silo'), ('restart', 'restart_site_silo')),
- 'webapp silo operation': (('show', 'show_site_silo_operation'),),
- 'webapp silo vnet-integration': (
- ('show', 'show_silo_vnet_integration'), ('add', 'add_silo_vnet_integration'),
- ('update', 'update_silo_vnet_integration'), ('remove', 'remove_silo_vnet_integration')),
- 'webapp managed-traffic': (
- ('list', 'list_managed_traffic'), ('show', 'show_managed_traffic'), ('update', 'update_managed_traffic')),
- 'webapp managed-traffic metrics': (('list', 'list_managed_traffic_metrics'),),
- 'webapp last-known-good': (
- ('list', 'list_last_known_good'), ('show', 'show_last_known_good'), ('mark', 'mark_last_known_good')),
- 'webapp silo log php': (('show', 'show_silo_php_logging'),),
- 'webapp silo deployment': (('list', 'list_silo_deployments'), ('show', 'show_silo_deployment')),
- 'webapp silo deployment log': (
- ('list', 'list_silo_deployment_logs'), ('show', 'show_silo_msdeploy_log')),
- 'webapp silo deployment status': (
- ('show', 'show_silo_onedeploy_status'), ('list', 'list_silo_deployment_statuses')),
- 'webapp silo process': (
- ('show', 'show_silo_process'), ('stop', 'stop_silo_process'), ('delete', 'delete_silo_process'),
- ('dump', 'dump_silo_process')),
- 'webapp silo process thread': (
- ('list', 'list_silo_process_threads'), ('show', 'show_silo_process_thread')),
- 'webapp silo process module': (
- ('list', 'list_silo_process_modules'), ('show', 'show_silo_process_module')),
- 'webapp silo container': (('restart', 'restart_silo_container'),),
- 'webapp silo log container': (('download', 'download_silo_container_logs'),),
- 'webapp silo log startup': (
- ('list', 'list_silo_startup_logs'), ('show-summary', 'show_silo_startup_summary'),
- ('show', 'show_silo_startup_log')),
- 'webapp silo log': (('list', 'list_silo_logs'), ('tail', 'tail_silo_logs')),
- 'webapp silo log config': (('show', 'show_silo_log_config'),),
- 'webapp silo webjob': (('list', 'list_silo_webjobs'), ('show', 'show_silo_webjob')),
- 'webapp silo webjob continuous': (
- ('list', 'list_silo_continuous_webjobs'), ('show', 'show_silo_continuous_webjob'),
- ('start', 'start_silo_continuous_webjob'), ('stop', 'stop_silo_continuous_webjob')),
- 'webapp silo webjob triggered': (
- ('list', 'list_silo_triggered_webjobs'), ('show', 'show_silo_triggered_webjob'),
- ('run', 'run_silo_triggered_webjob')),
- 'webapp silo webjob triggered history': (
- ('list', 'list_silo_webjob_history'), ('show', 'show_silo_webjob_history')),
- 'webapp silo site-extension': (
- ('list', 'list_silo_site_extensions'), ('show', 'show_silo_site_extension')),
- 'webapp silo extension': (('invoke', 'invoke_silo_extension'),),
- 'webapp silo network-check': (
- ('connectivity', 'check_silo_connectivity'), ('dns', 'check_silo_dns'), ('tcp-ping', 'check_silo_tcp')),
- 'webapp silo instance': (('list', 'list_silo_instances'), ('show', 'show_silo_instance')),
- 'webapp silo config': (('show', 'show_silo_config'), ('list', 'list_silo_config')),
- 'webapp silo config snapshot': (
- ('list', 'list_silo_config_snapshots'), ('show', 'show_silo_config_snapshot')),
- 'webapp silo config keyvault-reference': (
- ('list', 'list_silo_keyvault_references'), ('refresh', 'refresh_silo_keyvault_references')),
- 'webapp silo runtime': (('list-detected', 'list_silo_detected_stacks'),),
- 'webapp silo config appsettings': (('list', 'list_silo_appsettings'),),
- 'webapp silo config storage-account': (('list', 'list_silo_storage_accounts'),),
- 'webapp silo config connection-string': (
- ('list', 'list_silo_connection_strings'), ('show', 'show_silo_connection_strings')),
- 'webapp silo auth': (('show', 'show_silo_auth'),),
- 'webapp silo auth v2': (('show', 'show_silo_auth_v2'),),
- 'webapp silo config push-settings': (('show', 'show_silo_push_settings'),),
- 'webapp silo config metadata': (('list', 'list_silo_metadata'),),
- 'webapp silo premier-addon': (('list', 'list_silo_premier_addons'), ('show', 'show_silo_premier_addon')),
- 'webapp silo metrics': (('list', 'list_silo_metrics'),),
- 'webapp silo metrics definition': (('list', 'list_silo_metric_definitions'),),
- 'webapp silo metrics perf-counter': (('list', 'list_silo_perf_counters'),),
- 'webapp silo network-trace': (
- ('start', 'start_silo_network_trace'), ('stop', 'stop_silo_network_trace'),
- ('show', 'show_silo_network_trace')),
- 'webapp silo network-trace operation': (('show', 'show_silo_network_trace_operation'),),
- 'webapp silo runtime-operation': (('show', 'show_silo_runtime_operation'),),
- 'webapp silo antivirus': (('scan', 'scan_silo_antivirus'),),
- 'webapp silo antivirus config': (
- ('show', 'show_silo_antivirus_config'), ('update', 'update_silo_antivirus_config')),
- 'webapp silo antivirus log': (('list', 'list_silo_antivirus_logs'), ('show', 'show_silo_antivirus_log')),
- 'webapp silo sitecontainers': (('list', 'list_silo_sitecontainers'), ('show', 'show_silo_sitecontainer')),
-}
-
-ARGUMENT_HELP = {
- 'addon_name': 'Name of the installed premium add-on.',
- 'base_address': 'Module base address returned by the process module list.',
- 'body': 'API request envelope as JSON or @file. Prefer named request options when available.',
- 'container_name': 'Name of the container within the silo app.',
- 'content_type': 'Content-Type header for the extension request body, such as application/json.',
- 'delete_infected_files': 'Whether an antivirus scan may delete infected files. Specify true or false.',
- 'deployment_id': 'Deployment identifier returned by deployment list.',
- 'details': 'Include detailed metric values in the service response.',
- 'dns_name': 'DNS name to resolve from the silo worker, such as example.com.',
- 'duration': 'Network capture duration in seconds.',
- 'end_time': 'End of the metrics window in UTC ISO 8601 format, such as 2026-10-20T01:00:00Z.',
- 'endpoint_status': 'Enable or disable this silo as a managed-traffic endpoint.',
- 'endpoints': 'JSON array or @file of region-keyed weight or priority overrides, each from 1 to 1000.',
- 'expand_endpoints': 'Include persisted endpoint details in the managed-traffic collection.',
- 'extension_name': 'Identifier of the installed site extension.',
- 'file': 'Destination file for response bytes. Existing files require --overwrite.',
- 'filename': 'Startup-log filename returned by startup list.',
- 'filter_string': 'API OData metric filter. Do not combine with time, grain or metric-name options.',
- 'follow_last_known_good': 'Whether the silo follows Last Known Good instead of newer ordinary revisions.',
- 'force_restart': 'Whether the service may restart the silo when refreshing Key Vault references.',
- 'format': 'Container-log download format: raw content or a ZIP archive.',
- 'host': 'Destination hostname or IP address to test from the silo worker.',
- 'include_file_paths': 'Include scanned file paths. The output may contain sensitive paths.',
- 'include_secrets': 'Include private configuration values. Protect the command output.',
- 'instance': 'Worker instance identifier returned by instance list.',
- 'key': 'Connection-string name for a private-value point read. Omit to use the collection metadata route.',
- 'latest': 'Return the latest startup-log metadata instead of the startup file list.',
- 'legacy': 'Use the legacy service endpoint for this operation.',
- 'location': 'Target silo region. Required on create; preserved from the existing silo on update.',
- 'log_type': 'Startup-log type filter accepted by the service.',
- 'max_frame_length': 'Maximum captured network-frame length in bytes.',
- 'metadata_only': 'Read authentication metadata without requesting the full private settings.',
- 'method': 'HTTP method used for the instance-qualified extension request.',
- 'names': 'One or more metric names to include in the response.',
- 'number_of_workers': 'Silo worker-count override, from 1 to 30. Omit to preserve inheritance.',
- 'operation_id': 'Operation identifier returned by the corresponding accepted operation.',
- 'overwrite': 'Replace the destination file if it already exists.',
- 'path': 'Relative extension API path, without a leading slash, query string or fragment.',
- 'port': 'Destination TCP port, from 1 to 65535.',
- 'primary_location': 'Primary region. On create, resolved from the primary resource when omitted.',
- 'process_id': 'Process identifier on the selected silo worker.',
- 'run_id': 'Triggered WebJob run identifier returned by history list.',
- 'sas_url': 'SAS URL for the trace destination. Treat this URL as a credential.',
- 'scan_id': 'Antivirus scan identifier returned by antivirus log list.',
- 'slot_view': 'Request the service slot view for the returned metrics.',
- 'snapshot_id': 'Configuration snapshot identifier returned by config snapshot list.',
- 'soft_restart': 'Whether to request the service soft-restart behavior.',
- 'start_time': 'Start of the metrics window in UTC ISO 8601 format, such as 2026-10-20T00:00:00Z.',
- 'subnet': 'Full subnet ARM resource ID for the silo VNet integration.',
- 'synchronous': 'Whether the service should finish the restart before responding.',
- 'thread_id': 'Thread identifier returned by process thread list.',
- 'time_grain': 'Metrics interval as an ISO 8601 duration, such as PT1M or PT1H.',
- 'timeout': 'Maximum API-operation wait in seconds. Expiry does not cancel the service operation.',
- 'traffic_mode': 'Managed regional traffic mode: Active weights or Passive priorities.',
- 'uri_parameters': 'Extension query parameters as KEY=VALUE pairs. Repeat keys when needed.',
- 'webjob_name': 'Name of the WebJob installed in the silo app.',
- 'zone_redundant': 'Silo zone-redundancy override. Specify true or false; omit to preserve inheritance.',
-}
-
-COMMAND_SUMMARIES = {
- 'create_plan_silo': 'Create a regional Active Backup silo for an existing App Service plan.',
- 'update_plan_silo': 'Update worker-count or zone-redundancy overrides for a plan silo.',
- 'show_plan_silo': 'Show a plan silo, including its provisioning state and overrides.',
- 'list_plan_silos': 'List all silos associated with an App Service plan.',
- 'delete_plan_silo': 'Delete a plan silo after its dependent site silos have been removed.',
- 'wait_plan_silo': 'Wait for a plan-silo API operation to complete.',
- 'show_plan_silo_operation': 'Inspect the current status of a plan-silo API operation.',
- 'list_plan_silo_vnet_integrations': 'List the VNet integrations associated with a plan silo.',
- 'create_site_silo': 'Create a web app silo in a region with an existing plan silo.',
- 'update_site_silo': 'Update a silo endpoint, revision policy or networking override.',
- 'show_site_silo': 'Show a web app silo, its current revision and service error details.',
- 'list_site_silos': 'List a web app origin and its regional silos.',
- 'delete_site_silo': 'Delete a web app silo without deleting the primary app.',
- 'wait_site_silo': 'Wait for a web app silo API operation to complete.',
- 'show_site_silo_operation': 'Inspect the current status of a web app silo API operation.',
- 'refresh_site_silos': 'Request a fresh revision and asynchronous propagation to the app silos.',
- 'start_site_silo': 'Start the selected web app silo.',
- 'stop_site_silo': 'Stop the selected web app silo.',
- 'restart_site_silo': 'Restart the selected web app silo.',
- 'show_silo_vnet_integration': 'Show the selected silo VNet integration.',
- 'add_silo_vnet_integration': 'Connect a web app silo to a delegated VNet subnet.',
- 'update_silo_vnet_integration': 'Update a web app silo VNet integration.',
- 'remove_silo_vnet_integration': 'Disconnect a web app silo from its VNet subnet.',
- 'list_managed_traffic': 'List the app managed-traffic configuration, with optional endpoint expansion.',
- 'show_managed_traffic': 'Show the app regional traffic mode and endpoint overrides.',
- 'update_managed_traffic': 'Update the app regional traffic mode or selected endpoint overrides.',
- 'list_managed_traffic_metrics': 'Read health metrics for the app managed regional traffic.',
- 'list_last_known_good': 'List the app Last Known Good revision metadata.',
- 'show_last_known_good': 'Show the app current Last Known Good revision.',
- 'mark_last_known_good': 'Mark the app latest ready revision as Last Known Good.',
- 'show_silo_php_logging': 'Read the silo PHP error-logging setting.',
- 'list_silo_deployments': 'List deployment history recorded for the selected silo.',
- 'show_silo_deployment': 'Show one deployment from the silo deployment history.',
- 'list_silo_deployment_logs': 'List log entries for one silo deployment.',
- 'show_silo_msdeploy_log': 'Read the selected silo MSDeploy log.',
- 'show_silo_onedeploy_status': 'Show the selected silo OneDeploy status.',
- 'list_silo_deployment_statuses': 'List deployment-status records for the selected silo.',
- 'show_silo_process': 'Show one process on a silo worker.',
- 'stop_silo_process': 'Stop one process on a silo worker.',
- 'delete_silo_process': 'Delete one process on a silo worker.',
- 'dump_silo_process': 'Save a silo worker process dump to a local file.',
- 'list_silo_process_threads': 'List threads belonging to a silo worker process.',
- 'show_silo_process_thread': 'Show one thread belonging to a silo worker process.',
- 'list_silo_process_modules': 'List modules loaded by a silo worker process.',
- 'show_silo_process_module': 'Show one module loaded by a silo worker process.',
- 'restart_silo_container': 'Restart a named container in the selected silo.',
- 'download_silo_container_logs': 'Download raw or ZIP container logs to a local file.',
- 'list_silo_startup_logs': 'List container startup-log files or request the latest-log metadata.',
- 'show_silo_startup_summary': 'Show per-instance container startup success and failure summaries.',
- 'show_silo_startup_log': 'Read one container startup-log file.',
- 'list_silo_logs': 'List available instance logs for the selected silo.',
- 'tail_silo_logs': 'Stream live silo logs to the console or a local file.',
- 'show_silo_log_config': 'Show the silo diagnostic logging configuration.',
- 'list_silo_webjobs': 'List WebJobs installed in the selected silo.',
- 'show_silo_webjob': 'Show one WebJob installed in the selected silo.',
- 'list_silo_continuous_webjobs': 'List continuous WebJobs in the selected silo.',
- 'show_silo_continuous_webjob': 'Show a continuous WebJob in the selected silo.',
- 'start_silo_continuous_webjob': 'Start a continuous WebJob in the selected silo.',
- 'stop_silo_continuous_webjob': 'Stop a continuous WebJob in the selected silo.',
- 'list_silo_triggered_webjobs': 'List triggered WebJobs in the selected silo.',
- 'show_silo_triggered_webjob': 'Show a triggered WebJob in the selected silo.',
- 'run_silo_triggered_webjob': 'Start a new run of a triggered WebJob.',
- 'list_silo_webjob_history': 'List previous runs of a triggered WebJob.',
- 'show_silo_webjob_history': 'Show one previous run of a triggered WebJob.',
- 'list_silo_site_extensions': 'List site extensions installed in the selected silo.',
- 'show_silo_site_extension': 'Show one site extension installed in the selected silo.',
- 'invoke_silo_extension': 'Send a request to an instance-qualified silo extension API.',
- 'check_silo_connectivity': 'Run a service connectivity check from the selected silo.',
- 'check_silo_dns': 'Resolve a DNS name from the selected silo worker.',
- 'check_silo_tcp': 'Test a TCP destination from the selected silo worker.',
- 'list_silo_instances': 'List worker instances assigned to the selected silo.',
- 'show_silo_instance': 'Show one worker instance assigned to the selected silo.',
- 'show_silo_config': 'Read the selected silo web configuration.',
- 'list_silo_config': 'List web-configuration resources for the selected silo.',
- 'list_silo_config_snapshots': 'List configuration snapshots available for the selected silo.',
- 'show_silo_config_snapshot': 'Read a configuration snapshot, optionally including private values.',
- 'list_silo_keyvault_references': 'List connection-string Key Vault reference status for the silo.',
- 'refresh_silo_keyvault_references': 'Request refresh of the silo app-setting Key Vault references.',
- 'list_silo_detected_stacks': 'List runtime stacks detected in the selected silo.',
- 'list_silo_appsettings': 'Read the selected silo app-setting values.',
- 'list_silo_storage_accounts': 'Read storage-account mount settings for the selected silo.',
- 'list_silo_connection_strings': 'Read connection-string values for the selected silo.',
- 'show_silo_connection_strings': 'Read connection-string metadata or one named private value.',
- 'show_silo_auth': 'Read the silo authentication settings or their metadata.',
- 'show_silo_auth_v2': 'Read the silo version 2 authentication settings.',
- 'show_silo_push_settings': 'Read the silo push-notification settings.',
- 'list_silo_metadata': 'Read the selected silo metadata properties.',
- 'list_silo_premier_addons': 'List premium add-ons associated with the selected silo.',
- 'show_silo_premier_addon': 'Show a premium add-on associated with the selected silo.',
- 'list_silo_metrics': 'Read metric values for the selected silo and requested time window.',
- 'list_silo_metric_definitions': 'List metric names and definitions available for the selected silo.',
- 'list_silo_perf_counters': 'Read performance-counter values for the selected silo.',
- 'start_silo_network_trace': 'Start a network capture for the selected silo.',
- 'stop_silo_network_trace': 'Stop the selected silo network capture.',
- 'show_silo_network_trace': 'Read the results of a completed silo network capture.',
- 'show_silo_network_trace_operation': 'Inspect a silo network-capture operation.',
- 'show_silo_runtime_operation': 'Inspect an app-runtime operation on the selected silo.',
- 'scan_silo_antivirus': 'Queue an on-demand antivirus scan of the selected silo.',
- 'show_silo_antivirus_config': 'Read the selected silo antivirus scan configuration.',
- 'update_silo_antivirus_config': 'Update whether silo antivirus scans may delete infected files.',
- 'list_silo_antivirus_logs': 'List antivirus scan records for the selected silo.',
- 'show_silo_antivirus_log': 'Read an antivirus scan record, optionally including file paths.',
- 'list_silo_sitecontainers': 'List site-container definitions for the selected silo.',
- 'show_silo_sitecontainer': 'Show one site-container definition for the selected silo.',
-}
-
-EXAMPLE_ARGUMENTS = {
- 'create_plan_silo': '--number-of-workers 2 --timeout 7200',
- 'create_site_silo': '--endpoint-status Enabled --follow-last-known-good false --timeout 7200',
- 'update_plan_silo': '--number-of-workers 2 --zone-redundant false --timeout 7200',
- 'update_site_silo': '--endpoint-status Enabled --follow-last-known-good false --timeout 7200',
- 'delete_plan_silo': '--yes --timeout 7200',
- 'delete_site_silo': '--yes --timeout 7200',
- 'wait_plan_silo': '--timeout 7200',
- 'wait_site_silo': '--timeout 7200',
- 'restart_site_silo': '--soft-restart true --synchronous true --timeout 7200',
- 'start_site_silo': '--timeout 7200',
- 'stop_site_silo': '--timeout 7200',
- 'list_managed_traffic': '--expand-endpoints',
- 'update_managed_traffic': '--traffic-mode Passive',
- 'show_silo_process': '--instance MyInstance',
- 'list_silo_process_threads': '--instance MyInstance',
- 'list_silo_process_modules': '--instance MyInstance',
- 'delete_silo_process': '--yes',
- 'download_silo_container_logs': '--format zip',
- 'list_silo_startup_logs': '--latest true --instance MyInstance',
- 'show_silo_startup_summary': '--duration 1.5 --instance MyInstance',
- 'tail_silo_logs': '--instance MyInstance',
- 'invoke_silo_extension': '--method POST --body @request.json',
- 'check_silo_dns': '--dns-name example.com',
- 'check_silo_tcp': '--host example.com --port 443',
- 'show_silo_config_snapshot': '--include-secrets',
- 'refresh_silo_keyvault_references': '--force-restart false',
- 'show_silo_connection_strings': '--key MyConnection',
- 'show_silo_auth': '--metadata-only',
- 'show_silo_auth_v2': '--legacy',
- 'list_silo_metrics': (
- '--names Requests --details true --time-grain PT1H '
- '--start-time 2026-10-20T00:00:00Z --end-time 2026-10-20T01:00:00Z'),
- 'list_silo_perf_counters': (
- '--names Requests --time-grain PT1H '
- '--start-time 2026-10-20T00:00:00Z --end-time 2026-10-20T01:00:00Z'),
- 'start_silo_network_trace': '--duration 60 --max-frame-length 128 --timeout 7200',
- 'update_silo_antivirus_config': '--delete-infected-files false',
- 'show_silo_antivirus_log': '--include-file-paths',
-}
-
-ADDITIONAL_EXAMPLES = {
- 'create_plan_silo': (
- 'Create a plan silo with explicit zone and worker overrides.',
- 'az appservice plan silo create -g MyResourceGroup -n MyPrimary --silo MySilo '
- '--location westus2 --primary-location eastus --number-of-workers 2 --zone-redundant false'),
- 'update_plan_silo': (
- 'Update capacity while supplying the unchanged routing locations explicitly.',
- 'az appservice plan silo update -g MyResourceGroup -n MyPrimary --silo MySilo '
- '--number-of-workers 2 --location westus2 --primary-location eastus'),
- 'show_plan_silo': (
- 'Read only the plan-silo provisioning state for a script.',
- 'az appservice plan silo show -g MyResourceGroup -n MyPrimary --silo MySilo '
- '--query properties.provisioningState --output tsv'),
- 'list_plan_silos': (
- 'List the names of all plan silos for a script.',
- 'az appservice plan silo list -g MyResourceGroup -n MyPrimary --query "[].name" --output tsv'),
- 'delete_plan_silo': (
- 'Request plan-silo deletion using its ARM ID after removing dependent site silos.',
- 'az appservice plan silo delete --ids $PlanSiloResourceId --yes --no-wait'),
- 'wait_plan_silo': (
- 'Wait for a known plan-silo operation and return only its terminal status.',
- 'az appservice plan silo wait --ids $PlanSiloResourceId '
- '--operation-id 00000000-0000-0000-0000-000000000001 --timeout 120 --query status --output tsv'),
- 'show_plan_silo_operation': (
- 'Inspect the status of an operation on a plan silo identified by ARM ID.',
- 'az appservice plan silo operation show --ids $PlanSiloResourceId '
- '--operation-id 00000000-0000-0000-0000-000000000001 --query status --output tsv'),
- 'list_plan_silo_vnet_integrations': (
- 'Read integrations for a plan silo identified by ARM ID.',
- 'az appservice plan silo vnet-integration list --ids $PlanSiloResourceId --output json'),
- 'create_site_silo': (
- 'Create a Passive-traffic silo with a delegated subnet override.',
- 'az webapp silo create -g MyResourceGroup -n MyPrimary --silo MySilo '
- '--location westus2 --primary-location eastus --traffic-mode Passive '
- '--subnet /subscriptions/SUB/resourceGroups/NetworkGroup/providers/Microsoft.Network/'
- 'virtualNetworks/WestNet/subnets/integration'),
- 'update_site_silo': (
- 'Set a subnet override while supplying the unchanged routing locations.',
- 'az webapp silo update -g MyResourceGroup -n MyPrimary --silo MySilo '
- '--location westus2 --primary-location eastus '
- '--subnet /subscriptions/SUB/resourceGroups/NetworkGroup/providers/Microsoft.Network/'
- 'virtualNetworks/WestNet/subnets/integration'),
- 'show_site_silo': (
- 'Read the current revision for a silo identified by ARM ID.',
- 'az webapp silo show --ids $SiteSiloResourceId --query properties.revisionNumber --output tsv'),
- 'list_site_silos': (
- 'Show regional silo names and locations in a table.',
- 'az webapp silo list -g MyResourceGroup -n MyPrimary '
- '--query "[].{Name:name,Region:location}" --output table'),
- 'delete_site_silo': (
- 'Request deletion by silo ARM ID without polling the accepted operation.',
- 'az webapp silo delete --ids $SiteSiloResourceId --yes --no-wait'),
- 'wait_site_silo': (
- 'Wait for an operation on a silo identified by ARM ID and return its terminal status.',
- 'az webapp silo wait --ids $SiteSiloResourceId '
- '--operation-id 00000000-0000-0000-0000-000000000001 --timeout 120 --query status --output tsv'),
- 'show_site_silo_operation': (
- 'Read only the current operation status.',
- 'az webapp silo operation show --ids $SiteSiloResourceId '
- '--operation-id 00000000-0000-0000-0000-000000000001 --query status --output tsv'),
- 'refresh_site_silos': (
- 'Request asynchronous refresh for a primary app identified by ARM ID.',
- 'az webapp silo refresh --ids $PrimaryAppResourceId'),
- 'start_site_silo': (
- 'Start a silo identified by ARM ID without polling the accepted operation.',
- 'az webapp silo start --ids $SiteSiloResourceId --no-wait'),
- 'stop_site_silo': (
- 'Stop a silo identified by ARM ID without polling the accepted operation.',
- 'az webapp silo stop --ids $SiteSiloResourceId --no-wait'),
- 'restart_site_silo': (
- 'Ask the service for an asynchronous restart and skip subsequent operation polling.',
- 'az webapp silo restart --ids $SiteSiloResourceId --synchronous false --no-wait'),
- 'show_silo_vnet_integration': (
- 'Read the subnet ID from a silo VNet integration.',
- 'az webapp silo vnet-integration show --ids $SiteSiloResourceId '
- '--query properties.subnetResourceId --output tsv'),
- 'add_silo_vnet_integration': (
- 'Connect using an advanced VNet connection envelope.',
- 'az webapp silo vnet-integration add -g MyResourceGroup -n MyPrimary --silo MySilo --body @vnet.json'),
- 'update_silo_vnet_integration': (
- 'Patch advanced VNet connection properties from a file.',
- 'az webapp silo vnet-integration update -g MyResourceGroup -n MyPrimary --silo MySilo --body @vnet.json'),
- 'remove_silo_vnet_integration': (
- 'Disconnect the integration on a silo identified by ARM ID.',
- 'az webapp silo vnet-integration remove --ids $SiteSiloResourceId'),
- 'list_managed_traffic': (
- 'Read the expanded managed-traffic resource for a primary app identified by ARM ID.',
- 'az webapp managed-traffic list --ids $PrimaryAppResourceId --expand-endpoints --output json'),
- 'show_managed_traffic': (
- 'Read only the configured regional traffic mode.',
- 'az webapp managed-traffic show -g MyResourceGroup -n MyPrimary --query properties.trafficMode --output tsv'),
- 'update_managed_traffic': (
- 'Apply region-keyed weight overrides from a JSON array file.',
- 'az webapp managed-traffic update -g MyResourceGroup -n MyPrimary '
- '--traffic-mode Active --endpoints @endpoints.json'),
- 'list_managed_traffic_metrics': (
- 'Read health metrics for a one-hour UTC window at one-minute intervals.',
- 'az webapp managed-traffic metrics list -g MyResourceGroup -n MyPrimary '
- '--start-time 2026-10-20T00:00:00Z --end-time 2026-10-20T01:00:00Z --time-grain PT1M'),
- 'list_last_known_good': (
- 'Read Last Known Good metadata for a primary app identified by ARM ID.',
- 'az webapp last-known-good list --ids $PrimaryAppResourceId --output json'),
- 'show_last_known_good': (
- 'Read only the Last Known Good revision number.',
- 'az webapp last-known-good show -g MyResourceGroup -n MyPrimary '
- '--query properties.revisionNumber --output tsv'),
- 'mark_last_known_good': (
- 'Mark the latest ready revision using the primary app ARM ID.',
- 'az webapp last-known-good mark --ids $PrimaryAppResourceId'),
- 'show_silo_php_logging': (
- 'Inspect the PHP logging response for a silo identified by ARM ID.',
- 'az webapp silo log php show --ids $SiteSiloResourceId --output json'),
- 'list_silo_deployments': (
- 'List deployment identifiers for subsequent deployment-log reads.',
- 'az webapp silo deployment list --ids $SiteSiloResourceId --query "[].id" --output tsv'),
- 'show_silo_deployment': (
- 'Read one deployment using the silo ARM ID.',
- 'az webapp silo deployment show --ids $SiteSiloResourceId --deployment-id MyDeployment --output json'),
- 'list_silo_deployment_logs': (
- 'Read log entries for a deployment using the silo ARM ID.',
- 'az webapp silo deployment log list --ids $SiteSiloResourceId --deployment-id MyDeployment --output json'),
- 'show_silo_msdeploy_log': (
- 'Read the MSDeploy log from one worker instance.',
- 'az webapp silo deployment log show --ids $SiteSiloResourceId --instance MyInstance --output json'),
- 'show_silo_onedeploy_status': (
- 'Read OneDeploy status from one worker instance.',
- 'az webapp silo deployment status show --ids $SiteSiloResourceId --instance MyInstance --output json'),
- 'list_silo_deployment_statuses': (
- 'Read deployment-status resources for a silo identified by ARM ID.',
- 'az webapp silo deployment status list --ids $SiteSiloResourceId --output json'),
- 'show_silo_process': (
- 'Read one process using the silo ARM ID and a worker instance.',
- 'az webapp silo process show --ids $SiteSiloResourceId --process-id 123 --instance MyInstance --output json'),
- 'stop_silo_process': (
- 'Stop a process on a specific worker instance.',
- 'az webapp silo process stop --ids $SiteSiloResourceId --process-id 123 --instance MyInstance'),
- 'delete_silo_process': (
- 'Delete a process on a specific worker without prompting.',
- 'az webapp silo process delete --ids $SiteSiloResourceId --process-id 123 --instance MyInstance --yes'),
- 'dump_silo_process': (
- 'Save a worker-qualified process dump and replace an existing local file.',
- 'az webapp silo process dump --ids $SiteSiloResourceId --process-id 123 --instance MyInstance '
- '--file worker.dmp --overwrite'),
- 'list_silo_process_threads': (
- 'List thread identifiers for a process on a specific worker.',
- 'az webapp silo process thread list --ids $SiteSiloResourceId --process-id 123 '
- '--instance MyInstance --query "[].id" --output tsv'),
- 'show_silo_process_thread': (
- 'Read one thread on a specific worker instance.',
- 'az webapp silo process thread show --ids $SiteSiloResourceId --process-id 123 '
- '--thread-id 456 --instance MyInstance'),
- 'list_silo_process_modules': (
- 'List process module names on a specific worker.',
- 'az webapp silo process module list --ids $SiteSiloResourceId --process-id 123 '
- '--instance MyInstance --query "[].name" --output tsv'),
- 'show_silo_process_module': (
- 'Read a module at a known base address on a specific worker.',
- 'az webapp silo process module show --ids $SiteSiloResourceId --process-id 123 '
- '--base-address 0x1000 --instance MyInstance'),
- 'restart_silo_container': (
- 'Restart a named container on a specific worker.',
- 'az webapp silo container restart --ids $SiteSiloResourceId '
- '--container-name MyContainer --instance MyInstance'),
- 'download_silo_container_logs': (
- 'Download raw container logs and replace an existing destination.',
- 'az webapp silo log container download --ids $SiteSiloResourceId --format raw --file container.log --overwrite'),
- 'list_silo_startup_logs': (
- 'List startup files for a worker and a service-supported log type.',
- 'az webapp silo log startup list --ids $SiteSiloResourceId '
- '--instance MyInstance --log-type $StartupLogType --latest false'),
- 'show_silo_startup_summary': (
- 'Read the last two hours of startup summaries for one worker.',
- 'az webapp silo log startup show-summary --ids $SiteSiloResourceId --instance MyInstance --duration 2'),
- 'show_silo_startup_log': (
- 'Read a startup-log filename returned by startup list.',
- 'az webapp silo log startup show --ids $SiteSiloResourceId --filename startup.log'),
- 'list_silo_logs': (
- 'List available logs from a specific worker.',
- 'az webapp silo log list --ids $SiteSiloResourceId --instance MyInstance --output json'),
- 'tail_silo_logs': (
- 'Stream worker logs to a local file, replacing an existing destination.',
- 'az webapp silo log tail --ids $SiteSiloResourceId --instance MyInstance --file live.log --overwrite'),
- 'show_silo_log_config': (
- 'Read only the diagnostic logging properties.',
- 'az webapp silo log config show --ids $SiteSiloResourceId --query properties --output json'),
- 'list_silo_webjobs': (
- 'List installed WebJob names.',
- 'az webapp silo webjob list --ids $SiteSiloResourceId --query "[].name" --output tsv'),
- 'show_silo_webjob': (
- 'Read one WebJob using the silo ARM ID.',
- 'az webapp silo webjob show --ids $SiteSiloResourceId --webjob-name MyJob --output json'),
- 'list_silo_continuous_webjobs': (
- 'List continuous WebJob names for a script.',
- 'az webapp silo webjob continuous list --ids $SiteSiloResourceId --query "[].name" --output tsv'),
- 'show_silo_continuous_webjob': (
- 'Inspect one continuous WebJob using the silo ARM ID.',
- 'az webapp silo webjob continuous show --ids $SiteSiloResourceId --webjob-name MyJob --output json'),
- 'start_silo_continuous_webjob': (
- 'Start an installed continuous WebJob using the silo ARM ID.',
- 'az webapp silo webjob continuous start --ids $SiteSiloResourceId --webjob-name MyJob'),
- 'stop_silo_continuous_webjob': (
- 'Stop an installed continuous WebJob using the silo ARM ID.',
- 'az webapp silo webjob continuous stop --ids $SiteSiloResourceId --webjob-name MyJob'),
- 'list_silo_triggered_webjobs': (
- 'List triggered WebJob names for a script.',
- 'az webapp silo webjob triggered list --ids $SiteSiloResourceId --query "[].name" --output tsv'),
- 'show_silo_triggered_webjob': (
- 'Inspect a triggered WebJob using the silo ARM ID.',
- 'az webapp silo webjob triggered show --ids $SiteSiloResourceId --webjob-name MyJob --output json'),
- 'run_silo_triggered_webjob': (
- 'Queue a new run of an installed triggered WebJob.',
- 'az webapp silo webjob triggered run --ids $SiteSiloResourceId --webjob-name MyJob'),
- 'list_silo_webjob_history': (
- 'List previous run identifiers for a triggered WebJob.',
- 'az webapp silo webjob triggered history list --ids $SiteSiloResourceId '
- '--webjob-name MyJob --query "[].id" --output tsv'),
- 'show_silo_webjob_history': (
- 'Read a known triggered-WebJob run.',
- 'az webapp silo webjob triggered history show --ids $SiteSiloResourceId '
- '--webjob-name MyJob --run-id MyRun --output json'),
- 'list_silo_site_extensions': (
- 'List installed extension names for a script.',
- 'az webapp silo site-extension list --ids $SiteSiloResourceId --query "[].name" --output tsv'),
- 'show_silo_site_extension': (
- 'Inspect one installed site extension using the silo ARM ID.',
- 'az webapp silo site-extension show --ids $SiteSiloResourceId --extension-name MyExtension --output json'),
- 'invoke_silo_extension': (
- 'Send a custom-content request with query parameters and save the response.',
- 'az webapp silo extension invoke --ids $SiteSiloResourceId --instance MyInstance --path api/settings '
- '--method POST --content-type application/xml --body @request.xml '
- '--uri-parameters mode=inspect --file extension-response.bin --overwrite'),
- 'check_silo_connectivity': (
- 'Use a prepared connectivity-check request for a silo identified by ARM ID.',
- 'az webapp silo network-check connectivity --ids $SiteSiloResourceId --body @connectivity-check.json'),
- 'check_silo_dns': (
- 'Use an advanced DNS-check request instead of named request fields.',
- 'az webapp silo network-check dns --ids $SiteSiloResourceId --body @dns-check.json'),
- 'check_silo_tcp': (
- 'Use an advanced TCP-check request instead of host and port options.',
- 'az webapp silo network-check tcp-ping --ids $SiteSiloResourceId --body @tcp-check.json'),
- 'list_silo_instances': (
- 'List worker instance names for later worker-qualified commands.',
- 'az webapp silo instance list --ids $SiteSiloResourceId --query "[].name" --output tsv'),
- 'show_silo_instance': (
- 'Inspect one instance using the silo ARM ID.',
- 'az webapp silo instance show --ids $SiteSiloResourceId --instance MyInstance --output json'),
- 'show_silo_config': (
- 'Read only the web-configuration properties.',
- 'az webapp silo config show --ids $SiteSiloResourceId --query properties --output json'),
- 'list_silo_config': (
- 'List web-configuration resource names.',
- 'az webapp silo config list --ids $SiteSiloResourceId --query "[].name" --output tsv'),
- 'list_silo_config_snapshots': (
- 'List configuration snapshot identifiers for point reads.',
- 'az webapp silo config snapshot list --ids $SiteSiloResourceId --query "[].properties.snapshotId" --output tsv'),
- 'show_silo_config_snapshot': (
- 'Read a snapshot without including its private configuration values.',
- 'az webapp silo config snapshot show --ids $SiteSiloResourceId --snapshot-id 1 --output json'),
- 'list_silo_keyvault_references': (
- 'Read connection-string Key Vault reference status for a silo identified by ARM ID.',
- 'az webapp silo config keyvault-reference list --ids $SiteSiloResourceId --output json'),
- 'refresh_silo_keyvault_references': (
- 'Refresh app-setting Key Vault references and permit a service restart.',
- 'az webapp silo config keyvault-reference refresh --ids $SiteSiloResourceId --force-restart true'),
- 'list_silo_detected_stacks': (
- 'Inspect detected runtime-stack records using the silo ARM ID.',
- 'az webapp silo runtime list-detected --ids $SiteSiloResourceId --output json'),
- 'list_silo_appsettings': (
- 'Read one named app-setting value; protect the returned value.',
- 'az webapp silo config appsettings list --ids $SiteSiloResourceId --query properties.MySetting --output tsv'),
- 'list_silo_storage_accounts': (
- 'Read storage-mount properties using the silo ARM ID; protect secret values.',
- 'az webapp silo config storage-account list --ids $SiteSiloResourceId --query properties --output json'),
- 'list_silo_connection_strings': (
- 'Read one named connection string; protect the returned value.',
- 'az webapp silo config connection-string list --ids $SiteSiloResourceId '
- '--query properties.MyConnection --output json'),
- 'show_silo_connection_strings': (
- 'Read connection-string metadata without selecting a private-value key.',
- 'az webapp silo config connection-string show --ids $SiteSiloResourceId --output json'),
- 'show_silo_auth': (
- 'Read full authentication settings rather than metadata; protect secret values.',
- 'az webapp silo auth show --ids $SiteSiloResourceId --output json'),
- 'show_silo_auth_v2': (
- 'Use the current authentication-v2 endpoint instead of the legacy list route.',
- 'az webapp silo auth v2 show --ids $SiteSiloResourceId --output json'),
- 'show_silo_push_settings': (
- 'Read push-notification properties using the silo ARM ID; protect secret values.',
- 'az webapp silo config push-settings show --ids $SiteSiloResourceId --query properties --output json'),
- 'list_silo_metadata': (
- 'Read one named metadata property.',
- 'az webapp silo config metadata list --ids $SiteSiloResourceId --query properties.MyMetadata --output tsv'),
- 'list_silo_premier_addons': (
- 'List associated premium add-on names.',
- 'az webapp silo premier-addon list --ids $SiteSiloResourceId --query "[].name" --output tsv'),
- 'show_silo_premier_addon': (
- 'Read one premium add-on using the silo ARM ID.',
- 'az webapp silo premier-addon show --ids $SiteSiloResourceId --addon-name MyAddon --output json'),
- 'list_silo_metrics': (
- 'Use a service OData filter instead of named metric-window options.',
- 'az webapp silo metrics list --ids $SiteSiloResourceId '
- '--filter "name.value eq \'Requests\'" --slot-view true --details false'),
- 'list_silo_metric_definitions': (
- 'Inspect metric definitions for a silo identified by ARM ID.',
- 'az webapp silo metrics definition list --ids $SiteSiloResourceId --output json'),
- 'list_silo_perf_counters': (
- 'Use a service OData filter for performance counters.',
- 'az webapp silo metrics perf-counter list --ids $SiteSiloResourceId '
- '--filter "name.value eq \'Requests\'" --output json'),
- 'start_silo_network_trace': (
- 'Use the legacy capture endpoint with a caller-provided trace destination.',
- 'az webapp silo network-trace start --ids $SiteSiloResourceId --legacy '
- '--duration 60 --max-frame-length 128 --sas-url $TraceDestinationSas --timeout 7200'),
- 'stop_silo_network_trace': (
- 'Stop capture using the silo ARM ID.',
- 'az webapp silo network-trace stop --ids $SiteSiloResourceId'),
- 'show_silo_network_trace': (
- 'Read results for a known capture using the silo ARM ID.',
- 'az webapp silo network-trace show --ids $SiteSiloResourceId '
- '--operation-id 00000000-0000-0000-0000-000000000001 --output json'),
- 'show_silo_network_trace_operation': (
- 'Read only the status of a known capture operation.',
- 'az webapp silo network-trace operation show --ids $SiteSiloResourceId '
- '--operation-id 00000000-0000-0000-0000-000000000001 --output json'),
- 'show_silo_runtime_operation': (
- 'Inspect an app-runtime operation using the silo ARM ID.',
- 'az webapp silo runtime-operation show --ids $SiteSiloResourceId '
- '--operation-id 00000000-0000-0000-0000-000000000001 --output json'),
- 'scan_silo_antivirus': (
- 'Queue a scan using the silo ARM ID.',
- 'az webapp silo antivirus scan --ids $SiteSiloResourceId'),
- 'show_silo_antivirus_config': (
- 'Read the antivirus scan configuration properties.',
- 'az webapp silo antivirus config show --ids $SiteSiloResourceId --query properties --output json'),
- 'update_silo_antivirus_config': (
- 'Use an advanced antivirus configuration envelope instead of named options.',
- 'az webapp silo antivirus config update --ids $SiteSiloResourceId --body @antivirus.json'),
- 'list_silo_antivirus_logs': (
- 'Read scan records for a silo identified by ARM ID.',
- 'az webapp silo antivirus log list --ids $SiteSiloResourceId --output json'),
- 'show_silo_antivirus_log': (
- 'Read a scan record without including sensitive file paths.',
- 'az webapp silo antivirus log show --ids $SiteSiloResourceId --scan-id MyScan --output json'),
- 'list_silo_sitecontainers': (
- 'List the names of site-container definitions.',
- 'az webapp silo sitecontainers list --ids $SiteSiloResourceId --query "[].name" --output tsv'),
- 'show_silo_sitecontainer': (
- 'Read one site-container definition using the silo ARM ID.',
- 'az webapp silo sitecontainers show --ids $SiteSiloResourceId --container-name MyContainer --output json'),
-}
-
-NO_WAIT_EXAMPLE_ARGUMENTS = {
- 'create_plan_silo': '--number-of-workers 2',
- 'update_plan_silo': '--number-of-workers 2',
- 'delete_plan_silo': '--yes',
- 'create_site_silo': '--endpoint-status Enabled',
- 'update_site_silo': '--endpoint-status Enabled',
- 'delete_site_silo': '--yes',
- 'start_site_silo': '',
- 'stop_site_silo': '',
- 'restart_site_silo': '--soft-restart true --synchronous false',
- 'start_silo_network_trace': '--duration 60 --max-frame-length 128',
-}
-
-
-def load_command_table(loader):
- from azure.cli.core.commands import CliCommandType
- from . import active_backup
- command_type = CliCommandType(operations_tmpl='azure.cli.command_modules.appservice.active_backup#{}')
- for group, commands in COMMAND_GROUPS.items():
- with loader.command_group(group, custom_command_type=command_type, is_preview=True,
- validator=validate_identity) as context:
- for verb, handler_name in commands:
- options = {}
- if verb == 'delete':
- options['confirmation'] = True
- if group in ('appservice plan silo', 'webapp silo') and verb in ('create', 'update', 'show', 'list'):
- options['table_transformer'] = active_backup.silo_table
- register = context.custom_show_command if verb == 'show' else context.custom_command
- register(verb, handler_name, **options)
-
-
-def load_arguments(loader, command):
- from azure.cli.core.commands.parameters import get_enum_type, get_three_state_flag, resource_group_name_type
- from . import active_backup
- handler_name = next((handler for group, entries in COMMAND_GROUPS.items() for verb, handler in entries
- if group + ' ' + verb == command), None)
- if handler_name is None:
- return
- parameters = inspect.signature(getattr(active_backup, handler_name)).parameters
- with loader.argument_context(command) as context:
- context.argument('resource_group_name', arg_type=resource_group_name_type)
- context.argument('name', options_list=['--name', '-n'], id_part='name',
- configured_default=None, local_context_attribute=None, completer=None,
- help='Name of the primary App Service plan or app, not an internal silo resource.')
- context.argument('silo', id_part='child_name_1', help='Customer-facing silo name, including origin when supported.')
- if 'no_wait' in parameters:
- # Register the reserved flag locally; supports_no_wait would discard the receipt.
- context.extra(
- 'no_wait', options_list=['--no-wait'], action='store_true', default=False,
- help='Skip polling accepted API operations. The initial service request must still complete.')
- for argument in parameters:
- if argument in ('cmd', 'name', 'resource_group_name', 'silo', 'no_wait'):
- continue
- settings = {'help': ARGUMENT_HELP[argument]}
- if argument in ('zone_redundant', 'follow_last_known_good', 'force_restart',
- 'soft_restart', 'synchronous', 'details', 'latest', 'slot_view', 'delete_infected_files'):
- settings['arg_type'] = get_three_state_flag()
- if argument in ('expand_endpoints', 'overwrite', 'include_secrets', 'metadata_only',
- 'legacy', 'include_file_paths'):
- settings['action'] = 'store_true'
- if argument in ('number_of_workers', 'timeout', 'duration', 'max_frame_length', 'port'):
- settings['type'] = int
- if argument == 'duration' and handler_name == 'show_silo_startup_summary':
- settings['type'] = float
- settings['help'] = 'Startup-summary look-back window in hours. Zero or omitted uses 24 hours.'
- if argument == 'legacy':
- settings['help'] = (
- 'Use the legacy trace-start endpoint instead of the asynchronous start-operation endpoint.'
- if handler_name == 'start_silo_network_trace'
- else 'Read authsettingsV2 through the legacy /list GET endpoint.')
- if argument == 'number_of_workers':
- settings.update(default=None, help='Silo worker-count override, from 1 to 30. Omit to preserve inheritance.')
- if argument == 'location':
- settings.update(
- configured_default=None, local_context_attribute=None,
- help='Target silo region. Required on create; preserved from the existing silo on update.')
- if argument == 'filter_string':
- settings['options_list'] = ['--filter']
- if argument == 'names':
- settings['nargs'] = '+'
- if argument == 'uri_parameters':
- settings['nargs'] = '+'
- enums = {'traffic_mode': ['Active', 'Passive'], 'endpoint_status': ['Enabled', 'Disabled'],
- 'format': ['raw', 'zip'], 'method': ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']}
- if argument in enums:
- settings['arg_type'] = get_enum_type(enums[argument])
- if argument == 'time_grain' and command.startswith('webapp managed-traffic'):
- settings['arg_type'] = get_enum_type(['PT1M', 'PT1H'])
- context.argument(argument, **settings)
-
-
-def load_help():
- from knack.help_files import helps
- from . import active_backup
- sensitive_handlers = {
- 'list_silo_appsettings', 'list_silo_storage_accounts', 'list_silo_connection_strings',
- 'show_silo_connection_strings', 'show_silo_auth', 'show_silo_auth_v2',
- 'show_silo_push_settings', 'show_silo_config_snapshot', 'show_silo_antivirus_log',
- }
- for group, entries in COMMAND_GROUPS.items():
- root_length = 3 if group.startswith('appservice plan') else 2
- words = group.split()
- for length in range(root_length, len(words) + 1):
- subgroup = ' '.join(words[:length])
- helps[subgroup] = (
- 'type: group\nshort-summary: Manage App Service Active Backup {}.\n'
- 'long-summary: Operations use public ARM APIs. Service eligibility and not-found errors are preserved.\n'
- ).format(' '.join(words[root_length:length]) or 'silos')
- for verb, handler_name in entries:
- command = group + ' ' + verb
- parameters = inspect.signature(getattr(active_backup, handler_name)).parameters
- example = 'az ' + command + ' -g MyResourceGroup -n MyPrimary'
- if 'silo' in parameters:
- example += ' --silo MySilo'
- examples = {'location': 'westus2', 'operation_id': '00000000-0000-0000-0000-000000000001',
- 'file': 'silo-output.bin', 'body': '@request.json', 'path': 'api/settings',
- 'instance': 'MyInstance', 'process_id': '123', 'thread_id': '456',
- 'base_address': '0x1000', 'deployment_id': 'MyDeployment',
- 'filename': 'startup.log', 'webjob_name': 'MyJob', 'run_id': 'MyRun',
- 'extension_name': 'MyExtension', 'snapshot_id': '1', 'addon_name': 'MyAddon',
- 'scan_id': 'MyScan', 'container_name': 'MyContainer'}
- for argument, parameter in parameters.items():
- if argument not in ('cmd', 'resource_group_name', 'name', 'silo') and parameter.default is inspect.Parameter.empty:
- example += ' --{} {}'.format(argument.replace('_', '-'), examples[argument])
- if verb == 'create':
- example += ' --primary-location eastus'
- if group == 'webapp silo':
- example += ' --traffic-mode Active'
- if handler_name in ('add_silo_vnet_integration', 'update_silo_vnet_integration'):
- example += (
- ' --subnet /subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/MyNetworkGroup'
- '/providers/Microsoft.Network/virtualNetworks/MyVnet/subnets/MySubnet')
- base_example = example
- if handler_name in EXAMPLE_ARGUMENTS:
- example += ' ' + EXAMPLE_ARGUMENTS[handler_name]
- description = COMMAND_SUMMARIES[handler_name]
- long_summary = 'Calls the public Active Backup API. Service eligibility and errors remain authoritative.'
- if handler_name == 'refresh_site_silos':
- long_summary += (
- ' Returns on HTTP 204 acceptance without waiting for revision convergence or inventing a target revision.')
- if 'no_wait' in parameters:
- long_summary += (
- ' No-wait skips subsequent operation polling, not the initial service request or synchronous service work.')
- if handler_name in sensitive_handlers:
- long_summary += ' Output can contain secrets or sensitive values; protect it.'
- if any(variable in ADDITIONAL_EXAMPLES[handler_name][1] for variable in (
- '$SiteSiloResourceId', '$PlanSiloResourceId', '$PrimaryAppResourceId')):
- long_summary += ' ARM-ID variables in examples represent IDs returned by earlier show/list commands.'
- if handler_name == 'start_silo_network_trace':
- long_summary += ' Set TraceDestinationSas to your trace destination SAS URL; do not log or share it.'
- if handler_name == 'list_silo_startup_logs':
- long_summary += ' Set StartupLogType to the service-supported startup-log filter you need.'
- long_summary += (
- ' Origin point GET 404s can use the matching silo-list entry for metadata, with a warning. '
- 'Origin writes and operation polling are unchanged.'
- if handler_name in ('show_site_silo', 'update_site_silo') else '')
- long_summary += ' See https://github.com/Azure/azure-cli/blob/dev/doc/active_backup_preview.md for details.'
- helps[command] = (
- 'type: command\nshort-summary: {}\n'
- 'long-summary: {}\n'
- 'examples:\n - name: {}\n text: {}\n'
- ).format(description, long_summary, description, example)
- extra_name, extra_text = ADDITIONAL_EXAMPLES[handler_name]
- helps[command] += (
- ' - name: {}\n'
- ' text: {}\n'
- ).format(extra_name, extra_text)
- if 'no_wait' in parameters:
- options = NO_WAIT_EXAMPLE_ARGUMENTS[handler_name]
- no_wait_example = base_example + (' ' + options if options else '')
- helps[command] += (
- ' - name: Skip operation polling after the initial service response.\n'
- ' text: {} --no-wait\n'
- ).format(no_wait_example)
- for group in ('appservice plan silo', 'webapp silo'):
- helps[group] = (
- 'type: group\nshort-summary: Manage App Service Active Backup silos.\n'
- 'long-summary: >-\n'
- ' Preview creation targets Windows/Linux dedicated multitenant PremiumV3, PremiumMV3, '
- 'PremiumV4 and PremiumMV4 plans. P0v3 is excluded. Regional subscription/flavor enablement, '
- 'slot restrictions and dependency ordering are service-enforced. Creating the first silo '
- 'can restart the primary app. Pass-through operations are not filtered by OS or SKU.\n')
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py
index e1eb51b8b2c..7240edab63e 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py
@@ -5,8 +5,6 @@
# --------------------------------------------------------------------------------------------
from knack.help_files import helps # pylint: disable=unused-import
-from ._active_backup_commands import load_help as load_active_backup_help
-load_active_backup_help()
# pylint: disable=line-too-long, too-many-lines
helps['appservice'] = """
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_params.py b/src/azure-cli/azure/cli/command_modules/appservice/_params.py
index d0338488d11..52946dbb083 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py
@@ -52,8 +52,6 @@
def load_arguments(self, _):
- from ._active_backup_commands import load_arguments as load_active_backup_arguments
- load_active_backup_arguments(self, _)
# pylint: disable=line-too-long
# PARAMETER REGISTRATION
name_arg_type = CLIArgumentType(options_list=['--name', '-n'], metavar='NAME')
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_validators.py b/src/azure-cli/azure/cli/command_modules/appservice/_validators.py
index ba623cbb0c0..2b8ffb29050 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/_validators.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/_validators.py
@@ -10,7 +10,7 @@
ResourceNotFoundError, ValidationError, MutuallyExclusiveArgumentError)
from azure.cli.core.commands.client_factory import get_mgmt_service_client, get_subscription_id
from azure.cli.core.commands.validators import validate_tags
-from azure.mgmt.core.tools import is_valid_resource_id, parse_resource_id
+from azure.mgmt.core.tools import is_valid_resource_id, parse_resource_id, resource_id
from knack.log import get_logger
@@ -27,6 +27,34 @@
logger = get_logger(__name__)
+def validate_active_backup_ids(cmd, namespace):
+ """Keep core ID expansion from retargeting another resource type."""
+ parent_type = 'serverfarms' if cmd.name.startswith('appservice plan silo ') else 'sites'
+ singleton = ('managedTrafficConfig' if cmd.name.startswith('webapp managed-traffic ') else
+ 'lastKnownGood' if cmd.name.startswith('webapp last-known-good ') else None)
+ parent_only = bool(singleton) or cmd.name == 'webapp silo refresh'
+ resource_type = parent_type if parent_only else parent_type + '/silos'
+ ids = getattr(namespace, '_ids', None) or getattr(namespace, 'ids', None) or []
+ for value in ids:
+ parts = parse_resource_id(value)
+ required = ('subscription', 'resource_group', 'name') + (() if parent_only else ('child_name_1',))
+ if not is_valid_resource_id(value) or not all(parts.get(key) for key in required):
+ raise InvalidArgumentValueError(
+ "Provide a Microsoft.Web/{} resource ID with --ids.".format(resource_type))
+ parameters = dict(
+ subscription=parts['subscription'], resource_group=parts['resource_group'],
+ namespace='Microsoft.Web', type=parent_type, name=parts['name'],
+ )
+ if not parent_only:
+ parameters.update(child_type_1='silos', child_name_1=parts['child_name_1'])
+ expected = [resource_id(**parameters).lower()]
+ if singleton:
+ expected.append(resource_id(**parameters, child_type_1=singleton, child_name_1='default').lower())
+ if value.rstrip('/').lower() not in expected:
+ raise InvalidArgumentValueError(
+ "Provide a Microsoft.Web/{} resource ID with --ids.".format(resource_type))
+
+
def validate_and_convert_to_int(flag, val):
try:
return int(val)
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/__cmd_group.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/__cmd_group.py
new file mode 100644
index 00000000000..0d253f4f93f
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/__cmd_group.py
@@ -0,0 +1,24 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command_group(
+ "appservice plan silo",
+ is_preview=True,
+)
+class __CMDGroup(AAZCommandGroup):
+ """Manage appservice plan silo resources.
+ """
+ pass
+
+
+__all__ = ["__CMDGroup"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/__init__.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/__init__.py
new file mode 100644
index 00000000000..db73033039b
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/__init__.py
@@ -0,0 +1,17 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from .__cmd_group import *
+from ._create import *
+from ._delete import *
+from ._list import *
+from ._show import *
+from ._update import *
+from ._wait import *
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_create.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_create.py
new file mode 100644
index 00000000000..9742c7a4ba4
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_create.py
@@ -0,0 +1,321 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "appservice plan silo create",
+ is_preview=True,
+)
+class Create(AAZCommand):
+ """Create a silo for an App Service plan.
+
+ :example: Create a secondary plan silo.
+ az appservice plan silo create --resource-group MyGroup --name MyPlan --silo WestPlan --location westus2 --primary-location eastus --number-of-workers 1
+
+ :example: Submit a plan silo create without waiting.
+ az appservice plan silo create --resource-group MyGroup --name MyPlan --silo WestPlan --location westus2 --primary-location eastus --primary-server-farm-name MyPlan --number-of-workers 2 --zone-redundant false --no-wait
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/serverfarms/{}/silos/{}", "2026-08-01"],
+ ]
+ }
+
+ AZ_SUPPORT_NO_WAIT = True
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ return self.build_lro_poller(self._execute_operations, self._output)
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the App Service plan.",
+ required=True,
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ _args_schema.silo = AAZStrArg(
+ options=["--silo"],
+ help="The name of the ServerFarmSilo",
+ required=True,
+ fmt=AAZStrArgFormat(
+ pattern="^[A-Za-z0-9-]{1,24}$",
+ ),
+ )
+
+ # define Arg Group "Properties"
+
+ _args_schema = cls._args_schema
+ _args_schema.number_of_workers = AAZIntArg(
+ options=["--number-of-workers"],
+ arg_group="Properties",
+ help="Instance number override for the silo.",
+ )
+ _args_schema.primary_location = AAZStrArg(
+ options=["--primary-location"],
+ arg_group="Properties",
+ help="Region of the primary App Service plan.",
+ required=True,
+ )
+ _args_schema.primary_server_farm_name = AAZStrArg(
+ options=["--primary-server-farm-name"],
+ arg_group="Properties",
+ help="Name of the primary App Service plan.",
+ )
+ _args_schema.location = AAZStrArg(
+ options=["-l", "--location"],
+ arg_group="Properties",
+ help="ARM location of the App Service plan silo.",
+ required=True,
+ )
+ _args_schema.zone_redundant = AAZBoolArg(
+ options=["--zone-redundant"],
+ arg_group="Properties",
+ help="Zone redundancy override for the silo.",
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ yield self.ServerFarmSiloResourcesCreateOrUpdateServerFarmSilo(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True)
+ return result
+
+ class ServerFarmSiloResourcesCreateOrUpdateServerFarmSilo(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [202]:
+ return self.client.build_lro_polling(
+ self.ctx.args.no_wait,
+ session,
+ self.on_200_201,
+ self.on_error,
+ lro_options={"final-state-via": "azure-async-operation"},
+ path_format_arguments=self.url_parameters,
+ )
+ if session.http_response.status_code in [200, 201]:
+ return self.client.build_lro_polling(
+ self.ctx.args.no_wait,
+ session,
+ self.on_200_201,
+ self.on_error,
+ lro_options={"final-state-via": "azure-async-operation"},
+ path_format_arguments=self.url_parameters,
+ )
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/serverfarms/{name}/silos/{siloName}",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "PUT"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "siloName", self.ctx.args.silo,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Content-Type", "application/json",
+ ),
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ @property
+ def content(self):
+ _content_value, _builder = self.new_content_builder(
+ self.ctx.args,
+ typ=AAZObjectType,
+ typ_kwargs={"flags": {"required": True, "client_flatten": True}}
+ )
+ _builder.set_prop("properties", AAZObjectType)
+
+ properties = _builder.get(".properties")
+ if properties is not None:
+ properties.set_prop("instanceNumberOverride", AAZIntType, ".number_of_workers")
+ properties.set_prop("primaryLocation", AAZStrType, ".primary_location", typ_kwargs={"flags": {"required": True}})
+ properties.set_prop("primaryServerFarmName", AAZStrType, ".primary_server_farm_name")
+ properties.set_prop("siloLocation", AAZStrType, ".location")
+ properties.set_prop("zoneRedundantOverride", AAZBoolType, ".zone_redundant")
+
+ return self.serialize_content(_content_value)
+
+ def on_200_201(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200_201
+ )
+
+ _schema_on_200_201 = None
+
+ @classmethod
+ def _build_schema_on_200_201(cls):
+ if cls._schema_on_200_201 is not None:
+ return cls._schema_on_200_201
+
+ cls._schema_on_200_201 = AAZObjectType()
+
+ _schema_on_200_201 = cls._schema_on_200_201
+ _schema_on_200_201.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200_201.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200_201.properties = AAZObjectType()
+ _schema_on_200_201.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ _schema_on_200_201.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = cls._schema_on_200_201.properties
+ properties.created_time = AAZStrType(
+ serialized_name="createdTime",
+ flags={"read_only": True},
+ )
+ properties.error_code = AAZStrType(
+ serialized_name="errorCode",
+ flags={"read_only": True},
+ )
+ properties.instance_number_override = AAZIntType(
+ serialized_name="instanceNumberOverride",
+ )
+ properties.last_modified_time = AAZStrType(
+ serialized_name="lastModifiedTime",
+ flags={"read_only": True},
+ )
+ properties.primary_location = AAZStrType(
+ serialized_name="primaryLocation",
+ flags={"required": True},
+ )
+ properties.primary_server_farm_name = AAZStrType(
+ serialized_name="primaryServerFarmName",
+ )
+ properties.provisioning_details = AAZStrType(
+ serialized_name="provisioningDetails",
+ flags={"read_only": True},
+ )
+ properties.provisioning_state = AAZStrType(
+ serialized_name="provisioningState",
+ flags={"read_only": True},
+ )
+ properties.silo_location = AAZStrType(
+ serialized_name="siloLocation",
+ )
+ properties.zone_redundant_override = AAZBoolType(
+ serialized_name="zoneRedundantOverride",
+ )
+
+ system_data = cls._schema_on_200_201.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ return cls._schema_on_200_201
+
+
+class _CreateHelper:
+ """Helper class for Create"""
+
+
+__all__ = ["Create"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_delete.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_delete.py
new file mode 100644
index 00000000000..2fd7bf030bb
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_delete.py
@@ -0,0 +1,180 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "appservice plan silo delete",
+ is_preview=True,
+ confirmation="Are you sure you want to perform this operation?",
+)
+class Delete(AAZCommand):
+ """Delete a silo for an App Service plan.
+
+ :example: Delete the silo after removing its dependent resources.
+ az appservice plan silo delete --resource-group MyGroup --name MyPlan --silo WestPlan --yes
+
+ :example: Submit deletion without waiting.
+ az appservice plan silo delete --resource-group MyGroup --name MyPlan --silo WestPlan --yes --no-wait
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/serverfarms/{}/silos/{}", "2026-08-01"],
+ ]
+ }
+
+ AZ_SUPPORT_NO_WAIT = True
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ return self.build_lro_poller(self._execute_operations, None)
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the App Service plan.",
+ required=True,
+ id_part="name",
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ _args_schema.silo = AAZStrArg(
+ options=["--silo"],
+ help="The name of the ServerFarmSilo",
+ required=True,
+ id_part="child_name_1",
+ fmt=AAZStrArgFormat(
+ pattern="^[A-Za-z0-9-]{1,24}$",
+ ),
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ yield self.ServerFarmSiloResourcesDeleteServerFarmSilo(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ class ServerFarmSiloResourcesDeleteServerFarmSilo(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [202]:
+ return self.client.build_lro_polling(
+ self.ctx.args.no_wait,
+ session,
+ self.on_200_201,
+ self.on_error,
+ lro_options={"final-state-via": "location"},
+ path_format_arguments=self.url_parameters,
+ )
+ if session.http_response.status_code in [204]:
+ return self.client.build_lro_polling(
+ self.ctx.args.no_wait,
+ session,
+ self.on_204,
+ self.on_error,
+ lro_options={"final-state-via": "location"},
+ path_format_arguments=self.url_parameters,
+ )
+ if session.http_response.status_code in [200, 201]:
+ return self.client.build_lro_polling(
+ self.ctx.args.no_wait,
+ session,
+ self.on_200_201,
+ self.on_error,
+ lro_options={"final-state-via": "location"},
+ path_format_arguments=self.url_parameters,
+ )
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/serverfarms/{name}/silos/{siloName}",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "DELETE"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "siloName", self.ctx.args.silo,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ def on_204(self, session):
+ pass
+
+ def on_200_201(self, session):
+ pass
+
+
+class _DeleteHelper:
+ """Helper class for Delete"""
+
+
+__all__ = ["Delete"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_list.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_list.py
new file mode 100644
index 00000000000..c9f409a55a1
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_list.py
@@ -0,0 +1,252 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "appservice plan silo list",
+ is_preview=True,
+)
+class List(AAZCommand):
+ """List all silos for an App Service plan.
+
+ :example: List the resources for the primary app or plan.
+ az appservice plan silo list --resource-group MyGroup --name MyPlan
+
+ :example: List resource names.
+ az appservice plan silo list --resource-group MyGroup --name MyPlan --query "[].name" --output tsv
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/serverfarms/{}/silos", "2026-08-01"],
+ ]
+ }
+
+ AZ_SUPPORT_PAGINATION = True
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ return self.build_paging(self._execute_operations, self._output)
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the App Service plan.",
+ required=True,
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.ServerFarmSiloResourcesListServerFarmSilos(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance.value, client_flatten=True)
+ next_link = self.deserialize_output(self.ctx.vars.instance.next_link)
+ return result, next_link
+
+ class ServerFarmSiloResourcesListServerFarmSilos(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200]:
+ return self.on_200(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/serverfarms/{name}/silos",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "GET"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ def on_200(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200
+ )
+
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ if cls._schema_on_200 is not None:
+ return cls._schema_on_200
+
+ cls._schema_on_200 = AAZObjectType()
+
+ _schema_on_200 = cls._schema_on_200
+ _schema_on_200.next_link = AAZStrType(
+ serialized_name="nextLink",
+ )
+ _schema_on_200.value = AAZListType(
+ flags={"required": True},
+ )
+
+ value = cls._schema_on_200.value
+ value.Element = AAZObjectType()
+
+ _element = cls._schema_on_200.value.Element
+ _element.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ _element.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ _element.properties = AAZObjectType()
+ _element.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ _element.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = cls._schema_on_200.value.Element.properties
+ properties.created_time = AAZStrType(
+ serialized_name="createdTime",
+ flags={"read_only": True},
+ )
+ properties.error_code = AAZStrType(
+ serialized_name="errorCode",
+ flags={"read_only": True},
+ )
+ properties.instance_number_override = AAZIntType(
+ serialized_name="instanceNumberOverride",
+ )
+ properties.last_modified_time = AAZStrType(
+ serialized_name="lastModifiedTime",
+ flags={"read_only": True},
+ )
+ properties.primary_location = AAZStrType(
+ serialized_name="primaryLocation",
+ flags={"required": True},
+ )
+ properties.primary_server_farm_name = AAZStrType(
+ serialized_name="primaryServerFarmName",
+ )
+ properties.provisioning_details = AAZStrType(
+ serialized_name="provisioningDetails",
+ flags={"read_only": True},
+ )
+ properties.provisioning_state = AAZStrType(
+ serialized_name="provisioningState",
+ flags={"read_only": True},
+ )
+ properties.silo_location = AAZStrType(
+ serialized_name="siloLocation",
+ )
+ properties.zone_redundant_override = AAZBoolType(
+ serialized_name="zoneRedundantOverride",
+ )
+
+ system_data = cls._schema_on_200.value.Element.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ return cls._schema_on_200
+
+
+class _ListHelper:
+ """Helper class for List"""
+
+
+__all__ = ["List"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_show.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_show.py
new file mode 100644
index 00000000000..ba64ff6c9e3
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_show.py
@@ -0,0 +1,253 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "appservice plan silo show",
+ is_preview=True,
+)
+class Show(AAZCommand):
+ """Get a specific silo for an App Service plan.
+
+ :example: Show the resource for the primary app or plan.
+ az appservice plan silo show --resource-group MyGroup --name MyPlan --silo WestPlan
+
+ :example: Show the resource identity.
+ az appservice plan silo show --resource-group MyGroup --name MyPlan --silo WestPlan --query id --output tsv
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/serverfarms/{}/silos/{}", "2026-08-01"],
+ ]
+ }
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ self._execute_operations()
+ return self._output()
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the App Service plan.",
+ required=True,
+ id_part="name",
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ _args_schema.silo = AAZStrArg(
+ options=["--silo"],
+ help="The name of the ServerFarmSilo",
+ required=True,
+ id_part="child_name_1",
+ fmt=AAZStrArgFormat(
+ pattern="^[A-Za-z0-9-]{1,24}$",
+ ),
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.ServerFarmSiloResourcesGetServerFarmSilo(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True)
+ return result
+
+ class ServerFarmSiloResourcesGetServerFarmSilo(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200]:
+ return self.on_200(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/serverfarms/{name}/silos/{siloName}",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "GET"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "siloName", self.ctx.args.silo,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ def on_200(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200
+ )
+
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ if cls._schema_on_200 is not None:
+ return cls._schema_on_200
+
+ cls._schema_on_200 = AAZObjectType()
+
+ _schema_on_200 = cls._schema_on_200
+ _schema_on_200.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200.properties = AAZObjectType()
+ _schema_on_200.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ _schema_on_200.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = cls._schema_on_200.properties
+ properties.created_time = AAZStrType(
+ serialized_name="createdTime",
+ flags={"read_only": True},
+ )
+ properties.error_code = AAZStrType(
+ serialized_name="errorCode",
+ flags={"read_only": True},
+ )
+ properties.instance_number_override = AAZIntType(
+ serialized_name="instanceNumberOverride",
+ )
+ properties.last_modified_time = AAZStrType(
+ serialized_name="lastModifiedTime",
+ flags={"read_only": True},
+ )
+ properties.primary_location = AAZStrType(
+ serialized_name="primaryLocation",
+ flags={"required": True},
+ )
+ properties.primary_server_farm_name = AAZStrType(
+ serialized_name="primaryServerFarmName",
+ )
+ properties.provisioning_details = AAZStrType(
+ serialized_name="provisioningDetails",
+ flags={"read_only": True},
+ )
+ properties.provisioning_state = AAZStrType(
+ serialized_name="provisioningState",
+ flags={"read_only": True},
+ )
+ properties.silo_location = AAZStrType(
+ serialized_name="siloLocation",
+ )
+ properties.zone_redundant_override = AAZBoolType(
+ serialized_name="zoneRedundantOverride",
+ )
+
+ system_data = cls._schema_on_200.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ return cls._schema_on_200
+
+
+class _ShowHelper:
+ """Helper class for Show"""
+
+
+__all__ = ["Show"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_update.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_update.py
new file mode 100644
index 00000000000..c333f112ded
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_update.py
@@ -0,0 +1,469 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "appservice plan silo update",
+ is_preview=True,
+)
+class Update(AAZCommand):
+ """Update a silo for an App Service plan.
+
+ :example: Change the secondary worker override and retain explicit routing fields.
+ az appservice plan silo update --resource-group MyGroup --name MyPlan --silo WestPlan --number-of-workers 2 --zone-redundant false --location westus2 --primary-location eastus --primary-server-farm-name MyPlan
+
+ :example: Update a plan silo by resource ID.
+ az appservice plan silo update --ids /subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/MyGroup/providers/Microsoft.Web/serverfarms/MyPlan/silos/WestPlan --set properties.instanceNumberOverride=1 --no-wait
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/serverfarms/{}/silos/{}", "2026-08-01"],
+ ]
+ }
+
+ AZ_SUPPORT_NO_WAIT = True
+
+ AZ_SUPPORT_GENERIC_UPDATE = True
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ return self.build_lro_poller(self._execute_operations, self._output)
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the App Service plan.",
+ required=True,
+ id_part="name",
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ _args_schema.silo = AAZStrArg(
+ options=["--silo"],
+ help="The name of the ServerFarmSilo",
+ required=True,
+ id_part="child_name_1",
+ fmt=AAZStrArgFormat(
+ pattern="^[A-Za-z0-9-]{1,24}$",
+ ),
+ )
+
+ # define Arg Group "Properties"
+
+ _args_schema = cls._args_schema
+ _args_schema.number_of_workers = AAZIntArg(
+ options=["--number-of-workers"],
+ arg_group="Properties",
+ help="Instance number override for the silo.",
+ nullable=True,
+ )
+ _args_schema.primary_location = AAZStrArg(
+ options=["--primary-location"],
+ arg_group="Properties",
+ help="Region of the primary App Service plan.",
+ )
+ _args_schema.primary_server_farm_name = AAZStrArg(
+ options=["--primary-server-farm-name"],
+ arg_group="Properties",
+ help="Name of the primary App Service plan.",
+ nullable=True,
+ )
+ _args_schema.location = AAZStrArg(
+ options=["-l", "--location"],
+ arg_group="Properties",
+ help="ARM location of the App Service plan silo.",
+ nullable=True,
+ )
+ _args_schema.zone_redundant = AAZBoolArg(
+ options=["--zone-redundant"],
+ arg_group="Properties",
+ help="Zone redundancy override for the silo.",
+ nullable=True,
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.ServerFarmSiloResourcesGetServerFarmSilo(ctx=self.ctx)()
+ self.pre_instance_update(self.ctx.vars.instance)
+ self.InstanceUpdateByJson(ctx=self.ctx)()
+ self.InstanceUpdateByGeneric(ctx=self.ctx)()
+ self.post_instance_update(self.ctx.vars.instance)
+ yield self.ServerFarmSiloResourcesCreateOrUpdateServerFarmSilo(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ @register_callback
+ def pre_instance_update(self, instance):
+ pass
+
+ @register_callback
+ def post_instance_update(self, instance):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True)
+ return result
+
+ class ServerFarmSiloResourcesGetServerFarmSilo(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200]:
+ return self.on_200(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/serverfarms/{name}/silos/{siloName}",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "GET"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "siloName", self.ctx.args.silo,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ def on_200(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200
+ )
+
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ if cls._schema_on_200 is not None:
+ return cls._schema_on_200
+
+ cls._schema_on_200 = AAZObjectType()
+ _UpdateHelper._build_schema_server_farm_silo_read(cls._schema_on_200)
+
+ return cls._schema_on_200
+
+ class ServerFarmSiloResourcesCreateOrUpdateServerFarmSilo(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [202]:
+ return self.client.build_lro_polling(
+ self.ctx.args.no_wait,
+ session,
+ self.on_200_201,
+ self.on_error,
+ lro_options={"final-state-via": "azure-async-operation"},
+ path_format_arguments=self.url_parameters,
+ )
+ if session.http_response.status_code in [200, 201]:
+ return self.client.build_lro_polling(
+ self.ctx.args.no_wait,
+ session,
+ self.on_200_201,
+ self.on_error,
+ lro_options={"final-state-via": "azure-async-operation"},
+ path_format_arguments=self.url_parameters,
+ )
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/serverfarms/{name}/silos/{siloName}",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "PUT"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "siloName", self.ctx.args.silo,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Content-Type", "application/json",
+ ),
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ @property
+ def content(self):
+ _content_value, _builder = self.new_content_builder(
+ self.ctx.args,
+ value=self.ctx.vars.instance,
+ )
+
+ return self.serialize_content(_content_value)
+
+ def on_200_201(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200_201
+ )
+
+ _schema_on_200_201 = None
+
+ @classmethod
+ def _build_schema_on_200_201(cls):
+ if cls._schema_on_200_201 is not None:
+ return cls._schema_on_200_201
+
+ cls._schema_on_200_201 = AAZObjectType()
+ _UpdateHelper._build_schema_server_farm_silo_read(cls._schema_on_200_201)
+
+ return cls._schema_on_200_201
+
+ class InstanceUpdateByJson(AAZJsonInstanceUpdateOperation):
+
+ def __call__(self, *args, **kwargs):
+ self._update_instance(self.ctx.vars.instance)
+
+ def _update_instance(self, instance):
+ _instance_value, _builder = self.new_content_builder(
+ self.ctx.args,
+ value=instance,
+ typ=AAZObjectType
+ )
+ _builder.set_prop("properties", AAZObjectType)
+
+ properties = _builder.get(".properties")
+ if properties is not None:
+ properties.set_prop("instanceNumberOverride", AAZIntType, ".number_of_workers")
+ properties.set_prop("primaryLocation", AAZStrType, ".primary_location", typ_kwargs={"flags": {"required": True}})
+ properties.set_prop("primaryServerFarmName", AAZStrType, ".primary_server_farm_name")
+ properties.set_prop("siloLocation", AAZStrType, ".location")
+ properties.set_prop("zoneRedundantOverride", AAZBoolType, ".zone_redundant")
+
+ return _instance_value
+
+ class InstanceUpdateByGeneric(AAZGenericInstanceUpdateOperation):
+
+ def __call__(self, *args, **kwargs):
+ self._update_instance_by_generic(
+ self.ctx.vars.instance,
+ self.ctx.generic_update_args
+ )
+
+
+class _UpdateHelper:
+ """Helper class for Update"""
+
+ _schema_server_farm_silo_read = None
+
+ @classmethod
+ def _build_schema_server_farm_silo_read(cls, _schema):
+ if cls._schema_server_farm_silo_read is not None:
+ _schema.id = cls._schema_server_farm_silo_read.id
+ _schema.name = cls._schema_server_farm_silo_read.name
+ _schema.properties = cls._schema_server_farm_silo_read.properties
+ _schema.system_data = cls._schema_server_farm_silo_read.system_data
+ _schema.type = cls._schema_server_farm_silo_read.type
+ return
+
+ cls._schema_server_farm_silo_read = _schema_server_farm_silo_read = AAZObjectType()
+
+ server_farm_silo_read = _schema_server_farm_silo_read
+ server_farm_silo_read.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ server_farm_silo_read.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ server_farm_silo_read.properties = AAZObjectType()
+ server_farm_silo_read.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ server_farm_silo_read.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = _schema_server_farm_silo_read.properties
+ properties.created_time = AAZStrType(
+ serialized_name="createdTime",
+ flags={"read_only": True},
+ )
+ properties.error_code = AAZStrType(
+ serialized_name="errorCode",
+ flags={"read_only": True},
+ )
+ properties.instance_number_override = AAZIntType(
+ serialized_name="instanceNumberOverride",
+ )
+ properties.last_modified_time = AAZStrType(
+ serialized_name="lastModifiedTime",
+ flags={"read_only": True},
+ )
+ properties.primary_location = AAZStrType(
+ serialized_name="primaryLocation",
+ flags={"required": True},
+ )
+ properties.primary_server_farm_name = AAZStrType(
+ serialized_name="primaryServerFarmName",
+ )
+ properties.provisioning_details = AAZStrType(
+ serialized_name="provisioningDetails",
+ flags={"read_only": True},
+ )
+ properties.provisioning_state = AAZStrType(
+ serialized_name="provisioningState",
+ flags={"read_only": True},
+ )
+ properties.silo_location = AAZStrType(
+ serialized_name="siloLocation",
+ )
+ properties.zone_redundant_override = AAZBoolType(
+ serialized_name="zoneRedundantOverride",
+ )
+
+ system_data = _schema_server_farm_silo_read.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ _schema.id = cls._schema_server_farm_silo_read.id
+ _schema.name = cls._schema_server_farm_silo_read.name
+ _schema.properties = cls._schema_server_farm_silo_read.properties
+ _schema.system_data = cls._schema_server_farm_silo_read.system_data
+ _schema.type = cls._schema_server_farm_silo_read.type
+
+
+__all__ = ["Update"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_wait.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_wait.py
new file mode 100644
index 00000000000..e4561bfcfe4
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/_wait.py
@@ -0,0 +1,254 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "appservice plan silo wait",
+ is_preview=True,
+)
+class Wait(AAZWaitCommand):
+ """Place the CLI in a waiting state until a condition is met.
+
+ Poll the silo resource, not a saved operation ID. Content replication, endpoint health and DNS convergence require separate observation.
+
+ :example: Wait for Succeeded provisioning state.
+ az appservice plan silo wait --resource-group MyGroup --name MyPlan --silo WestPlan --created --interval 10 --timeout 1800
+
+ :example: Wait until a deleted silo no longer exists.
+ az appservice plan silo wait --resource-group MyGroup --name MyPlan --silo WestPlan --deleted --interval 10 --timeout 1800
+ """
+
+ _aaz_info = {
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/serverfarms/{}/silos/{}", "2026-08-01"],
+ ]
+ }
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ self._execute_operations()
+ return self._output()
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the App Service plan.",
+ required=True,
+ id_part="name",
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ _args_schema.silo = AAZStrArg(
+ options=["--silo"],
+ help="The name of the ServerFarmSilo",
+ required=True,
+ id_part="child_name_1",
+ fmt=AAZStrArgFormat(
+ pattern="^[A-Za-z0-9-]{1,24}$",
+ ),
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.ServerFarmSiloResourcesGetServerFarmSilo(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance, client_flatten=False)
+ return result
+
+ class ServerFarmSiloResourcesGetServerFarmSilo(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200]:
+ return self.on_200(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/serverfarms/{name}/silos/{siloName}",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "GET"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "siloName", self.ctx.args.silo,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ def on_200(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200
+ )
+
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ if cls._schema_on_200 is not None:
+ return cls._schema_on_200
+
+ cls._schema_on_200 = AAZObjectType()
+
+ _schema_on_200 = cls._schema_on_200
+ _schema_on_200.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200.properties = AAZObjectType()
+ _schema_on_200.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ _schema_on_200.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = cls._schema_on_200.properties
+ properties.created_time = AAZStrType(
+ serialized_name="createdTime",
+ flags={"read_only": True},
+ )
+ properties.error_code = AAZStrType(
+ serialized_name="errorCode",
+ flags={"read_only": True},
+ )
+ properties.instance_number_override = AAZIntType(
+ serialized_name="instanceNumberOverride",
+ )
+ properties.last_modified_time = AAZStrType(
+ serialized_name="lastModifiedTime",
+ flags={"read_only": True},
+ )
+ properties.primary_location = AAZStrType(
+ serialized_name="primaryLocation",
+ flags={"required": True},
+ )
+ properties.primary_server_farm_name = AAZStrType(
+ serialized_name="primaryServerFarmName",
+ )
+ properties.provisioning_details = AAZStrType(
+ serialized_name="provisioningDetails",
+ flags={"read_only": True},
+ )
+ properties.provisioning_state = AAZStrType(
+ serialized_name="provisioningState",
+ flags={"read_only": True},
+ )
+ properties.silo_location = AAZStrType(
+ serialized_name="siloLocation",
+ )
+ properties.zone_redundant_override = AAZBoolType(
+ serialized_name="zoneRedundantOverride",
+ )
+
+ system_data = cls._schema_on_200.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ return cls._schema_on_200
+
+
+class _WaitHelper:
+ """Helper class for Wait"""
+
+
+__all__ = ["Wait"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/vnet_integration/__cmd_group.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/vnet_integration/__cmd_group.py
new file mode 100644
index 00000000000..c575149527b
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/vnet_integration/__cmd_group.py
@@ -0,0 +1,24 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command_group(
+ "appservice plan silo vnet-integration",
+ is_preview=True,
+)
+class __CMDGroup(AAZCommandGroup):
+ """Manage appservice plan silo vnet-integration resources.
+ """
+ pass
+
+
+__all__ = ["__CMDGroup"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/vnet_integration/__init__.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/vnet_integration/__init__.py
new file mode 100644
index 00000000000..d63ae5a6fc9
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/vnet_integration/__init__.py
@@ -0,0 +1,12 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from .__cmd_group import *
+from ._list import *
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/vnet_integration/_list.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/vnet_integration/_list.py
new file mode 100644
index 00000000000..c71dce04d0b
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/appservice/plan/silo/vnet_integration/_list.py
@@ -0,0 +1,227 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "appservice plan silo vnet-integration list",
+ is_preview=True,
+)
+class List(AAZCommand):
+ """Get the virtual network integrations associated with an App Service plan silo.
+
+ :example: List virtual network integrations on the plan silo.
+ az appservice plan silo vnet-integration list --resource-group MyGroup --name MyPlan --silo WestPlan
+
+ :example: List subnet resource IDs.
+ az appservice plan silo vnet-integration list --resource-group MyGroup --name MyPlan --silo WestPlan --query "[].subnetResourceId"
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/serverfarms/{}/silos/{}/virtualnetworkintegrations", "2026-08-01"],
+ ]
+ }
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ self._execute_operations()
+ return self._output()
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the App Service plan.",
+ required=True,
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ _args_schema.silo = AAZStrArg(
+ options=["--silo"],
+ help="The name of the ServerFarmSilo",
+ required=True,
+ fmt=AAZStrArgFormat(
+ pattern="^[A-Za-z0-9-]{1,24}$",
+ ),
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.ServerFarmSiloResourcesGetServerFarmSiloVnetIntegrations(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True)
+ return result
+
+ class ServerFarmSiloResourcesGetServerFarmSiloVnetIntegrations(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200]:
+ return self.on_200(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/serverfarms/{name}/silos/{siloName}/virtualNetworkIntegrations",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "GET"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "siloName", self.ctx.args.silo,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ def on_200(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200
+ )
+
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ if cls._schema_on_200 is not None:
+ return cls._schema_on_200
+
+ cls._schema_on_200 = AAZListType()
+
+ _schema_on_200 = cls._schema_on_200
+ _schema_on_200.Element = AAZObjectType()
+
+ _element = cls._schema_on_200.Element
+ _element.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ _element.kind = AAZStrType()
+ _element.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ _element.properties = AAZObjectType(
+ flags={"client_flatten": True},
+ )
+ _element.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ _element.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = cls._schema_on_200.Element.properties
+ properties.subnet_resource_id = AAZStrType(
+ serialized_name="subnetResourceId",
+ )
+ properties.swift_supported = AAZBoolType(
+ serialized_name="swiftSupported",
+ )
+
+ system_data = cls._schema_on_200.Element.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ return cls._schema_on_200
+
+
+class _ListHelper:
+ """Helper class for List"""
+
+
+__all__ = ["List"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/__cmd_group.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/__cmd_group.py
new file mode 100644
index 00000000000..ed56574f04e
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/__cmd_group.py
@@ -0,0 +1,23 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command_group(
+ "webapp",
+)
+class __CMDGroup(AAZCommandGroup):
+ """Manage webapp resources.
+ """
+ pass
+
+
+__all__ = ["__CMDGroup"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/__init__.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/__init__.py
new file mode 100644
index 00000000000..5a9d61963d6
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/__init__.py
@@ -0,0 +1,11 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from .__cmd_group import *
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/__cmd_group.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/__cmd_group.py
new file mode 100644
index 00000000000..473d0012942
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/__cmd_group.py
@@ -0,0 +1,24 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command_group(
+ "webapp last-known-good",
+ is_preview=True,
+)
+class __CMDGroup(AAZCommandGroup):
+ """Manage webapp last-known-good resources.
+ """
+ pass
+
+
+__all__ = ["__CMDGroup"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/__init__.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/__init__.py
new file mode 100644
index 00000000000..8989cb677c6
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/__init__.py
@@ -0,0 +1,14 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from .__cmd_group import *
+from ._list import *
+from ._mark import *
+from ._show import *
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/_list.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/_list.py
new file mode 100644
index 00000000000..d183411167e
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/_list.py
@@ -0,0 +1,228 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "webapp last-known-good list",
+ is_preview=True,
+)
+class List(AAZCommand):
+ """List the last known good revision state for a Web App.
+
+ :example: List the resources for the primary app or plan.
+ az webapp last-known-good list --resource-group MyGroup --name MyApp
+
+ :example: List resource names.
+ az webapp last-known-good list --resource-group MyGroup --name MyApp --query "[].name" --output tsv
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/sites/{}/lastknowngood", "2026-08-01"],
+ ]
+ }
+
+ AZ_SUPPORT_PAGINATION = True
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ return self.build_paging(self._execute_operations, self._output)
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the app.",
+ required=True,
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.LastKnownGoodResourcesListLastKnownGoods(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance.value, client_flatten=True)
+ next_link = self.deserialize_output(self.ctx.vars.instance.next_link)
+ return result, next_link
+
+ class LastKnownGoodResourcesListLastKnownGoods(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200]:
+ return self.on_200(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/lastKnownGood",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "GET"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ def on_200(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200
+ )
+
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ if cls._schema_on_200 is not None:
+ return cls._schema_on_200
+
+ cls._schema_on_200 = AAZObjectType()
+
+ _schema_on_200 = cls._schema_on_200
+ _schema_on_200.next_link = AAZStrType(
+ serialized_name="nextLink",
+ )
+ _schema_on_200.value = AAZListType(
+ flags={"required": True},
+ )
+
+ value = cls._schema_on_200.value
+ value.Element = AAZObjectType()
+
+ _element = cls._schema_on_200.value.Element
+ _element.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ _element.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ _element.properties = AAZObjectType()
+ _element.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ _element.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = cls._schema_on_200.value.Element.properties
+ properties.created_time = AAZStrType(
+ serialized_name="createdTime",
+ flags={"read_only": True},
+ )
+ properties.revision_number = AAZIntType(
+ serialized_name="revisionNumber",
+ flags={"read_only": True},
+ )
+ properties.site_name = AAZStrType(
+ serialized_name="siteName",
+ flags={"read_only": True},
+ )
+
+ system_data = cls._schema_on_200.value.Element.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ return cls._schema_on_200
+
+
+class _ListHelper:
+ """Helper class for List"""
+
+
+__all__ = ["List"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/_mark.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/_mark.py
new file mode 100644
index 00000000000..76b9f050086
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/_mark.py
@@ -0,0 +1,216 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "webapp last-known-good mark",
+ is_preview=True,
+)
+class Mark(AAZCommand):
+ """Marks the current Web App state as last known good.
+
+ :example: Mark the latest ready revision as last known good.
+ az webapp last-known-good mark --resource-group MyGroup --name MyApp
+
+ :example: Return the revision number selected by the service.
+ az webapp last-known-good mark --resource-group MyGroup --name MyApp --query properties.revisionNumber
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/sites/{}/lastknowngood/default/mark", "2026-08-01"],
+ ]
+ }
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ self._execute_operations()
+ return self._output()
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the app.",
+ required=True,
+ id_part="name",
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.LastKnownGoodResourcesMarkLastKnownGood(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True)
+ return result
+
+ class LastKnownGoodResourcesMarkLastKnownGood(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200]:
+ return self.on_200(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/lastKnownGood/default/mark",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "POST"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ def on_200(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200
+ )
+
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ if cls._schema_on_200 is not None:
+ return cls._schema_on_200
+
+ cls._schema_on_200 = AAZObjectType()
+
+ _schema_on_200 = cls._schema_on_200
+ _schema_on_200.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200.properties = AAZObjectType()
+ _schema_on_200.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ _schema_on_200.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = cls._schema_on_200.properties
+ properties.created_time = AAZStrType(
+ serialized_name="createdTime",
+ flags={"read_only": True},
+ )
+ properties.revision_number = AAZIntType(
+ serialized_name="revisionNumber",
+ flags={"read_only": True},
+ )
+ properties.site_name = AAZStrType(
+ serialized_name="siteName",
+ flags={"read_only": True},
+ )
+
+ system_data = cls._schema_on_200.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ return cls._schema_on_200
+
+
+class _MarkHelper:
+ """Helper class for Mark"""
+
+
+__all__ = ["Mark"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/_show.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/_show.py
new file mode 100644
index 00000000000..2c117435de6
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/last_known_good/_show.py
@@ -0,0 +1,216 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "webapp last-known-good show",
+ is_preview=True,
+)
+class Show(AAZCommand):
+ """Get the last known good revision state for a Web App.
+
+ :example: Show the resource for the primary app or plan.
+ az webapp last-known-good show --resource-group MyGroup --name MyApp
+
+ :example: Show the resource identity.
+ az webapp last-known-good show --resource-group MyGroup --name MyApp --query id --output tsv
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/sites/{}/lastknowngood/default", "2026-08-01"],
+ ]
+ }
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ self._execute_operations()
+ return self._output()
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the app.",
+ required=True,
+ id_part="name",
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.LastKnownGoodResourcesGetLastKnownGood(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True)
+ return result
+
+ class LastKnownGoodResourcesGetLastKnownGood(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200]:
+ return self.on_200(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/lastKnownGood/default",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "GET"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ def on_200(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200
+ )
+
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ if cls._schema_on_200 is not None:
+ return cls._schema_on_200
+
+ cls._schema_on_200 = AAZObjectType()
+
+ _schema_on_200 = cls._schema_on_200
+ _schema_on_200.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200.properties = AAZObjectType()
+ _schema_on_200.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ _schema_on_200.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = cls._schema_on_200.properties
+ properties.created_time = AAZStrType(
+ serialized_name="createdTime",
+ flags={"read_only": True},
+ )
+ properties.revision_number = AAZIntType(
+ serialized_name="revisionNumber",
+ flags={"read_only": True},
+ )
+ properties.site_name = AAZStrType(
+ serialized_name="siteName",
+ flags={"read_only": True},
+ )
+
+ system_data = cls._schema_on_200.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ return cls._schema_on_200
+
+
+class _ShowHelper:
+ """Helper class for Show"""
+
+
+__all__ = ["Show"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/__cmd_group.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/__cmd_group.py
new file mode 100644
index 00000000000..0be42b7c1ca
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/__cmd_group.py
@@ -0,0 +1,24 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command_group(
+ "webapp managed-traffic",
+ is_preview=True,
+)
+class __CMDGroup(AAZCommandGroup):
+ """Manage webapp managed-traffic resources.
+ """
+ pass
+
+
+__all__ = ["__CMDGroup"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/__init__.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/__init__.py
new file mode 100644
index 00000000000..bee768f7653
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/__init__.py
@@ -0,0 +1,14 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from .__cmd_group import *
+from ._list import *
+from ._show import *
+from ._update import *
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/_list.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/_list.py
new file mode 100644
index 00000000000..64a110a5103
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/_list.py
@@ -0,0 +1,240 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "webapp managed-traffic list",
+ is_preview=True,
+)
+class List(AAZCommand):
+ """List managed traffic configuration resources for a Web App.
+
+ :example: List the app's traffic configuration.
+ az webapp managed-traffic list --resource-group MyGroup --name MyApp
+
+ :example: Include persisted endpoint weights and priorities.
+ az webapp managed-traffic list --resource-group MyGroup --name MyApp --expand endpoints --query "[].properties.endpoints"
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/sites/{}/managedtrafficconfig", "2026-08-01"],
+ ]
+ }
+
+ AZ_SUPPORT_PAGINATION = True
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ return self.build_paging(self._execute_operations, self._output)
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the app.",
+ required=True,
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ _args_schema.expand = AAZStrArg(
+ options=["--expand"],
+ help="Set to `endpoints` to return the endpoint collection without projection, including both weight and priority for every endpoint.",
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.ManagedTrafficConfigResourcesListManagedTrafficConfigs(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance.value, client_flatten=True)
+ next_link = self.deserialize_output(self.ctx.vars.instance.next_link)
+ return result, next_link
+
+ class ManagedTrafficConfigResourcesListManagedTrafficConfigs(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200]:
+ return self.on_200(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/managedTrafficConfig",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "GET"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "$expand", self.ctx.args.expand,
+ ),
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ def on_200(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200
+ )
+
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ if cls._schema_on_200 is not None:
+ return cls._schema_on_200
+
+ cls._schema_on_200 = AAZObjectType()
+
+ _schema_on_200 = cls._schema_on_200
+ _schema_on_200.next_link = AAZStrType(
+ serialized_name="nextLink",
+ )
+ _schema_on_200.value = AAZListType(
+ flags={"required": True},
+ )
+
+ value = cls._schema_on_200.value
+ value.Element = AAZObjectType()
+
+ _element = cls._schema_on_200.value.Element
+ _element.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ _element.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ _element.properties = AAZObjectType()
+ _element.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ _element.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = cls._schema_on_200.value.Element.properties
+ properties.endpoints = AAZListType()
+ properties.traffic_mode = AAZStrType(
+ serialized_name="trafficMode",
+ )
+
+ endpoints = cls._schema_on_200.value.Element.properties.endpoints
+ endpoints.Element = AAZObjectType()
+
+ _element = cls._schema_on_200.value.Element.properties.endpoints.Element
+ _element.is_weight_auto_computed = AAZBoolType(
+ serialized_name="isWeightAutoComputed",
+ )
+ _element.priority = AAZIntType()
+ _element.region = AAZStrType(
+ flags={"required": True},
+ )
+ _element.weight = AAZIntType()
+
+ system_data = cls._schema_on_200.value.Element.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ return cls._schema_on_200
+
+
+class _ListHelper:
+ """Helper class for List"""
+
+
+__all__ = ["List"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/_show.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/_show.py
new file mode 100644
index 00000000000..5236d84982b
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/_show.py
@@ -0,0 +1,221 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "webapp managed-traffic show",
+ is_preview=True,
+)
+class Show(AAZCommand):
+ """Get managed traffic configuration for a Web App.
+
+ :example: Show the resource for the primary app or plan.
+ az webapp managed-traffic show --resource-group MyGroup --name MyApp
+
+ :example: Show the resource identity.
+ az webapp managed-traffic show --resource-group MyGroup --name MyApp --query id --output tsv
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/sites/{}/managedtrafficconfig/default", "2026-08-01"],
+ ]
+ }
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ self._execute_operations()
+ return self._output()
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the app.",
+ required=True,
+ id_part="name",
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.ManagedTrafficConfigResourcesGetManagedTrafficConfig(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True)
+ return result
+
+ class ManagedTrafficConfigResourcesGetManagedTrafficConfig(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200]:
+ return self.on_200(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/managedTrafficConfig/default",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "GET"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ def on_200(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200
+ )
+
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ if cls._schema_on_200 is not None:
+ return cls._schema_on_200
+
+ cls._schema_on_200 = AAZObjectType()
+
+ _schema_on_200 = cls._schema_on_200
+ _schema_on_200.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200.properties = AAZObjectType()
+ _schema_on_200.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ _schema_on_200.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = cls._schema_on_200.properties
+ properties.endpoints = AAZListType()
+ properties.traffic_mode = AAZStrType(
+ serialized_name="trafficMode",
+ )
+
+ endpoints = cls._schema_on_200.properties.endpoints
+ endpoints.Element = AAZObjectType()
+
+ _element = cls._schema_on_200.properties.endpoints.Element
+ _element.is_weight_auto_computed = AAZBoolType(
+ serialized_name="isWeightAutoComputed",
+ )
+ _element.priority = AAZIntType()
+ _element.region = AAZStrType(
+ flags={"required": True},
+ )
+ _element.weight = AAZIntType()
+
+ system_data = cls._schema_on_200.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ return cls._schema_on_200
+
+
+class _ShowHelper:
+ """Helper class for Show"""
+
+
+__all__ = ["Show"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/_update.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/_update.py
new file mode 100644
index 00000000000..332e7c6a764
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/managed_traffic/_update.py
@@ -0,0 +1,442 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "webapp managed-traffic update",
+ is_preview=True,
+)
+class Update(AAZCommand):
+ """Update managed traffic configuration for a Web App.
+
+ :example: Use priority-based traffic routing.
+ az webapp managed-traffic update --resource-group MyGroup --name MyApp --traffic-mode Passive --endpoints "[{region:eastus,priority:1},{region:westus2,priority:2}]"
+
+ :example: Set explicit endpoint weights with structured arguments.
+ az webapp managed-traffic update --resource-group MyGroup --name MyApp --traffic-mode Active --endpoints "[{region:eastus,weight:700,is-weight-auto-computed:false},{region:westus2,weight:300}]"
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/sites/{}/managedtrafficconfig/default", "2026-08-01"],
+ ]
+ }
+
+ AZ_SUPPORT_GENERIC_UPDATE = True
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ self._execute_operations()
+ return self._output()
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the app.",
+ required=True,
+ id_part="name",
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+
+ # define Arg Group "Properties"
+
+ _args_schema = cls._args_schema
+ _args_schema.endpoints = AAZListArg(
+ options=["--endpoints"],
+ arg_group="Properties",
+ help="Per-region endpoint configurations controlling weight and priority.",
+ nullable=True,
+ )
+ _args_schema.traffic_mode = AAZStrArg(
+ options=["--traffic-mode"],
+ arg_group="Properties",
+ help="Traffic routing mode: Active (weighted) or Passive (priority-based failover).",
+ nullable=True,
+ enum={"Active": "Active", "Passive": "Passive", "Unknown": "Unknown"},
+ )
+
+ endpoints = cls._args_schema.endpoints
+ endpoints.Element = AAZObjectArg(
+ nullable=True,
+ )
+
+ _element = cls._args_schema.endpoints.Element
+ _element.is_weight_auto_computed = AAZBoolArg(
+ options=["is-weight-auto-computed"],
+ help="Whether the weight was auto-computed from the silo's worker count.",
+ nullable=True,
+ )
+ _element.priority = AAZIntArg(
+ options=["priority"],
+ help="Priority of the endpoint (1-1000). Lower values receive traffic first in priority routing.",
+ nullable=True,
+ fmt=AAZIntArgFormat(
+ maximum=1000,
+ minimum=1,
+ ),
+ )
+ _element.region = AAZStrArg(
+ options=["region"],
+ help="Normalized Azure region name (for example, eastus, westus).",
+ )
+ _element.weight = AAZIntArg(
+ options=["weight"],
+ help="Weight of the endpoint (1-1000). Higher values receive proportionally more traffic in weighted routing.",
+ nullable=True,
+ fmt=AAZIntArgFormat(
+ maximum=1000,
+ minimum=1,
+ ),
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.ManagedTrafficConfigResourcesGetManagedTrafficConfig(ctx=self.ctx)()
+ self.pre_instance_update(self.ctx.vars.instance)
+ self.InstanceUpdateByJson(ctx=self.ctx)()
+ self.InstanceUpdateByGeneric(ctx=self.ctx)()
+ self.post_instance_update(self.ctx.vars.instance)
+ self.ManagedTrafficConfigResourcesUpdateManagedTrafficConfig(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ @register_callback
+ def pre_instance_update(self, instance):
+ pass
+
+ @register_callback
+ def post_instance_update(self, instance):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True)
+ return result
+
+ class ManagedTrafficConfigResourcesGetManagedTrafficConfig(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200]:
+ return self.on_200(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/managedTrafficConfig/default",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "GET"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ def on_200(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200
+ )
+
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ if cls._schema_on_200 is not None:
+ return cls._schema_on_200
+
+ cls._schema_on_200 = AAZObjectType()
+ _UpdateHelper._build_schema_managed_traffic_config_resource_read(cls._schema_on_200)
+
+ return cls._schema_on_200
+
+ class ManagedTrafficConfigResourcesUpdateManagedTrafficConfig(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200, 201]:
+ return self.on_200_201(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/managedTrafficConfig/default",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "PUT"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Content-Type", "application/json",
+ ),
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ @property
+ def content(self):
+ _content_value, _builder = self.new_content_builder(
+ self.ctx.args,
+ value=self.ctx.vars.instance,
+ )
+
+ return self.serialize_content(_content_value)
+
+ def on_200_201(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200_201
+ )
+
+ _schema_on_200_201 = None
+
+ @classmethod
+ def _build_schema_on_200_201(cls):
+ if cls._schema_on_200_201 is not None:
+ return cls._schema_on_200_201
+
+ cls._schema_on_200_201 = AAZObjectType()
+ _UpdateHelper._build_schema_managed_traffic_config_resource_read(cls._schema_on_200_201)
+
+ return cls._schema_on_200_201
+
+ class InstanceUpdateByJson(AAZJsonInstanceUpdateOperation):
+
+ def __call__(self, *args, **kwargs):
+ self._update_instance(self.ctx.vars.instance)
+
+ def _update_instance(self, instance):
+ _instance_value, _builder = self.new_content_builder(
+ self.ctx.args,
+ value=instance,
+ typ=AAZObjectType
+ )
+ _builder.set_prop("properties", AAZObjectType)
+
+ properties = _builder.get(".properties")
+ if properties is not None:
+ properties.set_prop("endpoints", AAZListType, ".endpoints")
+ properties.set_prop("trafficMode", AAZStrType, ".traffic_mode")
+
+ endpoints = _builder.get(".properties.endpoints")
+ if endpoints is not None:
+ endpoints.set_elements(AAZObjectType, ".")
+
+ _elements = _builder.get(".properties.endpoints[]")
+ if _elements is not None:
+ _elements.set_prop("isWeightAutoComputed", AAZBoolType, ".is_weight_auto_computed")
+ _elements.set_prop("priority", AAZIntType, ".priority")
+ _elements.set_prop("region", AAZStrType, ".region", typ_kwargs={"flags": {"required": True}})
+ _elements.set_prop("weight", AAZIntType, ".weight")
+
+ return _instance_value
+
+ class InstanceUpdateByGeneric(AAZGenericInstanceUpdateOperation):
+
+ def __call__(self, *args, **kwargs):
+ self._update_instance_by_generic(
+ self.ctx.vars.instance,
+ self.ctx.generic_update_args
+ )
+
+
+class _UpdateHelper:
+ """Helper class for Update"""
+
+ _schema_managed_traffic_config_resource_read = None
+
+ @classmethod
+ def _build_schema_managed_traffic_config_resource_read(cls, _schema):
+ if cls._schema_managed_traffic_config_resource_read is not None:
+ _schema.id = cls._schema_managed_traffic_config_resource_read.id
+ _schema.name = cls._schema_managed_traffic_config_resource_read.name
+ _schema.properties = cls._schema_managed_traffic_config_resource_read.properties
+ _schema.system_data = cls._schema_managed_traffic_config_resource_read.system_data
+ _schema.type = cls._schema_managed_traffic_config_resource_read.type
+ return
+
+ cls._schema_managed_traffic_config_resource_read = _schema_managed_traffic_config_resource_read = AAZObjectType()
+
+ managed_traffic_config_resource_read = _schema_managed_traffic_config_resource_read
+ managed_traffic_config_resource_read.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ managed_traffic_config_resource_read.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ managed_traffic_config_resource_read.properties = AAZObjectType()
+ managed_traffic_config_resource_read.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ managed_traffic_config_resource_read.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = _schema_managed_traffic_config_resource_read.properties
+ properties.endpoints = AAZListType()
+ properties.traffic_mode = AAZStrType(
+ serialized_name="trafficMode",
+ )
+
+ endpoints = _schema_managed_traffic_config_resource_read.properties.endpoints
+ endpoints.Element = AAZObjectType()
+
+ _element = _schema_managed_traffic_config_resource_read.properties.endpoints.Element
+ _element.is_weight_auto_computed = AAZBoolType(
+ serialized_name="isWeightAutoComputed",
+ )
+ _element.priority = AAZIntType()
+ _element.region = AAZStrType(
+ flags={"required": True},
+ )
+ _element.weight = AAZIntType()
+
+ system_data = _schema_managed_traffic_config_resource_read.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ _schema.id = cls._schema_managed_traffic_config_resource_read.id
+ _schema.name = cls._schema_managed_traffic_config_resource_read.name
+ _schema.properties = cls._schema_managed_traffic_config_resource_read.properties
+ _schema.system_data = cls._schema_managed_traffic_config_resource_read.system_data
+ _schema.type = cls._schema_managed_traffic_config_resource_read.type
+
+
+__all__ = ["Update"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/__cmd_group.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/__cmd_group.py
new file mode 100644
index 00000000000..3b7e1e4616e
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/__cmd_group.py
@@ -0,0 +1,24 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command_group(
+ "webapp silo",
+ is_preview=True,
+)
+class __CMDGroup(AAZCommandGroup):
+ """Manage webapp silo resources.
+ """
+ pass
+
+
+__all__ = ["__CMDGroup"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/__init__.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/__init__.py
new file mode 100644
index 00000000000..06de5aa8813
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/__init__.py
@@ -0,0 +1,18 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from .__cmd_group import *
+from ._create import *
+from ._delete import *
+from ._list import *
+from ._refresh import *
+from ._show import *
+from ._update import *
+from ._wait import *
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_create.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_create.py
new file mode 100644
index 00000000000..5bd04de87fe
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_create.py
@@ -0,0 +1,400 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "webapp silo create",
+ is_preview=True,
+)
+class Create(AAZCommand):
+ """Create a silo for a Web App.
+
+ :example: Create a site silo with active traffic routing.
+ az webapp silo create --resource-group MyGroup --name MyApp --silo WestApp --location westus2 --primary-location eastus --traffic-mode Active --follow-last-known-good false
+
+ :example: Submit a site silo create with a networking override.
+ az webapp silo create --resource-group MyGroup --name MyApp --silo WestApp --location westus2 --primary-location eastus --primary-site-name MyApp --traffic-mode Passive --endpoint-status Enabled --networking-override "{vnet-integration-override:/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/NetworkGroup/providers/Microsoft.Network/virtualNetworks/WestNet/subnets/integration}" --no-wait
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/sites/{}/silos/{}", "2026-08-01"],
+ ]
+ }
+
+ AZ_SUPPORT_NO_WAIT = True
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ return self.build_lro_poller(self._execute_operations, self._output)
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the app.",
+ required=True,
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ _args_schema.silo = AAZStrArg(
+ options=["--silo"],
+ help="The name of the SiteSilo",
+ required=True,
+ fmt=AAZStrArgFormat(
+ pattern="^[A-Za-z0-9-]{1,24}$",
+ ),
+ )
+
+ # define Arg Group "Properties"
+
+ _args_schema = cls._args_schema
+ _args_schema.endpoint_status = AAZStrArg(
+ options=["--endpoint-status"],
+ arg_group="Properties",
+ help="Status of the silo traffic endpoint.",
+ )
+ _args_schema.follow_last_known_good = AAZBoolArg(
+ options=["--follow-last-known-good"],
+ arg_group="Properties",
+ help="Whether the silo should follow the last known good revision.",
+ )
+ _args_schema.networking_override = AAZObjectArg(
+ options=["--networking-override"],
+ arg_group="Properties",
+ help="Networking overrides for the silo.",
+ )
+ _args_schema.primary_location = AAZStrArg(
+ options=["--primary-location"],
+ arg_group="Properties",
+ help="Region of the primary site.",
+ required=True,
+ )
+ _args_schema.primary_site_name = AAZStrArg(
+ options=["--primary-site-name"],
+ arg_group="Properties",
+ help="Name of the primary site.",
+ )
+ _args_schema.location = AAZStrArg(
+ options=["-l", "--location"],
+ arg_group="Properties",
+ help="ARM location of the site silo.",
+ required=True,
+ )
+ _args_schema.traffic_mode = AAZStrArg(
+ options=["--traffic-mode"],
+ arg_group="Properties",
+ help="Traffic mode for the site silo's Traffic Manager profile.",
+ enum={"Active": "Active", "Passive": "Passive", "Unknown": "Unknown"},
+ )
+
+ networking_override = cls._args_schema.networking_override
+ networking_override.vnet_integration_override = AAZStrArg(
+ options=["vnet-integration-override"],
+ help="Virtual network subnet resource ID override for the silo.",
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ yield self.SiteSiloResourcesCreateOrUpdateSiteSilo(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True)
+ return result
+
+ class SiteSiloResourcesCreateOrUpdateSiteSilo(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [202]:
+ return self.client.build_lro_polling(
+ self.ctx.args.no_wait,
+ session,
+ self.on_200_201,
+ self.on_error,
+ lro_options={"final-state-via": "azure-async-operation"},
+ path_format_arguments=self.url_parameters,
+ )
+ if session.http_response.status_code in [200, 201]:
+ return self.client.build_lro_polling(
+ self.ctx.args.no_wait,
+ session,
+ self.on_200_201,
+ self.on_error,
+ lro_options={"final-state-via": "azure-async-operation"},
+ path_format_arguments=self.url_parameters,
+ )
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/silos/{siloName}",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "PUT"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "siloName", self.ctx.args.silo,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Content-Type", "application/json",
+ ),
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ @property
+ def content(self):
+ _content_value, _builder = self.new_content_builder(
+ self.ctx.args,
+ typ=AAZObjectType,
+ typ_kwargs={"flags": {"required": True, "client_flatten": True}}
+ )
+ _builder.set_prop("properties", AAZObjectType)
+
+ properties = _builder.get(".properties")
+ if properties is not None:
+ properties.set_prop("endpointStatus", AAZStrType, ".endpoint_status")
+ properties.set_prop("followLastKnownGood", AAZBoolType, ".follow_last_known_good")
+ properties.set_prop("networkingOverride", AAZObjectType, ".networking_override")
+ properties.set_prop("primaryLocation", AAZStrType, ".primary_location", typ_kwargs={"flags": {"required": True}})
+ properties.set_prop("primarySiteName", AAZStrType, ".primary_site_name")
+ properties.set_prop("siloLocation", AAZStrType, ".location")
+ properties.set_prop("trafficMode", AAZStrType, ".traffic_mode")
+
+ networking_override = _builder.get(".properties.networkingOverride")
+ if networking_override is not None:
+ networking_override.set_prop("vnetIntegrationOverride", AAZStrType, ".vnet_integration_override")
+
+ return self.serialize_content(_content_value)
+
+ def on_200_201(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200_201
+ )
+
+ _schema_on_200_201 = None
+
+ @classmethod
+ def _build_schema_on_200_201(cls):
+ if cls._schema_on_200_201 is not None:
+ return cls._schema_on_200_201
+
+ cls._schema_on_200_201 = AAZObjectType()
+
+ _schema_on_200_201 = cls._schema_on_200_201
+ _schema_on_200_201.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200_201.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200_201.properties = AAZObjectType()
+ _schema_on_200_201.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ _schema_on_200_201.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = cls._schema_on_200_201.properties
+ properties.created_time = AAZStrType(
+ serialized_name="createdTime",
+ flags={"read_only": True},
+ )
+ properties.default_host_name = AAZStrType(
+ serialized_name="defaultHostName",
+ flags={"read_only": True},
+ )
+ properties.endpoint_status = AAZStrType(
+ serialized_name="endpointStatus",
+ )
+ properties.error_code = AAZStrType(
+ serialized_name="errorCode",
+ flags={"read_only": True},
+ )
+ properties.follow_last_known_good = AAZBoolType(
+ serialized_name="followLastKnownGood",
+ )
+ properties.global_host_name = AAZStrType(
+ serialized_name="globalHostName",
+ flags={"read_only": True},
+ )
+ properties.inbound_ip_address = AAZStrType(
+ serialized_name="inboundIpAddress",
+ flags={"read_only": True},
+ )
+ properties.inbound_ipv6_address = AAZStrType(
+ serialized_name="inboundIpv6Address",
+ flags={"read_only": True},
+ )
+ properties.last_modified_time = AAZStrType(
+ serialized_name="lastModifiedTime",
+ flags={"read_only": True},
+ )
+ properties.monitor_status = AAZStrType(
+ serialized_name="monitorStatus",
+ flags={"read_only": True},
+ )
+ properties.networking_override = AAZObjectType(
+ serialized_name="networkingOverride",
+ )
+ properties.outbound_ip_addresses = AAZStrType(
+ serialized_name="outboundIpAddresses",
+ flags={"read_only": True},
+ )
+ properties.outbound_ipv6_addresses = AAZStrType(
+ serialized_name="outboundIpv6Addresses",
+ flags={"read_only": True},
+ )
+ properties.possible_inbound_ip_addresses = AAZStrType(
+ serialized_name="possibleInboundIpAddresses",
+ flags={"read_only": True},
+ )
+ properties.possible_inbound_ipv6_addresses = AAZStrType(
+ serialized_name="possibleInboundIpv6Addresses",
+ flags={"read_only": True},
+ )
+ properties.possible_outbound_ip_addresses = AAZStrType(
+ serialized_name="possibleOutboundIpAddresses",
+ flags={"read_only": True},
+ )
+ properties.possible_outbound_ipv6_addresses = AAZStrType(
+ serialized_name="possibleOutboundIpv6Addresses",
+ flags={"read_only": True},
+ )
+ properties.primary_location = AAZStrType(
+ serialized_name="primaryLocation",
+ flags={"required": True},
+ )
+ properties.primary_site_name = AAZStrType(
+ serialized_name="primarySiteName",
+ )
+ properties.provisioning_details = AAZStrType(
+ serialized_name="provisioningDetails",
+ flags={"read_only": True},
+ )
+ properties.provisioning_state = AAZStrType(
+ serialized_name="provisioningState",
+ flags={"read_only": True},
+ )
+ properties.revision_number = AAZIntType(
+ serialized_name="revisionNumber",
+ flags={"read_only": True},
+ )
+ properties.silo_location = AAZStrType(
+ serialized_name="siloLocation",
+ )
+
+ networking_override = cls._schema_on_200_201.properties.networking_override
+ networking_override.vnet_integration_override = AAZStrType(
+ serialized_name="vnetIntegrationOverride",
+ )
+
+ system_data = cls._schema_on_200_201.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ return cls._schema_on_200_201
+
+
+class _CreateHelper:
+ """Helper class for Create"""
+
+
+__all__ = ["Create"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_delete.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_delete.py
new file mode 100644
index 00000000000..5a2f56143b1
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_delete.py
@@ -0,0 +1,180 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "webapp silo delete",
+ is_preview=True,
+ confirmation="Are you sure you want to perform this operation?",
+)
+class Delete(AAZCommand):
+ """Delete a silo for a Web App.
+
+ :example: Delete the silo after removing its dependent resources.
+ az webapp silo delete --resource-group MyGroup --name MyApp --silo WestApp --yes
+
+ :example: Submit deletion without waiting.
+ az webapp silo delete --resource-group MyGroup --name MyApp --silo WestApp --yes --no-wait
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/sites/{}/silos/{}", "2026-08-01"],
+ ]
+ }
+
+ AZ_SUPPORT_NO_WAIT = True
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ return self.build_lro_poller(self._execute_operations, None)
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the app.",
+ required=True,
+ id_part="name",
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ _args_schema.silo = AAZStrArg(
+ options=["--silo"],
+ help="The name of the SiteSilo",
+ required=True,
+ id_part="child_name_1",
+ fmt=AAZStrArgFormat(
+ pattern="^[A-Za-z0-9-]{1,24}$",
+ ),
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ yield self.SiteSiloResourcesDeleteSiteSilo(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ class SiteSiloResourcesDeleteSiteSilo(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [202]:
+ return self.client.build_lro_polling(
+ self.ctx.args.no_wait,
+ session,
+ self.on_200_201,
+ self.on_error,
+ lro_options={"final-state-via": "location"},
+ path_format_arguments=self.url_parameters,
+ )
+ if session.http_response.status_code in [204]:
+ return self.client.build_lro_polling(
+ self.ctx.args.no_wait,
+ session,
+ self.on_204,
+ self.on_error,
+ lro_options={"final-state-via": "location"},
+ path_format_arguments=self.url_parameters,
+ )
+ if session.http_response.status_code in [200, 201]:
+ return self.client.build_lro_polling(
+ self.ctx.args.no_wait,
+ session,
+ self.on_200_201,
+ self.on_error,
+ lro_options={"final-state-via": "location"},
+ path_format_arguments=self.url_parameters,
+ )
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/silos/{siloName}",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "DELETE"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "siloName", self.ctx.args.silo,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ def on_204(self, session):
+ pass
+
+ def on_200_201(self, session):
+ pass
+
+
+class _DeleteHelper:
+ """Helper class for Delete"""
+
+
+__all__ = ["Delete"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_list.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_list.py
new file mode 100644
index 00000000000..31e3dbffaa6
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_list.py
@@ -0,0 +1,308 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "webapp silo list",
+ is_preview=True,
+)
+class List(AAZCommand):
+ """List all silos for a Web App.
+
+ :example: List the resources for the primary app or plan.
+ az webapp silo list --resource-group MyGroup --name MyApp
+
+ :example: List resource names.
+ az webapp silo list --resource-group MyGroup --name MyApp --query "[].name" --output tsv
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/sites/{}/silos", "2026-08-01"],
+ ]
+ }
+
+ AZ_SUPPORT_PAGINATION = True
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ return self.build_paging(self._execute_operations, self._output)
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the app.",
+ required=True,
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.SiteSiloResourcesListSiteSilos(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance.value, client_flatten=True)
+ next_link = self.deserialize_output(self.ctx.vars.instance.next_link)
+ return result, next_link
+
+ class SiteSiloResourcesListSiteSilos(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200]:
+ return self.on_200(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/silos",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "GET"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ def on_200(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200
+ )
+
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ if cls._schema_on_200 is not None:
+ return cls._schema_on_200
+
+ cls._schema_on_200 = AAZObjectType()
+
+ _schema_on_200 = cls._schema_on_200
+ _schema_on_200.next_link = AAZStrType(
+ serialized_name="nextLink",
+ )
+ _schema_on_200.value = AAZListType(
+ flags={"required": True},
+ )
+
+ value = cls._schema_on_200.value
+ value.Element = AAZObjectType()
+
+ _element = cls._schema_on_200.value.Element
+ _element.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ _element.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ _element.properties = AAZObjectType()
+ _element.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ _element.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = cls._schema_on_200.value.Element.properties
+ properties.created_time = AAZStrType(
+ serialized_name="createdTime",
+ flags={"read_only": True},
+ )
+ properties.default_host_name = AAZStrType(
+ serialized_name="defaultHostName",
+ flags={"read_only": True},
+ )
+ properties.endpoint_status = AAZStrType(
+ serialized_name="endpointStatus",
+ )
+ properties.error_code = AAZStrType(
+ serialized_name="errorCode",
+ flags={"read_only": True},
+ )
+ properties.follow_last_known_good = AAZBoolType(
+ serialized_name="followLastKnownGood",
+ )
+ properties.global_host_name = AAZStrType(
+ serialized_name="globalHostName",
+ flags={"read_only": True},
+ )
+ properties.inbound_ip_address = AAZStrType(
+ serialized_name="inboundIpAddress",
+ flags={"read_only": True},
+ )
+ properties.inbound_ipv6_address = AAZStrType(
+ serialized_name="inboundIpv6Address",
+ flags={"read_only": True},
+ )
+ properties.last_modified_time = AAZStrType(
+ serialized_name="lastModifiedTime",
+ flags={"read_only": True},
+ )
+ properties.monitor_status = AAZStrType(
+ serialized_name="monitorStatus",
+ flags={"read_only": True},
+ )
+ properties.networking_override = AAZObjectType(
+ serialized_name="networkingOverride",
+ )
+ properties.outbound_ip_addresses = AAZStrType(
+ serialized_name="outboundIpAddresses",
+ flags={"read_only": True},
+ )
+ properties.outbound_ipv6_addresses = AAZStrType(
+ serialized_name="outboundIpv6Addresses",
+ flags={"read_only": True},
+ )
+ properties.possible_inbound_ip_addresses = AAZStrType(
+ serialized_name="possibleInboundIpAddresses",
+ flags={"read_only": True},
+ )
+ properties.possible_inbound_ipv6_addresses = AAZStrType(
+ serialized_name="possibleInboundIpv6Addresses",
+ flags={"read_only": True},
+ )
+ properties.possible_outbound_ip_addresses = AAZStrType(
+ serialized_name="possibleOutboundIpAddresses",
+ flags={"read_only": True},
+ )
+ properties.possible_outbound_ipv6_addresses = AAZStrType(
+ serialized_name="possibleOutboundIpv6Addresses",
+ flags={"read_only": True},
+ )
+ properties.primary_location = AAZStrType(
+ serialized_name="primaryLocation",
+ flags={"required": True},
+ )
+ properties.primary_site_name = AAZStrType(
+ serialized_name="primarySiteName",
+ )
+ properties.provisioning_details = AAZStrType(
+ serialized_name="provisioningDetails",
+ flags={"read_only": True},
+ )
+ properties.provisioning_state = AAZStrType(
+ serialized_name="provisioningState",
+ flags={"read_only": True},
+ )
+ properties.revision_number = AAZIntType(
+ serialized_name="revisionNumber",
+ flags={"read_only": True},
+ )
+ properties.silo_location = AAZStrType(
+ serialized_name="siloLocation",
+ )
+
+ networking_override = cls._schema_on_200.value.Element.properties.networking_override
+ networking_override.vnet_integration_override = AAZStrType(
+ serialized_name="vnetIntegrationOverride",
+ )
+
+ system_data = cls._schema_on_200.value.Element.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ return cls._schema_on_200
+
+
+class _ListHelper:
+ """Helper class for List"""
+
+
+__all__ = ["List"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_refresh.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_refresh.py
new file mode 100644
index 00000000000..8c039f3b28d
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_refresh.py
@@ -0,0 +1,137 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "webapp silo refresh",
+ is_preview=True,
+)
+class Refresh(AAZCommand):
+ """Forces a fresh revision for an Active Backup primary Web App and re-syncs all of its silos. The refresh request is recorded synchronously; silos then converge asynchronously — track progress through each silo's revisionNumber.
+
+ :example: Request a fresh revision for the primary app.
+ az webapp silo refresh --resource-group MyGroup --name MyApp
+
+ :example: Request refresh in an explicit subscription.
+ az webapp silo refresh --resource-group MyGroup --name MyApp --subscription 00000000-0000-0000-0000-000000000001
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/sites/{}/refreshsilos", "2026-08-01"],
+ ]
+ }
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ self._execute_operations()
+ return None
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the app.",
+ required=True,
+ id_part="name",
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.SiteSiloResourcesRefreshSiteSilos(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ class SiteSiloResourcesRefreshSiteSilos(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [204]:
+ return self.on_204(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/refreshSilos",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "POST"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ def on_204(self, session):
+ pass
+
+
+class _RefreshHelper:
+ """Helper class for Refresh"""
+
+
+__all__ = ["Refresh"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_show.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_show.py
new file mode 100644
index 00000000000..70037baf7cf
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_show.py
@@ -0,0 +1,309 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "webapp silo show",
+ is_preview=True,
+)
+class Show(AAZCommand):
+ """Get a specific silo for a Web App.
+
+ :example: Show the resource for the primary app or plan.
+ az webapp silo show --resource-group MyGroup --name MyApp --silo WestApp
+
+ :example: Show the resource identity.
+ az webapp silo show --resource-group MyGroup --name MyApp --silo WestApp --query id --output tsv
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/sites/{}/silos/{}", "2026-08-01"],
+ ]
+ }
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ self._execute_operations()
+ return self._output()
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the app.",
+ required=True,
+ id_part="name",
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ _args_schema.silo = AAZStrArg(
+ options=["--silo"],
+ help="The name of the SiteSilo",
+ required=True,
+ id_part="child_name_1",
+ fmt=AAZStrArgFormat(
+ pattern="^[A-Za-z0-9-]{1,24}$",
+ ),
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.SiteSiloResourcesGetSiteSilo(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True)
+ return result
+
+ class SiteSiloResourcesGetSiteSilo(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200]:
+ return self.on_200(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/silos/{siloName}",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "GET"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "siloName", self.ctx.args.silo,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ def on_200(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200
+ )
+
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ if cls._schema_on_200 is not None:
+ return cls._schema_on_200
+
+ cls._schema_on_200 = AAZObjectType()
+
+ _schema_on_200 = cls._schema_on_200
+ _schema_on_200.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200.properties = AAZObjectType()
+ _schema_on_200.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ _schema_on_200.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = cls._schema_on_200.properties
+ properties.created_time = AAZStrType(
+ serialized_name="createdTime",
+ flags={"read_only": True},
+ )
+ properties.default_host_name = AAZStrType(
+ serialized_name="defaultHostName",
+ flags={"read_only": True},
+ )
+ properties.endpoint_status = AAZStrType(
+ serialized_name="endpointStatus",
+ )
+ properties.error_code = AAZStrType(
+ serialized_name="errorCode",
+ flags={"read_only": True},
+ )
+ properties.follow_last_known_good = AAZBoolType(
+ serialized_name="followLastKnownGood",
+ )
+ properties.global_host_name = AAZStrType(
+ serialized_name="globalHostName",
+ flags={"read_only": True},
+ )
+ properties.inbound_ip_address = AAZStrType(
+ serialized_name="inboundIpAddress",
+ flags={"read_only": True},
+ )
+ properties.inbound_ipv6_address = AAZStrType(
+ serialized_name="inboundIpv6Address",
+ flags={"read_only": True},
+ )
+ properties.last_modified_time = AAZStrType(
+ serialized_name="lastModifiedTime",
+ flags={"read_only": True},
+ )
+ properties.monitor_status = AAZStrType(
+ serialized_name="monitorStatus",
+ flags={"read_only": True},
+ )
+ properties.networking_override = AAZObjectType(
+ serialized_name="networkingOverride",
+ )
+ properties.outbound_ip_addresses = AAZStrType(
+ serialized_name="outboundIpAddresses",
+ flags={"read_only": True},
+ )
+ properties.outbound_ipv6_addresses = AAZStrType(
+ serialized_name="outboundIpv6Addresses",
+ flags={"read_only": True},
+ )
+ properties.possible_inbound_ip_addresses = AAZStrType(
+ serialized_name="possibleInboundIpAddresses",
+ flags={"read_only": True},
+ )
+ properties.possible_inbound_ipv6_addresses = AAZStrType(
+ serialized_name="possibleInboundIpv6Addresses",
+ flags={"read_only": True},
+ )
+ properties.possible_outbound_ip_addresses = AAZStrType(
+ serialized_name="possibleOutboundIpAddresses",
+ flags={"read_only": True},
+ )
+ properties.possible_outbound_ipv6_addresses = AAZStrType(
+ serialized_name="possibleOutboundIpv6Addresses",
+ flags={"read_only": True},
+ )
+ properties.primary_location = AAZStrType(
+ serialized_name="primaryLocation",
+ flags={"required": True},
+ )
+ properties.primary_site_name = AAZStrType(
+ serialized_name="primarySiteName",
+ )
+ properties.provisioning_details = AAZStrType(
+ serialized_name="provisioningDetails",
+ flags={"read_only": True},
+ )
+ properties.provisioning_state = AAZStrType(
+ serialized_name="provisioningState",
+ flags={"read_only": True},
+ )
+ properties.revision_number = AAZIntType(
+ serialized_name="revisionNumber",
+ flags={"read_only": True},
+ )
+ properties.silo_location = AAZStrType(
+ serialized_name="siloLocation",
+ )
+
+ networking_override = cls._schema_on_200.properties.networking_override
+ networking_override.vnet_integration_override = AAZStrType(
+ serialized_name="vnetIntegrationOverride",
+ )
+
+ system_data = cls._schema_on_200.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ return cls._schema_on_200
+
+
+class _ShowHelper:
+ """Helper class for Show"""
+
+
+__all__ = ["Show"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_update.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_update.py
new file mode 100644
index 00000000000..845e9c1d8aa
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_update.py
@@ -0,0 +1,543 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "webapp silo update",
+ is_preview=True,
+)
+class Update(AAZCommand):
+ """Update a silo for a Web App.
+
+ :example: Enable the endpoint and follow the last known good revision.
+ az webapp silo update --resource-group MyGroup --name MyApp --silo WestApp --endpoint-status Enabled --follow-last-known-good true --location westus2 --primary-location eastus --primary-site-name MyApp --networking-override "{vnet-integration-override:/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/NetworkGroup/providers/Microsoft.Network/virtualNetworks/WestNet/subnets/integration}"
+
+ :example: Update the revision policy by resource ID.
+ az webapp silo update --ids /subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/MyGroup/providers/Microsoft.Web/sites/MyApp/silos/WestApp --set properties.followLastKnownGood=false --no-wait
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/sites/{}/silos/{}", "2026-08-01"],
+ ]
+ }
+
+ AZ_SUPPORT_NO_WAIT = True
+
+ AZ_SUPPORT_GENERIC_UPDATE = True
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ return self.build_lro_poller(self._execute_operations, self._output)
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the app.",
+ required=True,
+ id_part="name",
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ _args_schema.silo = AAZStrArg(
+ options=["--silo"],
+ help="The name of the SiteSilo",
+ required=True,
+ id_part="child_name_1",
+ fmt=AAZStrArgFormat(
+ pattern="^[A-Za-z0-9-]{1,24}$",
+ ),
+ )
+
+ # define Arg Group "Properties"
+
+ _args_schema = cls._args_schema
+ _args_schema.endpoint_status = AAZStrArg(
+ options=["--endpoint-status"],
+ arg_group="Properties",
+ help="Status of the silo traffic endpoint.",
+ nullable=True,
+ )
+ _args_schema.follow_last_known_good = AAZBoolArg(
+ options=["--follow-last-known-good"],
+ arg_group="Properties",
+ help="Whether the silo should follow the last known good revision.",
+ nullable=True,
+ )
+ _args_schema.networking_override = AAZObjectArg(
+ options=["--networking-override"],
+ arg_group="Properties",
+ help="Networking overrides for the silo.",
+ nullable=True,
+ )
+ _args_schema.primary_location = AAZStrArg(
+ options=["--primary-location"],
+ arg_group="Properties",
+ help="Region of the primary site.",
+ )
+ _args_schema.primary_site_name = AAZStrArg(
+ options=["--primary-site-name"],
+ arg_group="Properties",
+ help="Name of the primary site.",
+ nullable=True,
+ )
+ _args_schema.location = AAZStrArg(
+ options=["-l", "--location"],
+ arg_group="Properties",
+ help="ARM location of the site silo.",
+ nullable=True,
+ )
+
+ networking_override = cls._args_schema.networking_override
+ networking_override.vnet_integration_override = AAZStrArg(
+ options=["vnet-integration-override"],
+ help="Virtual network subnet resource ID override for the silo.",
+ nullable=True,
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.SiteSiloResourcesGetSiteSilo(ctx=self.ctx)()
+ self.pre_instance_update(self.ctx.vars.instance)
+ self.InstanceUpdateByJson(ctx=self.ctx)()
+ self.InstanceUpdateByGeneric(ctx=self.ctx)()
+ self.post_instance_update(self.ctx.vars.instance)
+ yield self.SiteSiloResourcesCreateOrUpdateSiteSilo(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ @register_callback
+ def pre_instance_update(self, instance):
+ pass
+
+ @register_callback
+ def post_instance_update(self, instance):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True)
+ return result
+
+ class SiteSiloResourcesGetSiteSilo(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200]:
+ return self.on_200(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/silos/{siloName}",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "GET"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "siloName", self.ctx.args.silo,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ def on_200(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200
+ )
+
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ if cls._schema_on_200 is not None:
+ return cls._schema_on_200
+
+ cls._schema_on_200 = AAZObjectType()
+ _UpdateHelper._build_schema_site_silo_read(cls._schema_on_200)
+
+ return cls._schema_on_200
+
+ class SiteSiloResourcesCreateOrUpdateSiteSilo(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [202]:
+ return self.client.build_lro_polling(
+ self.ctx.args.no_wait,
+ session,
+ self.on_200_201,
+ self.on_error,
+ lro_options={"final-state-via": "azure-async-operation"},
+ path_format_arguments=self.url_parameters,
+ )
+ if session.http_response.status_code in [200, 201]:
+ return self.client.build_lro_polling(
+ self.ctx.args.no_wait,
+ session,
+ self.on_200_201,
+ self.on_error,
+ lro_options={"final-state-via": "azure-async-operation"},
+ path_format_arguments=self.url_parameters,
+ )
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/silos/{siloName}",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "PUT"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "siloName", self.ctx.args.silo,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Content-Type", "application/json",
+ ),
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ @property
+ def content(self):
+ _content_value, _builder = self.new_content_builder(
+ self.ctx.args,
+ value=self.ctx.vars.instance,
+ )
+
+ return self.serialize_content(_content_value)
+
+ def on_200_201(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200_201
+ )
+
+ _schema_on_200_201 = None
+
+ @classmethod
+ def _build_schema_on_200_201(cls):
+ if cls._schema_on_200_201 is not None:
+ return cls._schema_on_200_201
+
+ cls._schema_on_200_201 = AAZObjectType()
+ _UpdateHelper._build_schema_site_silo_read(cls._schema_on_200_201)
+
+ return cls._schema_on_200_201
+
+ class InstanceUpdateByJson(AAZJsonInstanceUpdateOperation):
+
+ def __call__(self, *args, **kwargs):
+ self._update_instance(self.ctx.vars.instance)
+
+ def _update_instance(self, instance):
+ _instance_value, _builder = self.new_content_builder(
+ self.ctx.args,
+ value=instance,
+ typ=AAZObjectType
+ )
+ _builder.set_prop("properties", AAZObjectType)
+
+ properties = _builder.get(".properties")
+ if properties is not None:
+ properties.set_prop("endpointStatus", AAZStrType, ".endpoint_status")
+ properties.set_prop("followLastKnownGood", AAZBoolType, ".follow_last_known_good")
+ properties.set_prop("networkingOverride", AAZObjectType, ".networking_override")
+ properties.set_prop("primaryLocation", AAZStrType, ".primary_location", typ_kwargs={"flags": {"required": True}})
+ properties.set_prop("primarySiteName", AAZStrType, ".primary_site_name")
+ properties.set_prop("siloLocation", AAZStrType, ".location")
+
+ networking_override = _builder.get(".properties.networkingOverride")
+ if networking_override is not None:
+ networking_override.set_prop("vnetIntegrationOverride", AAZStrType, ".vnet_integration_override")
+
+ return _instance_value
+
+ class InstanceUpdateByGeneric(AAZGenericInstanceUpdateOperation):
+
+ def __call__(self, *args, **kwargs):
+ self._update_instance_by_generic(
+ self.ctx.vars.instance,
+ self.ctx.generic_update_args
+ )
+
+
+class _UpdateHelper:
+ """Helper class for Update"""
+
+ _schema_site_silo_read = None
+
+ @classmethod
+ def _build_schema_site_silo_read(cls, _schema):
+ if cls._schema_site_silo_read is not None:
+ _schema.id = cls._schema_site_silo_read.id
+ _schema.name = cls._schema_site_silo_read.name
+ _schema.properties = cls._schema_site_silo_read.properties
+ _schema.system_data = cls._schema_site_silo_read.system_data
+ _schema.type = cls._schema_site_silo_read.type
+ return
+
+ cls._schema_site_silo_read = _schema_site_silo_read = AAZObjectType()
+
+ site_silo_read = _schema_site_silo_read
+ site_silo_read.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ site_silo_read.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ site_silo_read.properties = AAZObjectType()
+ site_silo_read.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ site_silo_read.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = _schema_site_silo_read.properties
+ properties.created_time = AAZStrType(
+ serialized_name="createdTime",
+ flags={"read_only": True},
+ )
+ properties.default_host_name = AAZStrType(
+ serialized_name="defaultHostName",
+ flags={"read_only": True},
+ )
+ properties.endpoint_status = AAZStrType(
+ serialized_name="endpointStatus",
+ )
+ properties.error_code = AAZStrType(
+ serialized_name="errorCode",
+ flags={"read_only": True},
+ )
+ properties.follow_last_known_good = AAZBoolType(
+ serialized_name="followLastKnownGood",
+ )
+ properties.global_host_name = AAZStrType(
+ serialized_name="globalHostName",
+ flags={"read_only": True},
+ )
+ properties.inbound_ip_address = AAZStrType(
+ serialized_name="inboundIpAddress",
+ flags={"read_only": True},
+ )
+ properties.inbound_ipv6_address = AAZStrType(
+ serialized_name="inboundIpv6Address",
+ flags={"read_only": True},
+ )
+ properties.last_modified_time = AAZStrType(
+ serialized_name="lastModifiedTime",
+ flags={"read_only": True},
+ )
+ properties.monitor_status = AAZStrType(
+ serialized_name="monitorStatus",
+ flags={"read_only": True},
+ )
+ properties.networking_override = AAZObjectType(
+ serialized_name="networkingOverride",
+ )
+ properties.outbound_ip_addresses = AAZStrType(
+ serialized_name="outboundIpAddresses",
+ flags={"read_only": True},
+ )
+ properties.outbound_ipv6_addresses = AAZStrType(
+ serialized_name="outboundIpv6Addresses",
+ flags={"read_only": True},
+ )
+ properties.possible_inbound_ip_addresses = AAZStrType(
+ serialized_name="possibleInboundIpAddresses",
+ flags={"read_only": True},
+ )
+ properties.possible_inbound_ipv6_addresses = AAZStrType(
+ serialized_name="possibleInboundIpv6Addresses",
+ flags={"read_only": True},
+ )
+ properties.possible_outbound_ip_addresses = AAZStrType(
+ serialized_name="possibleOutboundIpAddresses",
+ flags={"read_only": True},
+ )
+ properties.possible_outbound_ipv6_addresses = AAZStrType(
+ serialized_name="possibleOutboundIpv6Addresses",
+ flags={"read_only": True},
+ )
+ properties.primary_location = AAZStrType(
+ serialized_name="primaryLocation",
+ flags={"required": True},
+ )
+ properties.primary_site_name = AAZStrType(
+ serialized_name="primarySiteName",
+ )
+ properties.provisioning_details = AAZStrType(
+ serialized_name="provisioningDetails",
+ flags={"read_only": True},
+ )
+ properties.provisioning_state = AAZStrType(
+ serialized_name="provisioningState",
+ flags={"read_only": True},
+ )
+ properties.revision_number = AAZIntType(
+ serialized_name="revisionNumber",
+ flags={"read_only": True},
+ )
+ properties.silo_location = AAZStrType(
+ serialized_name="siloLocation",
+ )
+
+ networking_override = _schema_site_silo_read.properties.networking_override
+ networking_override.vnet_integration_override = AAZStrType(
+ serialized_name="vnetIntegrationOverride",
+ )
+
+ system_data = _schema_site_silo_read.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ _schema.id = cls._schema_site_silo_read.id
+ _schema.name = cls._schema_site_silo_read.name
+ _schema.properties = cls._schema_site_silo_read.properties
+ _schema.system_data = cls._schema_site_silo_read.system_data
+ _schema.type = cls._schema_site_silo_read.type
+
+
+__all__ = ["Update"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_wait.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_wait.py
new file mode 100644
index 00000000000..f6c96e4ad1b
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/_wait.py
@@ -0,0 +1,310 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "webapp silo wait",
+ is_preview=True,
+)
+class Wait(AAZWaitCommand):
+ """Place the CLI in a waiting state until a condition is met.
+
+ Poll the silo resource, not a saved operation ID. Content replication, endpoint health and DNS convergence require separate observation.
+
+ :example: Wait for Succeeded provisioning state.
+ az webapp silo wait --resource-group MyGroup --name MyApp --silo WestApp --created --interval 10 --timeout 1800
+
+ :example: Wait until a deleted silo no longer exists.
+ az webapp silo wait --resource-group MyGroup --name MyApp --silo WestApp --deleted --interval 10 --timeout 1800
+ """
+
+ _aaz_info = {
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/sites/{}/silos/{}", "2026-08-01"],
+ ]
+ }
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ self._execute_operations()
+ return self._output()
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the app.",
+ required=True,
+ id_part="name",
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ _args_schema.silo = AAZStrArg(
+ options=["--silo"],
+ help="The name of the SiteSilo",
+ required=True,
+ id_part="child_name_1",
+ fmt=AAZStrArgFormat(
+ pattern="^[A-Za-z0-9-]{1,24}$",
+ ),
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.SiteSiloResourcesGetSiteSilo(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance, client_flatten=False)
+ return result
+
+ class SiteSiloResourcesGetSiteSilo(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200]:
+ return self.on_200(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/silos/{siloName}",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "GET"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "siloName", self.ctx.args.silo,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ def on_200(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200
+ )
+
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ if cls._schema_on_200 is not None:
+ return cls._schema_on_200
+
+ cls._schema_on_200 = AAZObjectType()
+
+ _schema_on_200 = cls._schema_on_200
+ _schema_on_200.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200.properties = AAZObjectType()
+ _schema_on_200.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ _schema_on_200.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = cls._schema_on_200.properties
+ properties.created_time = AAZStrType(
+ serialized_name="createdTime",
+ flags={"read_only": True},
+ )
+ properties.default_host_name = AAZStrType(
+ serialized_name="defaultHostName",
+ flags={"read_only": True},
+ )
+ properties.endpoint_status = AAZStrType(
+ serialized_name="endpointStatus",
+ )
+ properties.error_code = AAZStrType(
+ serialized_name="errorCode",
+ flags={"read_only": True},
+ )
+ properties.follow_last_known_good = AAZBoolType(
+ serialized_name="followLastKnownGood",
+ )
+ properties.global_host_name = AAZStrType(
+ serialized_name="globalHostName",
+ flags={"read_only": True},
+ )
+ properties.inbound_ip_address = AAZStrType(
+ serialized_name="inboundIpAddress",
+ flags={"read_only": True},
+ )
+ properties.inbound_ipv6_address = AAZStrType(
+ serialized_name="inboundIpv6Address",
+ flags={"read_only": True},
+ )
+ properties.last_modified_time = AAZStrType(
+ serialized_name="lastModifiedTime",
+ flags={"read_only": True},
+ )
+ properties.monitor_status = AAZStrType(
+ serialized_name="monitorStatus",
+ flags={"read_only": True},
+ )
+ properties.networking_override = AAZObjectType(
+ serialized_name="networkingOverride",
+ )
+ properties.outbound_ip_addresses = AAZStrType(
+ serialized_name="outboundIpAddresses",
+ flags={"read_only": True},
+ )
+ properties.outbound_ipv6_addresses = AAZStrType(
+ serialized_name="outboundIpv6Addresses",
+ flags={"read_only": True},
+ )
+ properties.possible_inbound_ip_addresses = AAZStrType(
+ serialized_name="possibleInboundIpAddresses",
+ flags={"read_only": True},
+ )
+ properties.possible_inbound_ipv6_addresses = AAZStrType(
+ serialized_name="possibleInboundIpv6Addresses",
+ flags={"read_only": True},
+ )
+ properties.possible_outbound_ip_addresses = AAZStrType(
+ serialized_name="possibleOutboundIpAddresses",
+ flags={"read_only": True},
+ )
+ properties.possible_outbound_ipv6_addresses = AAZStrType(
+ serialized_name="possibleOutboundIpv6Addresses",
+ flags={"read_only": True},
+ )
+ properties.primary_location = AAZStrType(
+ serialized_name="primaryLocation",
+ flags={"required": True},
+ )
+ properties.primary_site_name = AAZStrType(
+ serialized_name="primarySiteName",
+ )
+ properties.provisioning_details = AAZStrType(
+ serialized_name="provisioningDetails",
+ flags={"read_only": True},
+ )
+ properties.provisioning_state = AAZStrType(
+ serialized_name="provisioningState",
+ flags={"read_only": True},
+ )
+ properties.revision_number = AAZIntType(
+ serialized_name="revisionNumber",
+ flags={"read_only": True},
+ )
+ properties.silo_location = AAZStrType(
+ serialized_name="siloLocation",
+ )
+
+ networking_override = cls._schema_on_200.properties.networking_override
+ networking_override.vnet_integration_override = AAZStrType(
+ serialized_name="vnetIntegrationOverride",
+ )
+
+ system_data = cls._schema_on_200.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ return cls._schema_on_200
+
+
+class _WaitHelper:
+ """Helper class for Wait"""
+
+
+__all__ = ["Wait"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/__cmd_group.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/__cmd_group.py
new file mode 100644
index 00000000000..1d7762919d0
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/__cmd_group.py
@@ -0,0 +1,24 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command_group(
+ "webapp silo vnet-integration",
+ is_preview=True,
+)
+class __CMDGroup(AAZCommandGroup):
+ """Manage webapp silo vnet-integration resources.
+ """
+ pass
+
+
+__all__ = ["__CMDGroup"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/__init__.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/__init__.py
new file mode 100644
index 00000000000..1f70447eedf
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/__init__.py
@@ -0,0 +1,15 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from .__cmd_group import *
+from ._add import *
+from ._remove import *
+from ._show import *
+from ._update import *
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/_add.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/_add.py
new file mode 100644
index 00000000000..8cfac5c62a5
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/_add.py
@@ -0,0 +1,269 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "webapp silo vnet-integration add",
+ is_preview=True,
+)
+class Add(AAZCommand):
+ """Create a virtual network connection to a silo, or updates the connection properties.
+
+ :example: Connect a site silo to a delegated subnet.
+ az webapp silo vnet-integration add --resource-group MyGroup --name MyApp --silo WestApp --subnet /subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/NetworkGroup/providers/Microsoft.Network/virtualNetworks/WestNet/subnets/integration
+
+ :example: Set the connection properties explicitly.
+ az webapp silo vnet-integration add --resource-group MyGroup --name MyApp --silo WestApp --subnet /subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/NetworkGroup/providers/Microsoft.Network/virtualNetworks/WestNet/subnets/integration --swift-supported true --kind app
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/sites/{}/silos/{}/virtualnetworkconnections/virtualnetwork", "2026-08-01"],
+ ]
+ }
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ self._execute_operations()
+ return self._output()
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the app.",
+ required=True,
+ id_part="name",
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ _args_schema.silo = AAZStrArg(
+ options=["--silo"],
+ help="Name of the silo.",
+ required=True,
+ id_part="child_name_1",
+ fmt=AAZStrArgFormat(
+ pattern="^[A-Za-z0-9-]{1,24}$",
+ ),
+ )
+
+ # define Arg Group "Properties"
+
+ _args_schema = cls._args_schema
+ _args_schema.subnet = AAZStrArg(
+ options=["--subnet"],
+ arg_group="Properties",
+ help="The Virtual Network subnet's resource ID. This is the subnet that this Web App will join. This subnet must have a delegation to Microsoft.Web/serverFarms defined first.",
+ )
+ _args_schema.swift_supported = AAZBoolArg(
+ options=["--swift-supported"],
+ arg_group="Properties",
+ help="A flag that specifies if the scale unit this Web App is on supports Swift integration.",
+ )
+
+ # define Arg Group "Resource"
+
+ _args_schema = cls._args_schema
+ _args_schema.kind = AAZStrArg(
+ options=["--kind"],
+ arg_group="Resource",
+ help="Kind of resource.",
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.SiteSiloVirtualNetworkConnectionsCreateOrUpdateSiteSiloVnetConnection(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True)
+ return result
+
+ class SiteSiloVirtualNetworkConnectionsCreateOrUpdateSiteSiloVnetConnection(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200, 201]:
+ return self.on_200_201(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/silos/{siloName}/virtualNetworkConnections/virtualNetwork",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "PUT"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "siloName", self.ctx.args.silo,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Content-Type", "application/json",
+ ),
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ @property
+ def content(self):
+ _content_value, _builder = self.new_content_builder(
+ self.ctx.args,
+ typ=AAZObjectType,
+ typ_kwargs={"flags": {"required": True, "client_flatten": True}}
+ )
+ _builder.set_prop("kind", AAZStrType, ".kind")
+ _builder.set_prop("properties", AAZObjectType, typ_kwargs={"flags": {"client_flatten": True}})
+
+ properties = _builder.get(".properties")
+ if properties is not None:
+ properties.set_prop("subnetResourceId", AAZStrType, ".subnet")
+ properties.set_prop("swiftSupported", AAZBoolType, ".swift_supported")
+
+ return self.serialize_content(_content_value)
+
+ def on_200_201(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200_201
+ )
+
+ _schema_on_200_201 = None
+
+ @classmethod
+ def _build_schema_on_200_201(cls):
+ if cls._schema_on_200_201 is not None:
+ return cls._schema_on_200_201
+
+ cls._schema_on_200_201 = AAZObjectType()
+
+ _schema_on_200_201 = cls._schema_on_200_201
+ _schema_on_200_201.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200_201.kind = AAZStrType()
+ _schema_on_200_201.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200_201.properties = AAZObjectType(
+ flags={"client_flatten": True},
+ )
+ _schema_on_200_201.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ _schema_on_200_201.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = cls._schema_on_200_201.properties
+ properties.subnet_resource_id = AAZStrType(
+ serialized_name="subnetResourceId",
+ )
+ properties.swift_supported = AAZBoolType(
+ serialized_name="swiftSupported",
+ )
+
+ system_data = cls._schema_on_200_201.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ return cls._schema_on_200_201
+
+
+class _AddHelper:
+ """Helper class for Add"""
+
+
+__all__ = ["Add"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/_remove.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/_remove.py
new file mode 100644
index 00000000000..12c9b1c254c
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/_remove.py
@@ -0,0 +1,156 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "webapp silo vnet-integration remove",
+ is_preview=True,
+ confirmation="Are you sure you want to perform this operation?",
+)
+class Remove(AAZCommand):
+ """Delete the virtual network connection from a silo.
+
+ :example: Disconnect a site silo from its virtual network.
+ az webapp silo vnet-integration remove --resource-group MyGroup --name MyApp --silo WestApp --yes
+
+ :example: Disconnect using a silo resource ID.
+ az webapp silo vnet-integration remove --ids /subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/MyGroup/providers/Microsoft.Web/sites/MyApp/silos/WestApp --yes
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/sites/{}/silos/{}/virtualnetworkconnections/virtualnetwork", "2026-08-01"],
+ ]
+ }
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ self._execute_operations()
+ return None
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the app.",
+ required=True,
+ id_part="name",
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ _args_schema.silo = AAZStrArg(
+ options=["--silo"],
+ help="Name of the silo.",
+ required=True,
+ id_part="child_name_1",
+ fmt=AAZStrArgFormat(
+ pattern="^[A-Za-z0-9-]{1,24}$",
+ ),
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.SiteSiloVirtualNetworkConnectionsDeleteSiteSiloVnetConnection(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ class SiteSiloVirtualNetworkConnectionsDeleteSiteSiloVnetConnection(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200]:
+ return self.on_200(session)
+ if session.http_response.status_code in [204]:
+ return self.on_204(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/silos/{siloName}/virtualNetworkConnections/virtualNetwork",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "DELETE"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "siloName", self.ctx.args.silo,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ def on_200(self, session):
+ pass
+
+ def on_204(self, session):
+ pass
+
+
+class _RemoveHelper:
+ """Helper class for Remove"""
+
+
+__all__ = ["Remove"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/_show.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/_show.py
new file mode 100644
index 00000000000..3c210a07c90
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/_show.py
@@ -0,0 +1,226 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "webapp silo vnet-integration show",
+ is_preview=True,
+)
+class Show(AAZCommand):
+ """Get the virtual network the silo is connected to.
+
+ :example: Show the resource for the primary app or plan.
+ az webapp silo vnet-integration show --resource-group MyGroup --name MyApp --silo WestApp
+
+ :example: Show the resource identity.
+ az webapp silo vnet-integration show --resource-group MyGroup --name MyApp --silo WestApp --query id --output tsv
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/sites/{}/silos/{}/virtualnetworkconnections/virtualnetwork", "2026-08-01"],
+ ]
+ }
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ self._execute_operations()
+ return self._output()
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the app.",
+ required=True,
+ id_part="name",
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ _args_schema.silo = AAZStrArg(
+ options=["--silo"],
+ help="Name of the silo.",
+ required=True,
+ id_part="child_name_1",
+ fmt=AAZStrArgFormat(
+ pattern="^[A-Za-z0-9-]{1,24}$",
+ ),
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.SiteSiloVirtualNetworkConnectionsGetSiteSiloVnetConnection(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True)
+ return result
+
+ class SiteSiloVirtualNetworkConnectionsGetSiteSiloVnetConnection(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200]:
+ return self.on_200(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/silos/{siloName}/virtualNetworkConnections/virtualNetwork",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "GET"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "siloName", self.ctx.args.silo,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ def on_200(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200
+ )
+
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ if cls._schema_on_200 is not None:
+ return cls._schema_on_200
+
+ cls._schema_on_200 = AAZObjectType()
+
+ _schema_on_200 = cls._schema_on_200
+ _schema_on_200.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200.kind = AAZStrType()
+ _schema_on_200.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200.properties = AAZObjectType(
+ flags={"client_flatten": True},
+ )
+ _schema_on_200.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ _schema_on_200.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = cls._schema_on_200.properties
+ properties.subnet_resource_id = AAZStrType(
+ serialized_name="subnetResourceId",
+ )
+ properties.swift_supported = AAZBoolType(
+ serialized_name="swiftSupported",
+ )
+
+ system_data = cls._schema_on_200.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ return cls._schema_on_200
+
+
+class _ShowHelper:
+ """Helper class for Show"""
+
+
+__all__ = ["Show"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/_update.py b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/_update.py
new file mode 100644
index 00000000000..c392bc870a9
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/aaz/latest/webapp/silo/vnet_integration/_update.py
@@ -0,0 +1,265 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+#
+# Code generated by aaz-dev-tools
+# --------------------------------------------------------------------------------------------
+
+# pylint: skip-file
+# flake8: noqa
+
+from azure.cli.core.aaz import *
+
+
+@register_command(
+ "webapp silo vnet-integration update",
+ is_preview=True,
+)
+class Update(AAZCommand):
+ """Update the properties of a virtual network connection on a silo.
+
+ :example: Update the silo's subnet connection using PATCH.
+ az webapp silo vnet-integration update --resource-group MyGroup --name MyApp --silo WestApp --subnet /subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/NetworkGroup/providers/Microsoft.Network/virtualNetworks/WestNet/subnets/integration
+
+ :example: Update the supported connection flag.
+ az webapp silo vnet-integration update --resource-group MyGroup --name MyApp --silo WestApp --swift-supported false --kind app
+ """
+
+ _aaz_info = {
+ "version": "2026-08-01",
+ "resources": [
+ ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.web/sites/{}/silos/{}/virtualnetworkconnections/virtualnetwork", "2026-08-01"],
+ ]
+ }
+
+ def _handler(self, command_args):
+ super()._handler(command_args)
+ self._execute_operations()
+ return self._output()
+
+ _args_schema = None
+
+ @classmethod
+ def _build_arguments_schema(cls, *args, **kwargs):
+ if cls._args_schema is not None:
+ return cls._args_schema
+ cls._args_schema = super()._build_arguments_schema(*args, **kwargs)
+
+ # define Arg Group ""
+
+ _args_schema = cls._args_schema
+ _args_schema.name = AAZStrArg(
+ options=["-n", "--name"],
+ help="Name of the app.",
+ required=True,
+ id_part="name",
+ )
+ _args_schema.resource_group = AAZResourceGroupNameArg(
+ required=True,
+ )
+ _args_schema.silo = AAZStrArg(
+ options=["--silo"],
+ help="Name of the silo.",
+ required=True,
+ id_part="child_name_1",
+ fmt=AAZStrArgFormat(
+ pattern="^[A-Za-z0-9-]{1,24}$",
+ ),
+ )
+
+ # define Arg Group "Properties"
+
+ _args_schema = cls._args_schema
+ _args_schema.kind = AAZStrArg(
+ options=["--kind"],
+ arg_group="Properties",
+ help="Kind of resource.",
+ )
+ _args_schema.subnet = AAZStrArg(
+ options=["--subnet"],
+ arg_group="Properties",
+ help="The Virtual Network subnet's resource ID. This is the subnet that this Web App will join. This subnet must have a delegation to Microsoft.Web/serverFarms defined first.",
+ )
+ _args_schema.swift_supported = AAZBoolArg(
+ options=["--swift-supported"],
+ arg_group="Properties",
+ help="A flag that specifies if the scale unit this Web App is on supports Swift integration.",
+ )
+ return cls._args_schema
+
+ def _execute_operations(self):
+ self.pre_operations()
+ self.SiteSiloVirtualNetworkConnectionsUpdateSiteSiloVnetConnection(ctx=self.ctx)()
+ self.post_operations()
+
+ @register_callback
+ def pre_operations(self):
+ pass
+
+ @register_callback
+ def post_operations(self):
+ pass
+
+ def _output(self, *args, **kwargs):
+ result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True)
+ return result
+
+ class SiteSiloVirtualNetworkConnectionsUpdateSiteSiloVnetConnection(AAZHttpOperation):
+ CLIENT_TYPE = "MgmtClient"
+
+ def __call__(self, *args, **kwargs):
+ request = self.make_request()
+ session = self.client.send_request(request=request, stream=False, **kwargs)
+ if session.http_response.status_code in [200]:
+ return self.on_200(session)
+
+ return self.on_error(session.http_response)
+
+ @property
+ def url(self):
+ return self.client.format_url(
+ "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/silos/{siloName}/virtualNetworkConnections/virtualNetwork",
+ **self.url_parameters
+ )
+
+ @property
+ def method(self):
+ return "PATCH"
+
+ @property
+ def error_format(self):
+ return "ODataV4Format"
+
+ @property
+ def url_parameters(self):
+ parameters = {
+ **self.serialize_url_param(
+ "name", self.ctx.args.name,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "resourceGroupName", self.ctx.args.resource_group,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "siloName", self.ctx.args.silo,
+ required=True,
+ ),
+ **self.serialize_url_param(
+ "subscriptionId", self.ctx.subscription_id,
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def query_parameters(self):
+ parameters = {
+ **self.serialize_query_param(
+ "api-version", "2026-08-01",
+ required=True,
+ ),
+ }
+ return parameters
+
+ @property
+ def header_parameters(self):
+ parameters = {
+ **self.serialize_header_param(
+ "Content-Type", "application/json",
+ ),
+ **self.serialize_header_param(
+ "Accept", "application/json",
+ ),
+ }
+ return parameters
+
+ @property
+ def content(self):
+ _content_value, _builder = self.new_content_builder(
+ self.ctx.args,
+ typ=AAZObjectType,
+ typ_kwargs={"flags": {"required": True, "client_flatten": True}}
+ )
+ _builder.set_prop("kind", AAZStrType, ".kind")
+ _builder.set_prop("properties", AAZObjectType, typ_kwargs={"flags": {"client_flatten": True}})
+
+ properties = _builder.get(".properties")
+ if properties is not None:
+ properties.set_prop("subnetResourceId", AAZStrType, ".subnet")
+ properties.set_prop("swiftSupported", AAZBoolType, ".swift_supported")
+
+ return self.serialize_content(_content_value)
+
+ def on_200(self, session):
+ data = self.deserialize_http_content(session)
+ self.ctx.set_var(
+ "instance",
+ data,
+ schema_builder=self._build_schema_on_200
+ )
+
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ if cls._schema_on_200 is not None:
+ return cls._schema_on_200
+
+ cls._schema_on_200 = AAZObjectType()
+
+ _schema_on_200 = cls._schema_on_200
+ _schema_on_200.id = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200.kind = AAZStrType()
+ _schema_on_200.name = AAZStrType(
+ flags={"read_only": True},
+ )
+ _schema_on_200.properties = AAZObjectType(
+ flags={"client_flatten": True},
+ )
+ _schema_on_200.system_data = AAZObjectType(
+ serialized_name="systemData",
+ flags={"read_only": True},
+ )
+ _schema_on_200.type = AAZStrType(
+ flags={"read_only": True},
+ )
+
+ properties = cls._schema_on_200.properties
+ properties.subnet_resource_id = AAZStrType(
+ serialized_name="subnetResourceId",
+ )
+ properties.swift_supported = AAZBoolType(
+ serialized_name="swiftSupported",
+ )
+
+ system_data = cls._schema_on_200.system_data
+ system_data.created_at = AAZStrType(
+ serialized_name="createdAt",
+ )
+ system_data.created_by = AAZStrType(
+ serialized_name="createdBy",
+ )
+ system_data.created_by_type = AAZStrType(
+ serialized_name="createdByType",
+ )
+ system_data.last_modified_at = AAZStrType(
+ serialized_name="lastModifiedAt",
+ )
+ system_data.last_modified_by = AAZStrType(
+ serialized_name="lastModifiedBy",
+ )
+ system_data.last_modified_by_type = AAZStrType(
+ serialized_name="lastModifiedByType",
+ )
+
+ return cls._schema_on_200
+
+
+class _UpdateHelper:
+ """Helper class for Update"""
+
+
+__all__ = ["Update"]
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/active_backup.py b/src/azure-cli/azure/cli/command_modules/appservice/active_backup.py
deleted file mode 100644
index 747041a6e8d..00000000000
--- a/src/azure-cli/azure/cli/command_modules/appservice/active_backup.py
+++ /dev/null
@@ -1,718 +0,0 @@
-# --------------------------------------------------------------------------------------------
-# Copyright (c) Microsoft Corporation. All rights reserved.
-# Licensed under the MIT License. See License.txt in the project root for license information.
-# --------------------------------------------------------------------------------------------
-
-from urllib.parse import quote, unquote
-
-from knack.log import get_logger
-
-from azure.cli.core.azclierror import (AzureResponseError, FileOperationError, InvalidArgumentValueError,
- MutuallyExclusiveArgumentError, RequiredArgumentMissingError)
-
-from ._active_backup_client import ActiveBackupClient, DEFAULT_TIMEOUT, load_json, segment
-
-
-logger = get_logger(__name__)
-
-
-def _client(cmd, resource_group_name, name, plan=False):
- return ActiveBackupClient(cmd.cli_ctx, resource_group_name, name, 'serverfarms' if plan else 'sites')
-
-
-def _boolean(value):
- if value is None or isinstance(value, bool):
- return value
- if str(value).lower() in ('true', 'false'):
- return str(value).lower() == 'true'
- raise InvalidArgumentValueError('Provide true or false.')
-
-
-def _primary_location(cmd, resource_group_name, name, plan):
- from ._client_factory import web_client_factory
- client = web_client_factory(cmd.cli_ctx)
- operations = client.app_service_plans if plan else client.web_apps
- return operations.get(resource_group_name, name).location
-
-
-def _silo_body(cmd, resource_group_name, name, silo, location, primary_location, properties, plan, update):
- client = _client(cmd, resource_group_name, name, plan)
- if update:
- existing = client.get_resource(client.resource(silo))
- if not isinstance(existing, dict) or not isinstance(existing.get('properties'), dict):
- raise AzureResponseError('The silo API did not return the resource properties required for update.')
- existing_properties = existing['properties']
- location = location or existing.get('location') or existing_properties.get('siloLocation')
- primary_location = primary_location or existing_properties.get('primaryLocation')
- elif not primary_location:
- primary_location = _primary_location(cmd, resource_group_name, name, plan)
- if not location or not primary_location:
- raise InvalidArgumentValueError('Provide --location and --primary-location for the silo.')
- properties = {key: value for key, value in properties.items() if value is not None}
- properties.update(name=silo, primaryLocation=primary_location)
- return client, {'location': location, 'properties': properties}
-
-
-def _plan_properties(number_of_workers, zone_redundant):
- if number_of_workers is not None and not 1 <= number_of_workers <= 30:
- raise InvalidArgumentValueError('--number-of-workers must be between 1 and 30.')
- return {'instanceNumberOverride': number_of_workers, 'zoneRedundantOverride': _boolean(zone_redundant)}
-
-
-def create_plan_silo(cmd, resource_group_name, name, silo, location, primary_location=None,
- number_of_workers=None, zone_redundant=None, no_wait=False, timeout=DEFAULT_TIMEOUT):
- client, body = _silo_body(cmd, resource_group_name, name, silo, location, primary_location,
- _plan_properties(number_of_workers, zone_redundant), True, False)
- return client.mutate('PUT', client.resource(silo), body, no_wait, timeout)
-
-
-def update_plan_silo(cmd, resource_group_name, name, silo, location=None, primary_location=None,
- number_of_workers=None, zone_redundant=None, no_wait=False, timeout=DEFAULT_TIMEOUT):
- client, body = _silo_body(cmd, resource_group_name, name, silo, location, primary_location,
- _plan_properties(number_of_workers, zone_redundant), True, True)
- return client.mutate('PUT', client.resource(silo), body, no_wait, timeout)
-
-
-def show_plan_silo(cmd, resource_group_name, name, silo):
- client = _client(cmd, resource_group_name, name, True)
- return client.decode(client.request('GET', client.resource(silo)))
-
-
-def list_plan_silos(cmd, resource_group_name, name):
- client = _client(cmd, resource_group_name, name, True)
- return client.list(client.resource())
-
-
-def delete_plan_silo(cmd, resource_group_name, name, silo, no_wait=False, timeout=DEFAULT_TIMEOUT):
- client = _client(cmd, resource_group_name, name, True)
- return client.mutate('DELETE', client.resource(silo), no_wait=no_wait, timeout=timeout)
-
-
-def show_plan_silo_operation(cmd, resource_group_name, name, silo, operation_id):
- client = _client(cmd, resource_group_name, name, True)
- return client.operation(client.operation_url(silo, operation_id))
-
-
-def wait_plan_silo(cmd, resource_group_name, name, silo, operation_id, timeout=DEFAULT_TIMEOUT):
- client = _client(cmd, resource_group_name, name, True)
- return client.wait(client.operation_url(silo, operation_id), timeout)
-
-
-def list_plan_silo_vnet_integrations(cmd, resource_group_name, name, silo):
- client = _client(cmd, resource_group_name, name, True)
- return client.decode(client.request('GET', client.resource(silo) + '/virtualNetworkIntegrations'))
-
-
-def _site_properties(endpoint_status, follow_last_known_good, subnet):
- properties = {'endpointStatus': endpoint_status, 'followLastKnownGood': _boolean(follow_last_known_good)}
- if subnet is not None:
- properties['networkingOverride'] = {'vnetIntegrationOverride': subnet}
- return properties
-
-
-def create_site_silo(cmd, resource_group_name, name, silo, location, primary_location=None,
- traffic_mode=None, endpoint_status=None, follow_last_known_good=None, subnet=None,
- no_wait=False, timeout=DEFAULT_TIMEOUT):
- properties = _site_properties(endpoint_status, follow_last_known_good, subnet)
- properties['trafficMode'] = traffic_mode
- client, body = _silo_body(cmd, resource_group_name, name, silo, location, primary_location,
- properties, False, False)
- return client.mutate('PUT', client.resource(silo), body, no_wait, timeout)
-
-
-def update_site_silo(cmd, resource_group_name, name, silo, location=None, primary_location=None,
- endpoint_status=None, follow_last_known_good=None, subnet=None,
- no_wait=False, timeout=DEFAULT_TIMEOUT):
- client, body = _silo_body(cmd, resource_group_name, name, silo, location, primary_location,
- _site_properties(endpoint_status, follow_last_known_good, subnet), False, True)
- return client.mutate('PUT', client.resource(silo), body, no_wait, timeout)
-
-
-def show_site_silo(cmd, resource_group_name, name, silo):
- client = _client(cmd, resource_group_name, name)
- return client.get_resource(client.resource(silo))
-
-
-def list_site_silos(cmd, resource_group_name, name):
- client = _client(cmd, resource_group_name, name)
- return client.list(client.resource())
-
-
-def delete_site_silo(cmd, resource_group_name, name, silo, no_wait=False, timeout=DEFAULT_TIMEOUT):
- client = _client(cmd, resource_group_name, name)
- return client.mutate('DELETE', client.resource(silo), no_wait=no_wait, timeout=timeout)
-
-
-def show_site_silo_operation(cmd, resource_group_name, name, silo, operation_id):
- client = _client(cmd, resource_group_name, name)
- return client.operation(client.operation_url(silo, operation_id))
-
-
-def wait_site_silo(cmd, resource_group_name, name, silo, operation_id, timeout=DEFAULT_TIMEOUT):
- client = _client(cmd, resource_group_name, name)
- return client.wait(client.operation_url(silo, operation_id), timeout)
-
-
-def refresh_site_silos(cmd, resource_group_name, name):
- client = _client(cmd, resource_group_name, name)
- response = client.request('POST', client.parent + '/refreshSilos')
- if response.status_code != 204:
- raise AzureResponseError('The refresh API did not return the expected 204 acceptance response.')
- logger.warning('Refresh request accepted. Revision propagation continues asynchronously and may take a few '
- 'minutes. Use silo show/list to inspect revision numbers.')
-
-
-def list_last_known_good(cmd, resource_group_name, name):
- client = _client(cmd, resource_group_name, name)
- return client.list(client.parent + '/lastKnownGood')
-
-
-def show_last_known_good(cmd, resource_group_name, name):
- client = _client(cmd, resource_group_name, name)
- return client.decode(client.request('GET', client.parent + '/lastKnownGood/default'))
-
-
-def mark_last_known_good(cmd, resource_group_name, name):
- client = _client(cmd, resource_group_name, name)
- return client.decode(client.request('POST', client.parent + '/lastKnownGood/default/mark'))
-
-
-def list_managed_traffic(cmd, resource_group_name, name, expand_endpoints=False):
- client = _client(cmd, resource_group_name, name)
- return client.list(client.parent + '/managedTrafficConfig',
- {'$expand': 'endpoints'} if expand_endpoints else None)
-
-
-def show_managed_traffic(cmd, resource_group_name, name):
- client = _client(cmd, resource_group_name, name)
- return client.decode(client.request('GET', client.parent + '/managedTrafficConfig/default'))
-
-
-def update_managed_traffic(cmd, resource_group_name, name, traffic_mode=None, endpoints=None):
- properties = {}
- if traffic_mode is not None:
- properties['trafficMode'] = traffic_mode
- if endpoints is not None:
- values = load_json(endpoints, list)
- for endpoint in values:
- if not isinstance(endpoint, dict) or not isinstance(endpoint.get('region'), str):
- raise InvalidArgumentValueError('Each endpoint must be an object with a region.')
- if set(endpoint) - {'region', 'weight', 'priority'}:
- raise InvalidArgumentValueError('Endpoint inputs support region, weight, and priority.')
- for key in ('weight', 'priority'):
- value = endpoint.get(key)
- if value is not None and (type(value) is not int or not 1 <= value <= 1000):
- raise InvalidArgumentValueError('Endpoint {} must be an integer from 1 to 1000.'.format(key))
- properties['endpoints'] = values
- if not properties:
- raise InvalidArgumentValueError('Provide --traffic-mode or --endpoints.')
- client = _client(cmd, resource_group_name, name)
- return client.decode(client.request(
- 'PUT', client.parent + '/managedTrafficConfig/default', {'properties': properties}))
-
-
-def _metric_query(start_time=None, end_time=None, time_grain=None, names=None, filter_string=None):
- if filter_string:
- if any(value is not None for value in (start_time, end_time, time_grain, names)):
- raise InvalidArgumentValueError('Use --filter or individual metric filter arguments, not both.')
- return {'$filter': filter_string}
- values = []
- for key, value in (('startTime', start_time), ('endTime', end_time), ('timeGrain', time_grain)):
- if value is not None:
- if "'" in value:
- raise InvalidArgumentValueError('Metric filter values must not contain quotes.')
- values.append("{} eq {}'{}'".format(key, 'duration' if key == 'timeGrain' else '', value))
- if names:
- name_filters = ["name.value eq '{}'".format(value.replace("'", "''")) for value in names]
- values.append('(' + ' or '.join(name_filters) + ')')
- return {'$filter': ' and '.join(values)} if values else {}
-
-
-def list_managed_traffic_metrics(cmd, resource_group_name, name, start_time=None, end_time=None, time_grain=None):
- client = _client(cmd, resource_group_name, name)
- return client.decode(client.request('GET', client.parent + '/managedTrafficConfig/default/metrics',
- query=_metric_query(start_time, end_time, time_grain)))
-
-
-def show_silo_vnet_integration(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/virtualNetworkConnections/virtualNetwork')
-
-
-def _vnet(cmd, resource_group_name, name, silo, method, subnet, body):
- envelope = load_json(body) if body is not None else {'properties': {}}
- if not isinstance(envelope.get('properties'), dict):
- raise InvalidArgumentValueError('--body must contain a properties object.')
- if subnet is not None:
- envelope['properties']['subnetResourceId'] = subnet
- return _site(cmd, resource_group_name, name, silo, '/virtualNetworkConnections/virtualNetwork',
- method=method, body=envelope)
-
-
-def add_silo_vnet_integration(cmd, resource_group_name, name, silo, subnet=None, body=None):
- if subnet is None and body is None:
- raise InvalidArgumentValueError('Provide --subnet or --body.')
- return _vnet(cmd, resource_group_name, name, silo, 'PUT', subnet, body)
-
-
-def update_silo_vnet_integration(cmd, resource_group_name, name, silo, subnet=None, body=None):
- if subnet is None and body is None:
- raise InvalidArgumentValueError('Provide --subnet or --body.')
- return _vnet(cmd, resource_group_name, name, silo, 'PATCH', subnet, body)
-
-
-def remove_silo_vnet_integration(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/virtualNetworkConnections/virtualNetwork', method='DELETE')
-
-
-def _site(cmd, resource_group_name, name, silo, suffix='', method='GET', query=None, body=None,
- filename=None, overwrite=False, tail=False, content_type=None, collection=False):
- client = _client(cmd, resource_group_name, name)
- path = client.resource(silo) + suffix
- if collection:
- return client.list(path, query)
- if filename is not None or tail:
- return client.consume(method, path, filename, overwrite, query, body, content_type, tail)
- return client.decode(client.request(method, path, body, query, content_type=content_type))
-
-
-def _site_operation(cmd, resource_group_name, name, silo, suffix, query=None,
- no_wait=False, timeout=DEFAULT_TIMEOUT):
- client = _client(cmd, resource_group_name, name)
- return client.mutate('POST', client.resource(silo) + suffix,
- no_wait=no_wait, timeout=timeout, query=query)
-
-
-def _instance(instance):
- return '/instances/' + segment(instance) if instance is not None else ''
-
-
-def _process(process_id, instance=None):
- return _instance(instance) + '/processes/' + segment(process_id)
-
-
-def show_silo_php_logging(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/phplogging')
-
-
-def list_silo_deployments(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/deployments', collection=True)
-
-
-def show_silo_deployment(cmd, resource_group_name, name, silo, deployment_id):
- return _site(cmd, resource_group_name, name, silo, '/deployments/' + segment(deployment_id))
-
-
-def list_silo_deployment_logs(cmd, resource_group_name, name, silo, deployment_id):
- return _site(cmd, resource_group_name, name, silo, '/deployments/' + segment(deployment_id) + '/log')
-
-
-def delete_silo_process(cmd, resource_group_name, name, silo, process_id, instance=None):
- return _site(cmd, resource_group_name, name, silo, _process(process_id, instance), method='DELETE')
-
-
-def stop_silo_process(cmd, resource_group_name, name, silo, process_id, instance=None):
- return _site(cmd, resource_group_name, name, silo, _process(process_id, instance) + '/stop', method='POST')
-
-
-def restart_silo_container(cmd, resource_group_name, name, silo, container_name, instance=None):
- return _site(cmd, resource_group_name, name, silo,
- _instance(instance) + '/containers/' + segment(container_name) + '/restart', method='POST')
-
-
-def show_silo_process(cmd, resource_group_name, name, silo, process_id, instance=None):
- return _site(cmd, resource_group_name, name, silo, _process(process_id, instance))
-
-
-def list_silo_process_threads(cmd, resource_group_name, name, silo, process_id, instance=None):
- return _site(cmd, resource_group_name, name, silo, _process(process_id, instance) + '/threads', collection=True)
-
-
-def show_silo_process_thread(cmd, resource_group_name, name, silo, process_id, thread_id, instance=None):
- return _site(cmd, resource_group_name, name, silo,
- _process(process_id, instance) + '/threads/' + segment(thread_id))
-
-
-def list_silo_process_modules(cmd, resource_group_name, name, silo, process_id, instance=None):
- return _site(cmd, resource_group_name, name, silo, _process(process_id, instance) + '/modules', collection=True)
-
-
-def show_silo_process_module(cmd, resource_group_name, name, silo, process_id, base_address, instance=None):
- return _site(cmd, resource_group_name, name, silo,
- _process(process_id, instance) + '/modules/' + segment(base_address))
-
-
-def dump_silo_process(cmd, resource_group_name, name, silo, process_id, file, instance=None, overwrite=False):
- return _site(cmd, resource_group_name, name, silo, _process(process_id, instance) + '/dump',
- filename=file, overwrite=overwrite)
-
-
-def download_silo_container_logs(cmd, resource_group_name, name, silo, file, format='zip', overwrite=False):
- return _site(cmd, resource_group_name, name, silo, '/containerlogs' + ('/zip' if format == 'zip' else ''),
- method='POST', filename=file, overwrite=overwrite)
-
-
-def list_silo_startup_logs(cmd, resource_group_name, name, silo, log_type=None, instance=None, latest=None):
- return _site(cmd, resource_group_name, name, silo, '/startuplogs',
- query={'type': log_type, 'targetSiteInstance': instance, 'latest': _boolean(latest)})
-
-
-def show_silo_startup_summary(cmd, resource_group_name, name, silo, duration=None, instance=None):
- return _site(cmd, resource_group_name, name, silo, '/startuplogs/summary',
- query={'duration': duration, 'targetSiteInstance': instance})
-
-
-def show_silo_startup_log(cmd, resource_group_name, name, silo, filename):
- return _site(cmd, resource_group_name, name, silo, '/startuplogs/' + segment(filename))
-
-
-def list_silo_logs(cmd, resource_group_name, name, silo, instance=None):
- return _site(cmd, resource_group_name, name, silo, '/logs', query={'instanceId': instance})
-
-
-def tail_silo_logs(cmd, resource_group_name, name, silo, instance=None, file=None, overwrite=False):
- return _site(cmd, resource_group_name, name, silo, _instance(instance) + '/logs/live',
- filename=file, overwrite=overwrite, tail=True)
-
-
-def _webjob(webjob_name, kind='webjobs'):
- return '/' + kind + '/' + segment(webjob_name)
-
-
-def list_silo_webjobs(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/webjobs', collection=True)
-
-
-def show_silo_webjob(cmd, resource_group_name, name, silo, webjob_name):
- return _site(cmd, resource_group_name, name, silo, _webjob(webjob_name))
-
-
-def list_silo_continuous_webjobs(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/continuouswebjobs', collection=True)
-
-
-def show_silo_continuous_webjob(cmd, resource_group_name, name, silo, webjob_name):
- return _site(cmd, resource_group_name, name, silo, _webjob(webjob_name, 'continuouswebjobs'))
-
-
-def start_silo_continuous_webjob(cmd, resource_group_name, name, silo, webjob_name):
- return _site(cmd, resource_group_name, name, silo, _webjob(webjob_name, 'continuouswebjobs') + '/start',
- method='POST')
-
-
-def stop_silo_continuous_webjob(cmd, resource_group_name, name, silo, webjob_name):
- return _site(cmd, resource_group_name, name, silo, _webjob(webjob_name, 'continuouswebjobs') + '/stop',
- method='POST')
-
-
-def list_silo_triggered_webjobs(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/triggeredwebjobs', collection=True)
-
-
-def show_silo_triggered_webjob(cmd, resource_group_name, name, silo, webjob_name):
- return _site(cmd, resource_group_name, name, silo, _webjob(webjob_name, 'triggeredwebjobs'))
-
-
-def list_silo_webjob_history(cmd, resource_group_name, name, silo, webjob_name):
- return _site(cmd, resource_group_name, name, silo, _webjob(webjob_name, 'triggeredwebjobs') + '/history',
- collection=True)
-
-
-def show_silo_webjob_history(cmd, resource_group_name, name, silo, webjob_name, run_id):
- return _site(cmd, resource_group_name, name, silo,
- _webjob(webjob_name, 'triggeredwebjobs') + '/history/' + segment(run_id))
-
-
-def run_silo_triggered_webjob(cmd, resource_group_name, name, silo, webjob_name):
- return _site(cmd, resource_group_name, name, silo, _webjob(webjob_name, 'triggeredwebjobs') + '/run',
- method='POST')
-
-
-def list_silo_site_extensions(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/siteextensions', collection=True)
-
-
-def show_silo_site_extension(cmd, resource_group_name, name, silo, extension_name):
- return _site(cmd, resource_group_name, name, silo, '/siteextensions/' + segment(extension_name))
-
-
-def check_silo_connectivity(cmd, resource_group_name, name, silo, body):
- return _site(cmd, resource_group_name, name, silo, '/connectivityCheck', method='POST', body=load_json(body))
-
-
-def check_silo_dns(cmd, resource_group_name, name, silo, body=None, dns_name=None):
- if body is not None:
- if dns_name is not None:
- raise MutuallyExclusiveArgumentError('Use --dns-name or --body, not both.')
- payload = load_json(body)
- else:
- if dns_name is None:
- raise RequiredArgumentMissingError('Provide --dns-name or --body.')
- if not isinstance(dns_name, str) or not dns_name.strip():
- raise InvalidArgumentValueError('--dns-name must be a non-empty DNS name.')
- payload = {'properties': {'dnsName': dns_name}}
- return _site(cmd, resource_group_name, name, silo, '/dnsCheck', method='POST', body=payload)
-
-
-def check_silo_tcp(cmd, resource_group_name, name, silo, body=None, host=None, port=None):
- if body is not None:
- if host is not None or port is not None:
- raise MutuallyExclusiveArgumentError('Use --host and --port or --body, not both.')
- payload = load_json(body)
- else:
- if host is None or port is None:
- raise RequiredArgumentMissingError('Provide both --host and --port, or use --body.')
- if not isinstance(host, str) or not host.strip():
- raise InvalidArgumentValueError('--host must be a non-empty hostname or IP address.')
- if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
- raise InvalidArgumentValueError('--port must be an integer from 1 to 65535.')
- payload = {'properties': {'host': host, 'port': str(port)}}
- return _site(cmd, resource_group_name, name, silo, '/tcpPingCheck', method='POST', body=payload)
-
-
-def invoke_silo_extension(cmd, resource_group_name, name, silo, instance, path, method='GET',
- body=None, content_type=None, file=None, overwrite=False, uri_parameters=None):
- if method.upper() not in ('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'):
- raise InvalidArgumentValueError('Provide a supported HTTP method.')
- if not path or path.startswith('/') or '\\' in path or '?' in path or '#' in path:
- raise InvalidArgumentValueError('--path must be a relative extension path without a query or fragment.')
- parts = path.split('/')
- for part in parts:
- decoded = part
- for _ in range(3):
- decoded = unquote(decoded)
- segment(decoded)
- suffix = _instance(instance) + '/extensions/' + '/'.join(quote(unquote(part), safe='') for part in parts)
- if body is not None and (not content_type or 'json' in content_type.lower()):
- body = load_json(body, expected_type=None)
- if body is None:
- body = b'null'
- elif isinstance(body, str) and body.startswith('@'):
- try:
- with open(body[1:], 'rb') as handle:
- body = handle.read()
- except OSError as ex:
- raise FileOperationError('Unable to read the request body file: {}.'.format(ex.strerror)) from ex
- query = {}
- for parameter in uri_parameters or []:
- key, separator, value = parameter.partition('=')
- if not key or not separator or key.lower() == 'api-version':
- raise InvalidArgumentValueError('--uri-parameters accepts KEY=VALUE pairs other than api-version.')
- query.setdefault(key, []).append(value)
- return _site(cmd, resource_group_name, name, silo, suffix, method=method.upper(), body=body,
- content_type=content_type, filename=file, overwrite=overwrite, query=query)
-
-
-def show_silo_onedeploy_status(cmd, resource_group_name, name, silo, instance=None):
- return _site(cmd, resource_group_name, name, silo, '/extensions/onedeploy', query={'instanceId': instance})
-
-
-def show_silo_msdeploy_log(cmd, resource_group_name, name, silo, instance=None):
- return _site(cmd, resource_group_name, name, silo, '/extensions/MSDeploy/log', query={'instanceId': instance})
-
-
-def list_silo_instances(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/instances', collection=True)
-
-
-def show_silo_instance(cmd, resource_group_name, name, silo, instance):
- return _site(cmd, resource_group_name, name, silo, '/instances/' + segment(instance))
-
-
-def show_silo_config(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/config/web')
-
-
-def list_silo_config(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/config', collection=True)
-
-
-def list_silo_config_snapshots(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/config/web/snapshots', collection=True)
-
-
-def show_silo_config_snapshot(cmd, resource_group_name, name, silo, snapshot_id, include_secrets=False):
- return _site(cmd, resource_group_name, name, silo,
- '/config/web/snapshots/' + segment(snapshot_id) + ('/listsecrets' if include_secrets else ''),
- method='POST' if include_secrets else 'GET')
-
-
-def list_silo_keyvault_references(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/config/configreferences/connectionstrings', collection=True)
-
-
-def refresh_silo_keyvault_references(cmd, resource_group_name, name, silo, force_restart=None):
- return _site(cmd, resource_group_name, name, silo, '/config/configreferences/appsettings/refresh',
- method='POST', query={'forceRestart': _boolean(force_restart)})
-
-
-def list_silo_detected_stacks(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/detectedstacks')
-
-
-def list_silo_appsettings(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/config/appsettings/list', method='POST')
-
-
-def list_silo_storage_accounts(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/config/azurestorageaccounts/list', method='POST')
-
-
-def show_silo_connection_strings(cmd, resource_group_name, name, silo, key=None):
- suffix = '/config/web/connectionstrings/' + segment(key) if key is not None else '/config/connectionstrings'
- return _site(cmd, resource_group_name, name, silo, suffix, method='POST' if key is not None else 'GET')
-
-
-def list_silo_connection_strings(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/config/connectionstrings/list', method='POST')
-
-
-def show_silo_auth(cmd, resource_group_name, name, silo, metadata_only=False):
- return _site(cmd, resource_group_name, name, silo,
- '/config/authsettings' + ('' if metadata_only else '/list'),
- method='GET' if metadata_only else 'POST')
-
-
-def show_silo_auth_v2(cmd, resource_group_name, name, silo, legacy=False):
- return _site(cmd, resource_group_name, name, silo, '/config/authsettingsV2' + ('/list' if legacy else ''))
-
-
-def show_silo_push_settings(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/config/pushsettings/list', method='POST')
-
-
-def list_silo_metadata(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/config/metadata/list', method='POST')
-
-
-def show_silo_log_config(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/config/logs')
-
-
-def list_silo_premier_addons(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/premieraddons', collection=True)
-
-
-def show_silo_premier_addon(cmd, resource_group_name, name, silo, addon_name):
- return _site(cmd, resource_group_name, name, silo, '/premieraddons/' + segment(addon_name))
-
-
-def list_silo_metrics(cmd, resource_group_name, name, silo, start_time=None, end_time=None,
- time_grain=None, names=None, filter_string=None, details=None, slot_view=None):
- query = _metric_query(start_time, end_time, time_grain, names, filter_string)
- query['details'] = _boolean(details)
- query['slotView'] = _boolean(slot_view)
- return _site(cmd, resource_group_name, name, silo, '/metrics', query=query, collection=True)
-
-
-def list_silo_metric_definitions(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/metricdefinitions', collection=True)
-
-
-def list_silo_perf_counters(
- cmd, resource_group_name, name, silo, start_time=None, end_time=None,
- time_grain=None, names=None, filter_string=None):
- return _site(cmd, resource_group_name, name, silo, '/perfcounters',
- query=_metric_query(start_time, end_time, time_grain, names, filter_string), collection=True)
-
-
-def restart_site_silo(
- cmd, resource_group_name, name, silo, soft_restart=None, synchronous=None,
- no_wait=False, timeout=DEFAULT_TIMEOUT):
- return _site_operation(
- cmd, resource_group_name, name, silo, '/restart',
- query={'softRestart': _boolean(soft_restart), 'synchronous': _boolean(synchronous)},
- no_wait=no_wait, timeout=timeout)
-
-
-def start_site_silo(cmd, resource_group_name, name, silo, no_wait=False, timeout=DEFAULT_TIMEOUT):
- return _site_operation(cmd, resource_group_name, name, silo, '/start', no_wait=no_wait, timeout=timeout)
-
-
-def stop_site_silo(cmd, resource_group_name, name, silo, no_wait=False, timeout=DEFAULT_TIMEOUT):
- return _site_operation(cmd, resource_group_name, name, silo, '/stop', no_wait=no_wait, timeout=timeout)
-
-
-def start_silo_network_trace(
- cmd, resource_group_name, name, silo, duration=None, max_frame_length=None,
- sas_url=None, legacy=False, no_wait=False, timeout=DEFAULT_TIMEOUT):
- return _site_operation(
- cmd, resource_group_name, name, silo, '/networkTrace/start' + ('' if legacy else 'Operation'),
- query={'durationInSeconds': duration, 'maxFrameLength': max_frame_length, 'sasUrl': sas_url},
- no_wait=no_wait, timeout=timeout)
-
-
-def stop_silo_network_trace(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/networkTrace/stop', method='POST')
-
-
-def show_silo_network_trace_operation(cmd, resource_group_name, name, silo, operation_id):
- return _site(cmd, resource_group_name, name, silo, '/networkTrace/operationresults/' + segment(operation_id))
-
-
-def show_silo_network_trace(cmd, resource_group_name, name, silo, operation_id):
- return _site(cmd, resource_group_name, name, silo, '/networkTrace/' + segment(operation_id))
-
-
-def show_silo_runtime_operation(cmd, resource_group_name, name, silo, operation_id):
- return _site(cmd, resource_group_name, name, silo, '/operations/' + segment(operation_id))
-
-
-def scan_silo_antivirus(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/antivirus/queue', method='POST')
-
-
-def show_silo_antivirus_config(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/antivirus/config')
-
-
-def update_silo_antivirus_config(cmd, resource_group_name, name, silo, body=None, delete_infected_files=None):
- if body is not None:
- if delete_infected_files is not None:
- raise MutuallyExclusiveArgumentError('Use --delete-infected-files or --body, not both.')
- payload = load_json(body)
- else:
- if delete_infected_files is None:
- raise RequiredArgumentMissingError('Provide --delete-infected-files or --body.')
- payload = {'properties': {'deleteInfectedFiles': _boolean(delete_infected_files)}}
- return _site(cmd, resource_group_name, name, silo, '/antivirus/config', method='PUT', body=payload)
-
-
-def list_silo_antivirus_logs(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/antiviruslogs')
-
-
-def show_silo_antivirus_log(cmd, resource_group_name, name, silo, scan_id, include_file_paths=False):
- return _site(cmd, resource_group_name, name, silo,
- '/antiviruslogs/' + segment(scan_id) + ('/list' if include_file_paths else ''),
- method='POST' if include_file_paths else 'GET')
-
-
-def list_silo_deployment_statuses(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/deploymentStatus', collection=True)
-
-
-def list_silo_sitecontainers(cmd, resource_group_name, name, silo):
- return _site(cmd, resource_group_name, name, silo, '/sitecontainers', collection=True)
-
-
-def show_silo_sitecontainer(cmd, resource_group_name, name, silo, container_name):
- return _site(cmd, resource_group_name, name, silo, '/sitecontainers/' + segment(container_name))
-
-
-def silo_table(result):
- rows = result if isinstance(result, list) else [result]
- output = []
- for row in rows:
- if row.get('status') == 'Accepted':
- output.append({'Status': 'Accepted', 'Operation': row.get('operationId'),
- 'Resource': row.get('resourceId')})
- continue
- properties = row.get('properties') or {}
- output.append({
- 'Name': row.get('name'), 'Location': row.get('location'),
- 'State': properties.get('provisioningState'), 'Revision': properties.get('revisionNumber'),
- 'FollowLastKnownGood': properties.get('followLastKnownGood'),
- 'Endpoint': properties.get('endpointStatus'), 'Monitor': properties.get('monitorStatus'),
- 'Error': properties.get('errorCode'),
- })
- return output
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/commands.py b/src/azure-cli/azure/cli/command_modules/appservice/commands.py
index 4eca0b8a50a..ffa659017a6 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/commands.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/commands.py
@@ -17,7 +17,7 @@
validate_functionapp_on_containerapp_site_config_show,
validate_functionapp_on_containerapp_container_settings_delete,
validate_functionapp_on_containerapp_update, validate_functionapp_on_flex_plan,
- validate_functionapp, validate_is_flex_functionapp)
+ validate_functionapp, validate_is_flex_functionapp, validate_active_backup_ids)
def output_slots_in_table(slots):
@@ -284,8 +284,34 @@ def _polish_bad_errors(ex, creating_plan):
# pylint: disable=too-many-statements
def load_command_table(self, _):
- from ._active_backup_commands import load_command_table as load_active_backup_commands
- load_active_backup_commands(self)
+ from .operations.active_backup import (
+ PlanSiloCreate, PlanSiloUpdate, PlanSiloDelete, PlanSiloShow, PlanSiloList, PlanSiloWait, PlanSiloVnetList,
+ SiteSiloCreate, SiteSiloUpdate, SiteSiloDelete, SiteSiloShow, SiteSiloList, SiteSiloWait,
+ )
+ for name, command in (
+ ('appservice plan silo create', PlanSiloCreate), ('appservice plan silo update', PlanSiloUpdate),
+ ('appservice plan silo delete', PlanSiloDelete),
+ ('appservice plan silo show', PlanSiloShow), ('appservice plan silo list', PlanSiloList),
+ ('appservice plan silo wait', PlanSiloWait),
+ ('appservice plan silo vnet-integration list', PlanSiloVnetList),
+ ('webapp silo create', SiteSiloCreate), ('webapp silo update', SiteSiloUpdate),
+ ('webapp silo delete', SiteSiloDelete),
+ ('webapp silo show', SiteSiloShow), ('webapp silo list', SiteSiloList), ('webapp silo wait', SiteSiloWait),
+ ):
+ if name in self.command_table:
+ self.command_table[name] = command(loader=self)
+ for name in (
+ 'appservice plan silo show', 'appservice plan silo update', 'appservice plan silo delete',
+ 'appservice plan silo wait', 'appservice plan silo vnet-integration list',
+ 'webapp silo show', 'webapp silo update', 'webapp silo delete', 'webapp silo wait',
+ 'webapp silo vnet-integration show', 'webapp silo vnet-integration add',
+ 'webapp silo vnet-integration update', 'webapp silo vnet-integration remove',
+ 'webapp silo refresh', 'webapp managed-traffic list', 'webapp managed-traffic show',
+ 'webapp managed-traffic update', 'webapp last-known-good list', 'webapp last-known-good show',
+ 'webapp last-known-good mark',
+ ):
+ if name in self.command_table:
+ self.command_table[name].validator = validate_active_backup_ids
webclient_sdk = CliCommandType(
operations_tmpl='azure.mgmt.web.operations#WebSiteManagementClientOperationsMixin.{}',
client_factory=cf_web_client
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/operations/__init__.py b/src/azure-cli/azure/cli/command_modules/appservice/operations/__init__.py
new file mode 100644
index 00000000000..34913fb394d
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/operations/__init__.py
@@ -0,0 +1,4 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+# --------------------------------------------------------------------------------------------
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/operations/active_backup.py b/src/azure-cli/azure/cli/command_modules/appservice/operations/active_backup.py
new file mode 100644
index 00000000000..1452e1e92d9
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/operations/active_backup.py
@@ -0,0 +1,319 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+# --------------------------------------------------------------------------------------------
+
+"""Compatibility for silo resource envelopes, origin reads and async responses."""
+
+from functools import partial
+from urllib.parse import unquote, urlsplit
+
+from azure.core.exceptions import ResourceExistsError
+from azure.cli.core.aaz import AAZStrType
+from azure.cli.core.azclierror import AzureResponseError, InvalidArgumentValueError
+from knack.log import get_logger
+
+from ..aaz.latest.appservice.plan.silo import (
+ Create as _PlanCreate, Update as _PlanUpdate, Delete as _PlanDelete,
+ Show as _PlanShow, List as _PlanList, Wait as _PlanWait,
+)
+from ..aaz.latest.appservice.plan.silo.vnet_integration import List as _PlanVnetList
+from ..aaz.latest.webapp.silo import (
+ Create as _SiteCreate, Update as _SiteUpdate, Delete as _SiteDelete,
+ Show as _SiteShow, List as _SiteList, Wait as _SiteWait,
+)
+
+
+logger = get_logger(__name__)
+
+
+def _prefer_location_polling(response, hook=None):
+ if hook:
+ hook(response)
+ headers = response.http_response.headers
+ # Silo async-status reads return empty 202s; suppress that header so the SDK uses
+ # the provided Location monitor. Remove once Azure-AsyncOperation returns status JSON.
+ if headers.get("Location"):
+ headers.pop("Azure-AsyncOperation", None)
+
+
+def _read_origin_from_collection(operation, schema_builder):
+ ctx = operation.ctx
+ if ctx.args.silo.to_serialized_data().casefold() != "origin":
+ return False
+ # Origin point GET can return 404 while the collection contains it. Remove this
+ # fallback once point GET is fixed; keep generated paging and response schemas.
+ resources = SiteSiloList(cli_ctx=ctx.cli_ctx)(command_args={
+ "subscription": ctx.subscription_id,
+ "resource_group": ctx.args.resource_group.to_serialized_data(),
+ "name": ctx.args.name.to_serialized_data(),
+ })
+ resource_id = unquote(urlsplit(operation.url).path).casefold()
+ matches = [
+ resource for resource in resources
+ if isinstance(resource, dict) and isinstance(resource.get("id"), str) and
+ unquote(resource["id"]).casefold() == resource_id
+ ]
+ if not matches:
+ return False
+ if len(matches) != 1:
+ raise AzureResponseError("ARM returned duplicate origin resources in the silo collection.")
+ resource = matches[0]
+ resource_type = resource.get("type")
+ if (not isinstance(resource_type, str) or resource_type.casefold() != "microsoft.web/sites/silos" or
+ not isinstance(resource.get("properties"), dict)):
+ raise AzureResponseError("ARM returned an invalid origin resource in the silo collection.")
+ ctx.set_var("instance", resource, schema_builder=schema_builder)
+ logger.warning(
+ "Origin point GET returned HTTP 404; using the matching origin from the silo collection. "
+ "Stamp-derived IP fields may be absent.")
+ return True
+
+
+def _include_location(schema):
+ if not hasattr(schema, "location"):
+ schema.location = AAZStrType()
+ return schema
+
+
+def _same_location(left, right):
+ return left.replace(" ", "").casefold() == right.replace(" ", "").casefold()
+
+
+def _silo_location(content):
+ locations = [
+ content.get("location"),
+ content.get("properties", {}).get("siloLocation"),
+ ]
+ locations = [value for value in locations if value is not None]
+ if not locations or any(not isinstance(value, str) or not value.strip() for value in locations):
+ raise AzureResponseError("The silo resource does not include a usable location.")
+ if any(not _same_location(locations[0], value) for value in locations[1:]):
+ raise AzureResponseError("The silo resource contains conflicting location fields.")
+ return locations[0]
+
+
+def _create_content(content):
+ content["location"] = _silo_location(content)
+ return content
+
+
+def _prepare_update(ctx, instance):
+ location = _silo_location(instance.to_serialized_data())
+ ctx.set_var("original_silo_location", location, schema_builder=AAZStrType)
+ instance.location = location
+
+
+def _complete_update(ctx, instance):
+ original = ctx.vars.original_silo_location.to_serialized_data()
+ content = instance.to_serialized_data()
+ location = content.get("location")
+ nested = content.get("properties", {}).get("siloLocation")
+ if (not isinstance(location, str) or not _same_location(location, original) or
+ (nested is not None and (not isinstance(nested, str) or not _same_location(nested, original)))):
+ raise InvalidArgumentValueError("The location of an existing silo cannot be changed or removed.")
+ instance.location = original
+
+
+class PlanSiloCreate(_PlanCreate):
+ class ServerFarmSiloResourcesCreateOrUpdateServerFarmSilo(
+ _PlanCreate.ServerFarmSiloResourcesCreateOrUpdateServerFarmSilo):
+ _schema_on_200_201 = None
+
+ def __call__(self, *args, **kwargs):
+ kwargs["raw_response_hook"] = partial(_prefer_location_polling, hook=kwargs.get("raw_response_hook"))
+ return super().__call__(*args, **kwargs)
+
+ @property
+ def content(self):
+ return _create_content(super().content)
+
+ @classmethod
+ def _build_schema_on_200_201(cls):
+ return _include_location(super()._build_schema_on_200_201())
+
+
+class PlanSiloUpdate(_PlanUpdate):
+ def pre_instance_update(self, instance):
+ _prepare_update(self.ctx, instance)
+
+ def post_instance_update(self, instance):
+ _complete_update(self.ctx, instance)
+
+ class ServerFarmSiloResourcesGetServerFarmSilo(_PlanUpdate.ServerFarmSiloResourcesGetServerFarmSilo):
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ return _include_location(super()._build_schema_on_200())
+
+ class ServerFarmSiloResourcesCreateOrUpdateServerFarmSilo(
+ _PlanUpdate.ServerFarmSiloResourcesCreateOrUpdateServerFarmSilo):
+ _schema_on_200_201 = None
+
+ def __call__(self, *args, **kwargs):
+ kwargs["raw_response_hook"] = partial(_prefer_location_polling, hook=kwargs.get("raw_response_hook"))
+ return super().__call__(*args, **kwargs)
+
+ @classmethod
+ def _build_schema_on_200_201(cls):
+ return _include_location(super()._build_schema_on_200_201())
+
+
+class PlanSiloDelete(_PlanDelete):
+ class ServerFarmSiloResourcesDeleteServerFarmSilo(_PlanDelete.ServerFarmSiloResourcesDeleteServerFarmSilo):
+ def __call__(self, *args, **kwargs):
+ kwargs["raw_response_hook"] = partial(_prefer_location_polling, hook=kwargs.get("raw_response_hook"))
+ return super().__call__(*args, **kwargs)
+
+ def on_error(self, response):
+ if response.status_code == 409:
+ # Core's 409 mapping skips AAZ's case-insensitive formatter for App Service Code/Message fields.
+ raise ResourceExistsError(response=response, error_format=self.ctx.get_error_format(self.error_format))
+ return super().on_error(response)
+
+
+class PlanSiloShow(_PlanShow):
+ class ServerFarmSiloResourcesGetServerFarmSilo(_PlanShow.ServerFarmSiloResourcesGetServerFarmSilo):
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ return _include_location(super()._build_schema_on_200())
+
+
+class PlanSiloList(_PlanList):
+ class ServerFarmSiloResourcesListServerFarmSilos(_PlanList.ServerFarmSiloResourcesListServerFarmSilos):
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ schema = super()._build_schema_on_200()
+ _include_location(schema.value.Element)
+ return schema
+
+
+class PlanSiloWait(_PlanWait):
+ class ServerFarmSiloResourcesGetServerFarmSilo(_PlanWait.ServerFarmSiloResourcesGetServerFarmSilo):
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ return _include_location(super()._build_schema_on_200())
+
+
+class PlanSiloVnetList(_PlanVnetList):
+ class ServerFarmSiloResourcesGetServerFarmSiloVnetIntegrations(
+ _PlanVnetList.ServerFarmSiloResourcesGetServerFarmSiloVnetIntegrations):
+ def deserialize_http_content(self, session):
+ data = super().deserialize_http_content(session)
+ if isinstance(data, list) and all(isinstance(item, dict) for item in data):
+ return data
+ # The draft spec declares an array, but the service wraps subnet properties
+ # in an ARM envelope. Remove this adapter after the contract is regenerated.
+ resource_type = data.get("type") if isinstance(data, dict) else None
+ properties = data.get("properties") if isinstance(data, dict) else None
+ if (not isinstance(resource_type, str) or
+ resource_type.casefold() != "microsoft.web/serverfarms/virtualnetworkintegrations" or
+ not isinstance(properties, list) or any(
+ not isinstance(item, dict) or not isinstance(item.get("subnetResourceId"), str) or
+ not item["subnetResourceId"].strip() for item in properties)):
+ raise AzureResponseError("The plan-silo VNet integration API returned an unexpected response shape.")
+ logger.warning(
+ "Plan-silo VNet integrations returned an ARM properties array; using its subnet entries "
+ "until the service specification is corrected.")
+ return [{"properties": item} for item in properties]
+
+
+class SiteSiloCreate(_SiteCreate):
+ class SiteSiloResourcesCreateOrUpdateSiteSilo(_SiteCreate.SiteSiloResourcesCreateOrUpdateSiteSilo):
+ _schema_on_200_201 = None
+
+ def __call__(self, *args, **kwargs):
+ kwargs["raw_response_hook"] = partial(_prefer_location_polling, hook=kwargs.get("raw_response_hook"))
+ return super().__call__(*args, **kwargs)
+
+ @property
+ def content(self):
+ return _create_content(super().content)
+
+ @classmethod
+ def _build_schema_on_200_201(cls):
+ return _include_location(super()._build_schema_on_200_201())
+
+
+class SiteSiloUpdate(_SiteUpdate):
+ def pre_instance_update(self, instance):
+ _prepare_update(self.ctx, instance)
+
+ def post_instance_update(self, instance):
+ _complete_update(self.ctx, instance)
+
+ class SiteSiloResourcesGetSiteSilo(_SiteUpdate.SiteSiloResourcesGetSiteSilo):
+ _schema_on_200 = None
+
+ def on_error(self, response):
+ if response.status_code == 404 and _read_origin_from_collection(self, self._build_schema_on_200):
+ return None
+ return super().on_error(response)
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ return _include_location(super()._build_schema_on_200())
+
+ class SiteSiloResourcesCreateOrUpdateSiteSilo(_SiteUpdate.SiteSiloResourcesCreateOrUpdateSiteSilo):
+ _schema_on_200_201 = None
+
+ def __call__(self, *args, **kwargs):
+ kwargs["raw_response_hook"] = partial(_prefer_location_polling, hook=kwargs.get("raw_response_hook"))
+ return super().__call__(*args, **kwargs)
+
+ @classmethod
+ def _build_schema_on_200_201(cls):
+ return _include_location(super()._build_schema_on_200_201())
+
+
+class SiteSiloDelete(_SiteDelete):
+ class SiteSiloResourcesDeleteSiteSilo(_SiteDelete.SiteSiloResourcesDeleteSiteSilo):
+ def __call__(self, *args, **kwargs):
+ kwargs["raw_response_hook"] = partial(_prefer_location_polling, hook=kwargs.get("raw_response_hook"))
+ return super().__call__(*args, **kwargs)
+
+
+class SiteSiloShow(_SiteShow):
+ class SiteSiloResourcesGetSiteSilo(_SiteShow.SiteSiloResourcesGetSiteSilo):
+ _schema_on_200 = None
+
+ def on_error(self, response):
+ if response.status_code == 404 and _read_origin_from_collection(self, self._build_schema_on_200):
+ return None
+ return super().on_error(response)
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ return _include_location(super()._build_schema_on_200())
+
+
+class SiteSiloList(_SiteList):
+ class SiteSiloResourcesListSiteSilos(_SiteList.SiteSiloResourcesListSiteSilos):
+ _schema_on_200 = None
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ schema = super()._build_schema_on_200()
+ _include_location(schema.value.Element)
+ return schema
+
+
+class SiteSiloWait(_SiteWait):
+ class SiteSiloResourcesGetSiteSilo(_SiteWait.SiteSiloResourcesGetSiteSilo):
+ _schema_on_200 = None
+
+ def on_error(self, response):
+ if response.status_code == 404 and _read_origin_from_collection(self, self._build_schema_on_200):
+ return None
+ return super().on_error(response)
+
+ @classmethod
+ def _build_schema_on_200(cls):
+ return _include_location(super()._build_schema_on_200())
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/active_backup_command_catalog.json b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/active_backup_command_catalog.json
new file mode 100644
index 00000000000..8facd80ee76
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/active_backup_command_catalog.json
@@ -0,0 +1,30 @@
+{
+ "apiVersion": "2026-08-01",
+ "specCommit": "33ee07b2b3d377b58c4d1a1c5be7e2339f43b879",
+ "commands": [
+ {"command": "appservice plan silo list", "key": "C01", "methods": ["GET"], "path": "/serverfarms/{name}/silos"},
+ {"command": "appservice plan silo show", "key": "C02", "methods": ["GET"], "path": "/serverfarms/{name}/silos/{silo}"},
+ {"command": "appservice plan silo create", "key": "C03", "methods": ["PUT"], "path": "/serverfarms/{name}/silos/{silo}"},
+ {"command": "appservice plan silo update", "key": "C04", "methods": ["GET", "PUT"], "path": "/serverfarms/{name}/silos/{silo}"},
+ {"command": "appservice plan silo delete", "key": "C05", "methods": ["DELETE"], "path": "/serverfarms/{name}/silos/{silo}"},
+ {"command": "appservice plan silo wait", "methods": ["GET"], "path": "/serverfarms/{name}/silos/{silo}"},
+ {"command": "appservice plan silo vnet-integration list", "key": "C07", "methods": ["GET"], "path": "/serverfarms/{name}/silos/{silo}/virtualNetworkIntegrations"},
+ {"command": "webapp silo list", "key": "C08", "methods": ["GET"], "path": "/sites/{name}/silos"},
+ {"command": "webapp silo show", "key": "C09", "methods": ["GET"], "path": "/sites/{name}/silos/{silo}"},
+ {"command": "webapp silo create", "key": "C10", "methods": ["PUT"], "path": "/sites/{name}/silos/{silo}"},
+ {"command": "webapp silo update", "key": "C11", "methods": ["GET", "PUT"], "path": "/sites/{name}/silos/{silo}"},
+ {"command": "webapp silo delete", "key": "C12", "methods": ["DELETE"], "path": "/sites/{name}/silos/{silo}"},
+ {"command": "webapp silo wait", "methods": ["GET"], "path": "/sites/{name}/silos/{silo}"},
+ {"command": "webapp silo refresh", "key": "C14", "methods": ["POST"], "path": "/sites/{name}/refreshSilos"},
+ {"command": "webapp last-known-good list", "key": "C15", "methods": ["GET"], "path": "/sites/{name}/lastKnownGood"},
+ {"command": "webapp last-known-good show", "key": "C16", "methods": ["GET"], "path": "/sites/{name}/lastKnownGood/default"},
+ {"command": "webapp last-known-good mark", "key": "C17", "methods": ["POST"], "path": "/sites/{name}/lastKnownGood/default/mark"},
+ {"command": "webapp managed-traffic list", "key": "C18", "methods": ["GET"], "path": "/sites/{name}/managedTrafficConfig"},
+ {"command": "webapp managed-traffic show", "key": "C19", "methods": ["GET"], "path": "/sites/{name}/managedTrafficConfig/default"},
+ {"command": "webapp managed-traffic update", "key": "C20", "methods": ["GET", "PUT"], "path": "/sites/{name}/managedTrafficConfig/default"},
+ {"command": "webapp silo vnet-integration show", "key": "C22", "methods": ["GET"], "path": "/sites/{name}/silos/{silo}/virtualNetworkConnections/virtualNetwork"},
+ {"command": "webapp silo vnet-integration add", "key": "C23", "methods": ["PUT"], "path": "/sites/{name}/silos/{silo}/virtualNetworkConnections/virtualNetwork"},
+ {"command": "webapp silo vnet-integration update", "key": "C24", "methods": ["PATCH"], "path": "/sites/{name}/silos/{silo}/virtualNetworkConnections/virtualNetwork"},
+ {"command": "webapp silo vnet-integration remove", "key": "C25", "methods": ["DELETE"], "path": "/sites/{name}/silos/{silo}/virtualNetworkConnections/virtualNetwork"}
+ ]
+}
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_aaz.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_aaz.py
new file mode 100644
index 00000000000..1551ca10960
--- /dev/null
+++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_aaz.py
@@ -0,0 +1,897 @@
+# --------------------------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for license information.
+# --------------------------------------------------------------------------------------------
+
+from contextlib import ExitStack, redirect_stderr, redirect_stdout
+import io
+import json
+import os
+from pathlib import Path
+import shlex
+import tempfile
+import unittest
+from unittest.mock import patch
+from urllib.parse import parse_qs, urlsplit
+
+from azure.core.credentials import AccessToken
+from azure.core.pipeline.transport import HttpRequest, HttpResponse
+from requests.structures import CaseInsensitiveDict
+
+from azure.cli.core import get_default_cli
+from azure.cli.core._profile import Profile
+from azure.cli.command_modules.appservice import COMMAND_LOADER_CLS
+
+
+CATALOG = json.loads(Path(__file__).with_name("active_backup_command_catalog.json").read_text(encoding="utf-8"))
+API_VERSION = CATALOG["apiVersion"]
+COMMANDS = CATALOG["commands"]
+SUBSCRIPTION = "00000000-0000-0000-0000-000000000001"
+RESOURCE_ROOT = "/subscriptions/{}/resourceGroups/group/providers/Microsoft.Web".format(SUBSCRIPTION)
+SUBNET = RESOURCE_ROOT.replace("Microsoft.Web", "Microsoft.Network") + "/virtualNetworks/net/subnets/integration"
+ORIGIN = RESOURCE_ROOT + "/sites/primary/silos/origin"
+
+
+class _Credential:
+ def get_token(self, *_args, **_kwargs):
+ return AccessToken("offline-active-backup-test", 9999999999)
+
+
+class _Response(HttpResponse):
+ def __init__(self, request, status, payload=None, headers=None):
+ super().__init__(request, None)
+ self.status_code = status
+ self.reason = "Conflict" if status == 409 else "Offline test response"
+ self.headers = CaseInsensitiveDict({"content-type": "application/json", **(headers or {})})
+ self.content_type = "application/json"
+ self._body = b"" if payload is None else json.dumps(payload).encode("utf-8")
+
+ def body(self):
+ return self._body
+
+
+def _resource(path):
+ result = {"id": path, "name": path.rsplit("/", 1)[-1], "properties": {}}
+ if "/managedTrafficConfig/" in path:
+ result["type"] = "Microsoft.Web/sites/managedTrafficConfig"
+ result["properties"] = {
+ "trafficMode": "Active",
+ "endpoints": [{"region": "eastus", "weight": 700}, {"region": "westus2", "weight": 300}],
+ }
+ elif "/lastKnownGood/" in path:
+ result["type"] = "Microsoft.Web/sites/lastKnownGood"
+ result["properties"] = {"revisionNumber": 7, "siteName": "primary"}
+ elif "/virtualNetworkConnections/" in path:
+ result["type"] = "Microsoft.Web/sites/silos/virtualNetworkConnections"
+ result["properties"] = {"subnetResourceId": SUBNET, "swiftSupported": True}
+ else:
+ plan = "/serverfarms/" in path
+ result["type"] = "Microsoft.Web/{}/silos".format("serverfarms" if plan else "sites")
+ result["location"] = "eastus" if path.casefold().endswith("/silos/origin") else "westus2"
+ result["properties"] = {
+ "primaryLocation": "eastus", "provisioningState": "Succeeded",
+ "primaryServerFarmName" if plan else "primarySiteName": "primary",
+ }
+ if plan:
+ result["properties"].update(instanceNumberOverride=1, zoneRedundantOverride=True)
+ else:
+ result["properties"].update(
+ trafficMode="Active", endpointStatus="Enabled", followLastKnownGood=False, revisionNumber=7,
+ defaultHostName="secondary.example.test",
+ )
+ return result
+
+
+class ActiveBackupAAZTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ root = Path(__file__).resolve().parents[8]
+ cache = root / ".x" / "active-backup" / "offline-cli"
+ cache.mkdir(parents=True, exist_ok=True)
+ cls.directory = tempfile.TemporaryDirectory(dir=cache)
+ cls.isolation = ExitStack()
+ cls.isolation.enter_context(patch.dict(os.environ, {
+ "AZURE_CONFIG_DIR": cls.directory.name,
+ "AZURE_EXTENSION_DIR": str(Path(cls.directory.name) / "extensions"),
+ "AZURE_EXTENSION_USE_DYNAMIC_INSTALL": "no",
+ "AZURE_CORE_COLLECT_TELEMETRY": "false",
+ }))
+ cls.isolation.enter_context(patch("azure.cli.core._config.GLOBAL_CONFIG_DIR", cls.directory.name))
+ cls.isolation.enter_context(patch(
+ "azure.cli.core.extension.EXTENSIONS_DIR", str(Path(cls.directory.name) / "extensions")))
+ cls.isolation.enter_context(patch(
+ "azure.cli.core.extension.EXTENSIONS_SYS_DIR", str(Path(cls.directory.name) / "system-extensions")))
+ cls.isolation.enter_context(patch("azure.cli.core.extension.DEV_EXTENSION_SOURCES", []))
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.isolation.close()
+ cls.directory.cleanup()
+
+ def setUp(self):
+ self.calls = []
+ self.async_operation = False
+ self.operation_status = "Succeeded"
+ self.location_operation = False
+ self.location_responses = []
+ self.operation_method = None
+ self.http_status = None
+ self.paginate = False
+ self.reads = []
+ self.responses = []
+ self.cloud = None
+ self.stack = ExitStack()
+ self.addCleanup(self.stack.close)
+ self.stack.enter_context(patch("socket.socket.connect", side_effect=AssertionError("Unexpected network")))
+ self.stack.enter_context(patch("socket.create_connection", side_effect=AssertionError("Unexpected network")))
+ self.stack.enter_context(patch("azure.cli.core.util.handle_version_update"))
+ self.stack.enter_context(patch("azure.cli.core.util.check_connectivity", return_value=False))
+ self.stack.enter_context(patch.object(
+ Profile, "get_subscription",
+ return_value={"id": SUBSCRIPTION, "tenantId": SUBSCRIPTION, "name": "Offline"},
+ ))
+ self.stack.enter_context(patch.object(
+ Profile, "get_login_credentials", return_value=(_Credential(), SUBSCRIPTION, SUBSCRIPTION),
+ ))
+ self.stack.enter_context(patch("azure.core.pipeline.transport.RequestsTransport.send",
+ autospec=True, side_effect=self.send))
+ self.stack.enter_context(patch("time.sleep"))
+
+ def send(self, _transport, request, **_kwargs):
+ url = urlsplit(request.url)
+ query = parse_qs(url.query)
+ self.assertEqual(query.get("api-version"), [API_VERSION])
+ body = request.body
+ if isinstance(body, bytes):
+ body = body.decode("utf-8")
+ body = json.loads(body) if body else None
+ self.calls.append({"method": request.method, "url": request.url, "path": url.path, "body": body})
+ if self.http_status:
+ return _Response(request, self.http_status, {
+ "error": {"code": "FixtureError", "message": "The service rejected this operation."},
+ })
+ if self.responses:
+ status, payload = self.responses.pop(0)
+ return _Response(request, status, payload)
+ if url.path.endswith("/operationresults/test-operation"):
+ if self.location_operation:
+ self.assertNotIn("azureAsyncOperation", query)
+ status = self.location_responses.pop(0)
+ if status == 202:
+ return _Response(request, 202, headers={"Retry-After": "0"})
+ if status != 200:
+ return _Response(request, status, {
+ "error": {"code": "FixtureFailure", "message": "The operation failed."},
+ })
+ payload = (None if self.operation_method == "DELETE" else
+ _resource(url.path.rsplit("/operationresults/", 1)[0]))
+ return _Response(request, 200, payload)
+ return _Response(request, 200, {
+ "status": self.operation_status,
+ **({"error": {"code": "FixtureFailure", "message": "The operation failed."}}
+ if self.operation_status == "Failed" else {}),
+ }, {"Retry-After": "0"})
+ if self.async_operation and request.method in ("PUT", "DELETE"):
+ self.operation_method = request.method
+ operation_url = (request.url.split("?", 1)[0] +
+ "/operationresults/test-operation?api-version=" + API_VERSION)
+ headers = {"Azure-AsyncOperation": operation_url + "&azureAsyncOperation=1", "Retry-After": "0"}
+ if self.location_operation:
+ headers["Location"] = operation_url
+ return _Response(request, 202, {"status": "InProgress"}, headers)
+ if request.method == "POST" and url.path.endswith("/refreshSilos"):
+ return _Response(request, 204)
+ if request.method == "DELETE":
+ return _Response(request, 204)
+ if request.method == "GET" and url.path.endswith(("/silos", "/managedTrafficConfig", "/lastKnownGood")):
+ payload = {"value": [_resource(url.path + ("/backup" if url.path.endswith("/silos") else "/default"))]}
+ if self.paginate and "page" not in query:
+ payload["nextLink"] = request.url + "&page=2"
+ return _Response(request, 200, payload)
+ if request.method == "GET" and url.path.endswith("/virtualNetworkIntegrations"):
+ return _Response(request, 200, [{"properties": {"subnetResourceId": SUBNET}}])
+ payload = self.reads.pop(0) if self.reads and request.method == "GET" else _resource(url.path)
+ if body:
+ payload["properties"].update(body.get("properties", {}))
+ if "location" in body:
+ payload["location"] = body["location"]
+ return _Response(request, 200, payload)
+
+ def invoke(self, arguments, expected=0, location_default=None):
+ stdout, stderr = io.StringIO(), io.StringIO()
+ cli = get_default_cli()
+ if self.cloud is not None:
+ cli.cloud = self.cloud
+ if location_default:
+ cli.config.set_value("defaults", "location", location_default)
+ with redirect_stdout(stdout), redirect_stderr(stderr):
+ try:
+ code = cli.invoke(arguments, out_file=stdout)
+ except SystemExit as ex:
+ code = ex.code
+ self.assertEqual(code, expected, "{}\n{}\n{}".format(
+ arguments, stderr.getvalue(), getattr(cli.result, "error", None)))
+ output_format = "json"
+ for flag in ("--output", "-o"):
+ if flag in arguments:
+ output_format = arguments[arguments.index(flag) + 1]
+ if stdout.getvalue().strip() and "--help" not in arguments and output_format == "json":
+ return json.loads(stdout.getvalue())
+ return stdout.getvalue()
+
+ @staticmethod
+ def arguments(entry):
+ command = entry["command"]
+ args = command.split() + ["-g", "group", "-n", "primary", "--output", "json"]
+ if "{silo}" in entry["path"]:
+ args += ["--silo", "backup"]
+ if command.endswith((" delete", " remove")):
+ args += ["--yes"]
+ if command.endswith("silo create"):
+ args += ["--location", "westus2", "--primary-location", "eastus"]
+ args += (["--number-of-workers", "2", "--zone-redundant", "false"]
+ if command.startswith("appservice") else
+ ["--traffic-mode", "Active", "--follow-last-known-good", "false"])
+ if command.endswith("silo update"):
+ args += (["--number-of-workers", "2", "--zone-redundant", "false"]
+ if command.startswith("appservice") else
+ ["--endpoint-status", "Disabled", "--follow-last-known-good", "false"])
+ if command == "webapp managed-traffic update":
+ args += ["--traffic-mode", "Passive"]
+ if command.endswith(("vnet-integration add", "vnet-integration update")):
+ args += ["--subnet", SUBNET]
+ if command.endswith(" wait"):
+ args += ["--exists", "--interval", "1", "--timeout", "2"]
+ return args
+
+ def test_appservice_active_backup_inventory_is_spec_limited_and_preview(self):
+ table = COMMAND_LOADER_CLS(cli_ctx=get_default_cli()).load_command_table(None)
+ prefixes = ("appservice plan silo ", "webapp silo ", "webapp managed-traffic ", "webapp last-known-good ")
+ actual = {name for name in table if name.startswith(prefixes)}
+ self.assertEqual(actual, {entry["command"] for entry in COMMANDS})
+ self.assertEqual(len(actual), 24)
+ for command in actual:
+ self.assertIsNotNone(table[command].preview_info, command)
+
+ def test_appservice_active_backup_all_help_loads(self):
+ for entry in COMMANDS:
+ with self.subTest(command=entry["command"]):
+ output = self.invoke(entry["command"].split() + ["--help"])
+ self.assertIn("Examples", output)
+ self.assertEqual(self.calls, [])
+
+ def test_appservice_active_backup_examples_parse_and_execute(self):
+ table = COMMAND_LOADER_CLS(cli_ctx=get_default_cli()).load_command_table(None)
+ for entry in COMMANDS:
+ command = table[entry["command"]]
+ examples = command.help.get("examples", [])
+ self.assertGreaterEqual(len(examples), 2, entry["command"])
+ for example in examples:
+ with self.subTest(command=entry["command"], example=example["name"]):
+ arguments = shlex.split(example["text"])[1:]
+ arguments = [arg.replace("MyGroup", "group").replace("MyPlan", "primary")
+ .replace("MyApp", "primary").replace("WestPlan", "backup")
+ .replace("WestApp", "backup") for arg in arguments]
+ if "--deleted" in arguments:
+ self.http_status = 404
+ result = self.invoke(arguments)
+ if entry["command"] == "appservice plan silo vnet-integration list" and "--query" in arguments:
+ self.assertEqual(result, [SUBNET])
+ self.http_status = None
+
+ def test_appservice_active_backup_exact_spec_routes(self):
+ expected = set()
+ actual = set()
+ for entry in COMMANDS:
+ with self.subTest(command=entry["command"]):
+ self.calls.clear()
+ self.invoke(self.arguments(entry))
+ path = RESOURCE_ROOT + entry["path"].format(name="primary", silo="backup")
+ self.assertEqual([(call["method"], call["path"]) for call in self.calls],
+ [(method, path) for method in entry["methods"]])
+ expected.update((method, path) for method in entry["methods"])
+ actual.update((call["method"], call["path"]) for call in self.calls)
+ self.assertEqual(actual, expected)
+ self.assertEqual(len(actual), 20)
+
+ def test_appservice_active_backup_create_adds_arm_location_and_keeps_generated_fields(self):
+ for entry in COMMANDS:
+ if not entry["command"].endswith("silo create"):
+ continue
+ self.calls.clear()
+ output = self.invoke(self.arguments(entry))
+ body = self.calls[0]["body"]
+ self.assertEqual(body["location"], "westus2")
+ self.assertEqual(output["location"], "westus2")
+ self.assertEqual(body["properties"]["siloLocation"], "westus2")
+ self.assertEqual(body["properties"]["primaryLocation"], "eastus")
+ field = "zoneRedundantOverride" if entry["command"].startswith("appservice") else "followLastKnownGood"
+ self.assertIs(body["properties"][field], False)
+
+ def test_appservice_active_backup_updates_preserve_fields_without_readonly_roundtrip(self):
+ for family, flag, value in (
+ ("appservice plan", "--number-of-workers", "2"), ("webapp", "--endpoint-status", "Disabled"),
+ ):
+ self.calls.clear()
+ self.invoke(family.split() + [
+ "silo", "update", "-g", "group", "-n", "primary", "--silo", "backup", flag, value,
+ ])
+ props = self.calls[-1]["body"]["properties"]
+ self.assertEqual(self.calls[-1]["body"]["location"], "westus2")
+ self.assertEqual(props["primaryLocation"], "eastus")
+ self.assertNotIn("provisioningState", props)
+ self.assertNotIn("revisionNumber", props)
+ if family == "webapp":
+ self.assertIs(props["followLastKnownGood"], False)
+
+ def test_appservice_active_backup_async_and_no_wait(self):
+ self.async_operation = True
+ for entry in COMMANDS:
+ if not entry["command"].endswith("silo create"):
+ continue
+ for no_wait in (False, True):
+ self.calls.clear()
+ args = self.arguments(entry) + (["--no-wait"] if no_wait else [])
+ output = self.invoke(args)
+ self.assertEqual(len(self.calls), 1 if no_wait else 3)
+ if no_wait:
+ self.assertEqual(output, "")
+ else:
+ self.assertEqual(output["id"], self.calls[0]["path"])
+ self.assertIn("/operationresults/test-operation", self.calls[1]["path"])
+ self.assertEqual(self.calls[2]["path"], self.calls[0]["path"])
+
+ def test_appservice_active_backup_async_failure_is_not_success(self):
+ self.async_operation = True
+ self.operation_status = "Failed"
+ self.invoke([
+ "webapp", "silo", "create", "-g", "group", "-n", "primary", "--silo", "backup",
+ "--location", "westus2", "--primary-location", "eastus",
+ ], expected=1)
+ self.assertEqual(len(self.calls), 2)
+
+ def test_appservice_active_backup_synchronous_no_wait_has_no_receipt(self):
+ for entry in COMMANDS:
+ if entry["command"].endswith("silo create"):
+ self.calls.clear()
+ self.assertEqual(self.invoke(self.arguments(entry) + ["--no-wait"]), "")
+ self.assertEqual(len(self.calls), 1)
+
+ def test_appservice_active_backup_async_delete_has_no_resource_readback(self):
+ self.async_operation = True
+ for entry in COMMANDS:
+ if not entry["command"].endswith("silo delete"):
+ continue
+ for no_wait in (False, True):
+ self.calls.clear()
+ result = self.invoke(self.arguments(entry) + (["--no-wait"] if no_wait else []))
+ self.assertEqual(result, "")
+ self.assertEqual(len(self.calls), 1 if no_wait else 2)
+ self.assertEqual(self.calls[0]["method"], "DELETE")
+ if not no_wait:
+ self.assertTrue(self.calls[1]["path"].endswith("/operationresults/test-operation"))
+
+ def test_appservice_active_backup_plan_delete_preserves_standard_and_legacy_conflict_details(self):
+ message = "Cannot delete this plan silo because it has site silos associated with it. Delete site silos first."
+ for payload in (
+ {"error": {"code": "Conflict", "message": message}},
+ {"Code": "Conflict", "Message": message},
+ {"Error": {"Code": "Conflict", "Message": message}},
+ ):
+ with self.subTest(payload=payload):
+ self.calls.clear()
+ self.responses = [(409, payload)]
+ with self.assertLogs("cli.azure.cli.core.azclierror", level="ERROR") as logged:
+ self.invoke([
+ "appservice", "plan", "silo", "delete", "-g", "group", "-n", "primary",
+ "--silo", "backup", "--yes",
+ ], expected=1)
+ output = "\n".join(logged.output)
+ self.assertIn(message, output)
+ self.assertNotIn("invalid status", output)
+ self.assertEqual([call["method"] for call in self.calls], ["DELETE"])
+
+ def test_appservice_active_backup_plan_delete_does_not_invent_dependency_errors(self):
+ message = "Another operation is in progress."
+ self.responses = [(409, {"Code": "Conflict", "Message": message})]
+ with self.assertLogs("cli.azure.cli.core.azclierror", level="ERROR") as logged:
+ self.invoke([
+ "appservice", "plan", "silo", "delete", "-g", "group", "-n", "primary",
+ "--silo", "backup", "--yes",
+ ], expected=1)
+ output = "\n".join(logged.output)
+ self.assertIn(message, output)
+ self.assertNotIn("site silo", output)
+ self.assertEqual(len(self.calls), 1)
+
+ def test_appservice_active_backup_plan_delete_unstructured_conflicts_still_fail(self):
+ for payload in (None, {"unexpected": "value"}, "not an error object"):
+ with self.subTest(payload=payload):
+ self.calls.clear()
+ self.responses = [(409, payload)]
+ with self.assertLogs("cli.azure.cli.core.azclierror", level="ERROR") as logged:
+ self.invoke([
+ "appservice", "plan", "silo", "delete", "-g", "group", "-n", "primary",
+ "--silo", "backup", "--yes",
+ ], expected=1)
+ output = "\n".join(logged.output)
+ self.assertIn("Operation returned an invalid status 'Conflict'", output)
+ self.assertNotIn("site silo", output)
+ self.assertEqual(len(self.calls), 1)
+
+ def test_appservice_active_backup_plan_delete_keeps_conflict_exception_type_and_status(self):
+ from types import SimpleNamespace
+ from azure.core.exceptions import ResourceExistsError
+ from azure.cli.core.aaz._command_ctx import AAZCommandCtx
+ from azure.cli.command_modules.appservice.operations.active_backup import PlanSiloDelete
+ operation = PlanSiloDelete.ServerFarmSiloResourcesDeleteServerFarmSilo(ctx=SimpleNamespace(
+ get_http_client=lambda _kind: None, get_error_format=AAZCommandCtx.get_error_format,
+ ))
+ response = _Response(HttpRequest("DELETE", "https://management.azure.com/example"), 409, {
+ "Code": "Conflict", "Message": "Service-provided failure.",
+ })
+ with self.assertRaises(ResourceExistsError) as caught:
+ operation.on_error(response)
+ self.assertEqual(caught.exception.status_code, 409)
+ self.assertIs(caught.exception.response, response)
+ self.assertEqual(caught.exception.error.message, "Service-provided failure.")
+
+ def test_appservice_active_backup_resource_waits(self):
+ path = RESOURCE_ROOT + "/sites/primary/silos/backup"
+ pending = _resource(path)
+ pending["properties"]["provisioningState"] = "Creating"
+ self.reads = [pending, _resource(path)]
+ result = self.invoke([
+ "webapp", "silo", "wait", "--ids", path, "--created", "--interval", "1", "--timeout", "2",
+ ])
+ self.assertEqual(result, "")
+ self.assertEqual(len(self.calls), 2)
+ self.calls.clear()
+ self.http_status = 404
+ result = self.invoke([
+ "webapp", "silo", "wait", "--ids", path, "--deleted", "--interval", "1", "--timeout", "2",
+ ])
+ self.assertEqual(result, "")
+ self.assertEqual(len(self.calls), 1)
+
+ def test_appservice_active_backup_refresh_acceptance_and_conflict(self):
+ args = ["webapp", "silo", "refresh", "-g", "group", "-n", "primary"]
+ self.assertEqual(self.invoke(args), "")
+ self.assertEqual(len(self.calls), 1)
+ self.calls.clear()
+ self.http_status = 409
+ self.invoke(args, expected=1)
+ self.assertEqual(len(self.calls), 1)
+
+ def test_appservice_active_backup_origin_read_uses_matching_collection_resource_and_warns(self):
+ for identity in (["--ids", ORIGIN], ["-g", "group", "-n", "primary", "--silo", "ORIGIN"]):
+ with self.subTest(identity=identity):
+ self.calls.clear()
+ origin = _resource(ORIGIN)
+ self.responses = [(404, None), (200, {"value": [_resource(ORIGIN + "-other"), origin]})]
+ with self.assertLogs(
+ "cli.azure.cli.command_modules.appservice.operations.active_backup", level="WARNING") as logged:
+ output = self.invoke(["webapp", "silo", "show"] + identity)
+ self.assertEqual(output["id"], ORIGIN)
+ self.assertEqual(output["location"], "eastus")
+ self.assertEqual(output["properties"]["endpointStatus"], "Enabled")
+ self.assertNotIn("inboundIpAddress", output["properties"])
+ self.assertIn("Origin point GET returned HTTP 404", logged.output[0])
+ self.assertEqual([call["method"] for call in self.calls], ["GET", "GET"])
+ self.assertEqual(self.calls[1]["path"], ORIGIN.rsplit("/", 1)[0])
+
+ def test_appservice_active_backup_successful_origin_read_does_not_use_collection(self):
+ output = self.invoke(["webapp", "silo", "show", "--ids", ORIGIN])
+ self.assertEqual(output["id"], ORIGIN)
+ self.assertEqual([(call["method"], call["path"]) for call in self.calls], [("GET", ORIGIN)])
+
+ def test_appservice_active_backup_origin_read_searches_generated_pagination(self):
+ next_link = "https://management.azure.com" + ORIGIN.rsplit("/", 1)[0] + "?api-version=" + API_VERSION + "&page=2"
+ self.responses = [
+ (404, None), (200, {"value": [], "nextLink": next_link}),
+ (200, {"value": [_resource(ORIGIN)]}),
+ ]
+ output = self.invoke(["webapp", "silo", "show", "--ids", ORIGIN])
+ self.assertEqual(output["id"], ORIGIN)
+ self.assertEqual(len(self.calls), 3)
+ self.assertEqual(parse_qs(urlsplit(self.calls[-1]["url"]).query)["page"], ["2"])
+ self.assertEqual(self.responses, [])
+
+ def test_appservice_active_backup_origin_read_preserves_missing_and_unrelated_errors(self):
+ for command, status, expected in (
+ (["webapp", "silo", "show", "--ids", ORIGIN + "-missing"], 404, 3),
+ (["appservice", "plan", "silo", "show", "--ids", ORIGIN.replace("/sites/", "/serverfarms/")], 404, 3),
+ *[(["webapp", "silo", "show", "--ids", ORIGIN], status, 1) for status in (400, 401, 403, 409)],
+ ):
+ with self.subTest(command=command, status=status):
+ self.calls.clear()
+ self.responses = [(status, {"error": {"code": "FixtureError", "message": "Original failure."}})]
+ self.invoke(command, expected=expected)
+ self.assertEqual(len(self.calls), 1)
+ self.calls.clear()
+ self.responses = [(404, None), (403, {"error": {"code": "Forbidden", "message": "Collection denied."}})]
+ self.invoke(["webapp", "silo", "show", "--ids", ORIGIN], expected=1)
+ self.assertEqual(len(self.calls), 2)
+
+ def test_appservice_active_backup_origin_read_requires_exact_id_and_unique_valid_resource(self):
+ for resource_id in (
+ ORIGIN + "-other", ORIGIN.replace("/primary/", "/other/"),
+ ORIGIN.replace(SUBSCRIPTION, "00000000-0000-0000-0000-000000000002"),
+ ORIGIN.replace("/silos/", "/providers/Other.Provider/silos/"),
+ ):
+ with self.subTest(resource_id=resource_id):
+ self.calls.clear()
+ self.responses = [(404, None), (200, {"value": [_resource(resource_id)]})]
+ self.invoke(["webapp", "silo", "show", "--ids", ORIGIN], expected=3)
+ self.assertEqual(len(self.calls), 2)
+ for field, value in (("type", "Microsoft.Web/serverfarms/silos"), ("properties", None)):
+ with self.subTest(field=field):
+ origin = _resource(ORIGIN)
+ origin[field] = value
+ self.responses = [(404, None), (200, {"value": [origin]})]
+ self.invoke(["webapp", "silo", "show", "--ids", ORIGIN], expected=1)
+ next_link = "https://management.azure.com" + ORIGIN.rsplit("/", 1)[0] + "?api-version=" + API_VERSION + "&page=2"
+ self.responses = [
+ (404, None), (200, {"value": [_resource(ORIGIN)], "nextLink": next_link}),
+ (200, {"value": [_resource(ORIGIN.upper())]}),
+ ]
+ self.invoke(["webapp", "silo", "show", "--ids", ORIGIN], expected=1)
+ self.assertEqual(self.responses, [])
+
+ def test_appservice_active_backup_origin_update_reuses_generated_get_and_put(self):
+ origin = _resource(ORIGIN)
+ origin["properties"]["followLastKnownGood"] = True
+ self.responses = [(404, None), (200, {"value": [origin]})]
+ output = self.invoke(["webapp", "silo", "update", "--ids", ORIGIN, "--endpoint-status", "Disabled"])
+ self.assertEqual([call["method"] for call in self.calls], ["GET", "GET", "PUT"])
+ self.assertEqual(self.calls[-1]["path"], ORIGIN)
+ body = self.calls[-1]["body"]
+ self.assertEqual(body["location"], "eastus")
+ self.assertEqual(body["properties"]["endpointStatus"], "Disabled")
+ self.assertIs(body["properties"]["followLastKnownGood"], True)
+ self.assertNotIn("revisionNumber", body["properties"])
+ self.assertEqual(output["properties"]["endpointStatus"], "Disabled")
+
+ def test_appservice_active_backup_origin_fallback_does_not_mask_update_failure(self):
+ self.responses = [
+ (404, None), (200, {"value": [_resource(ORIGIN)]}),
+ (409, {"error": {"code": "Conflict", "message": "Update rejected."}}),
+ ]
+ self.invoke(["webapp", "silo", "update", "--ids", ORIGIN, "--endpoint-status", "Disabled"], expected=1)
+ self.assertEqual([call["method"] for call in self.calls], ["GET", "GET", "PUT"])
+ self.assertEqual(self.responses, [])
+
+ def test_appservice_active_backup_origin_wait_checks_collection_state_and_actual_absence(self):
+ pending = _resource(ORIGIN)
+ pending["properties"]["provisioningState"] = "Creating"
+ for condition, pages in (
+ ("--created", [{"value": [pending]}, {"value": [_resource(ORIGIN)]}]),
+ ("--deleted", [{"value": [_resource(ORIGIN)]}, {"value": []}]),
+ ):
+ with self.subTest(condition=condition):
+ self.calls.clear()
+ self.responses = [(404, None), (200, pages[0]), (404, None), (200, pages[1])]
+ self.invoke(["webapp", "silo", "wait", "--ids", ORIGIN, condition, "--interval", "1", "--timeout", "2"])
+ self.assertEqual([call["method"] for call in self.calls], ["GET"] * 4)
+ self.assertEqual(self.responses, [])
+
+ def test_appservice_active_backup_origin_read_preserves_cloud_subscription_and_encoded_identity(self):
+ from azure.cli.core.cloud import get_cloud
+ self.cloud = get_cloud(get_default_cli(), "AzureUSGovernment")
+ target = ORIGIN.replace(SUBSCRIPTION, "00000000-0000-0000-0000-000000000002").replace("/group/", "/group space/")
+ self.responses = [(404, None), (200, {"value": [_resource(target.upper())]})]
+ output = self.invoke(["webapp", "silo", "show", "--ids", target])
+ self.assertEqual(output["id"], target.upper())
+ self.assertEqual(len(self.calls), 2)
+ for call in self.calls:
+ self.assertEqual(urlsplit(call["url"]).hostname, "management.usgovcloudapi.net")
+ self.assertIn("/subscriptions/00000000-0000-0000-0000-000000000002/", call["path"])
+ self.assertIn("/resourceGroups/group%20space/", call["path"])
+
+ def test_appservice_active_backup_lists_follow_next_link(self):
+ self.paginate = True
+ result = self.invoke(["webapp", "silo", "list", "-g", "group", "-n", "primary"])
+ self.assertEqual(len(result), 2)
+ self.assertEqual(len(self.calls), 2)
+ self.assertEqual(parse_qs(urlsplit(self.calls[1]["url"]).query)["page"], ["2"])
+
+ def test_appservice_active_backup_traffic_uses_structured_arguments(self):
+ self.invoke([
+ "webapp", "managed-traffic", "update", "-g", "group", "-n", "primary",
+ "--traffic-mode", "Active",
+ "--endpoints", "[{region:eastus,weight:1},{region:westus2,weight:1000,is-weight-auto-computed:false}]",
+ ])
+ endpoints = self.calls[-1]["body"]["properties"]["endpoints"]
+ self.assertEqual([item["weight"] for item in endpoints], [1, 1000])
+ self.assertIs(endpoints[1]["isWeightAutoComputed"], False)
+ self.calls.clear()
+ self.invoke([
+ "webapp", "managed-traffic", "list", "-g", "group", "-n", "primary", "--expand", "endpoints",
+ ])
+ self.assertEqual(parse_qs(urlsplit(self.calls[0]["url"]).query)["$expand"], ["endpoints"])
+
+ def test_appservice_active_backup_invalid_traffic_bounds_send_no_request(self):
+ for value in ("0", "1001"):
+ self.invoke([
+ "webapp", "managed-traffic", "update", "-g", "group", "-n", "primary",
+ "--endpoints", "[{region:eastus,weight:" + value + "}]",
+ ], expected=1)
+ self.assertEqual(self.calls, [])
+
+ def test_appservice_active_backup_vnet_update_is_patch(self):
+ self.invoke([
+ "webapp", "silo", "vnet-integration", "update", "-g", "group", "-n", "primary", "--silo", "backup",
+ "--subnet", SUBNET, "--swift-supported", "false",
+ ])
+ self.assertEqual(len(self.calls), 1)
+ self.assertEqual(self.calls[0]["method"], "PATCH")
+ self.assertEqual(self.calls[0]["body"]["properties"], {"subnetResourceId": SUBNET, "swiftSupported": False})
+
+ def test_appservice_active_backup_plan_vnet_list_accepts_array_and_arm_envelope(self):
+ properties = {"subnetResourceId": SUBNET, "swiftSupported": False}
+ for payload in (
+ [{"properties": properties}],
+ {"id": RESOURCE_ROOT + "/serverfarms/physical/virtualNetworkIntegrations",
+ "type": "Microsoft.Web/serverfarms/virtualNetworkIntegrations", "location": "westus2",
+ "properties": [{**properties, "subnetIpAllocation": {"subnetIpAddressesAvailable": 59}}]},
+ ):
+ with self.subTest(payload=payload):
+ self.calls.clear()
+ self.responses = [(200, payload)]
+ output = self.invoke([
+ "appservice", "plan", "silo", "vnet-integration", "list",
+ "-g", "group", "-n", "primary", "--silo", "backup",
+ ])
+ self.assertEqual(output, [properties])
+ self.assertNotIn("id", output[0])
+ self.assertEqual(len(self.calls), 1)
+ self.assertEqual(self.calls[0]["method"], "GET")
+
+ def test_appservice_active_backup_plan_vnet_list_empty_results(self):
+ for payload in ([], {"type": "Microsoft.Web/serverfarms/virtualNetworkIntegrations", "properties": []}):
+ self.responses = [(200, payload)]
+ self.assertEqual(self.invoke([
+ "appservice", "plan", "silo", "vnet-integration", "list",
+ "-g", "group", "-n", "primary", "--silo", "backup",
+ ]), [])
+
+ def test_appservice_active_backup_plan_vnet_list_rejects_unexpected_shapes(self):
+ envelope = {"type": "Microsoft.Web/serverfarms/virtualNetworkIntegrations", "properties": []}
+ for payload in (
+ None, {}, {"value": []}, [None], {**envelope, "type": "Microsoft.Web/sites/silos"},
+ {**envelope, "properties": {}}, {**envelope, "properties": [None]},
+ {**envelope, "properties": [{}]}, {**envelope, "properties": [{"subnetResourceId": ""}]},
+ ):
+ with self.subTest(payload=payload):
+ self.calls.clear()
+ self.responses = [(200, payload)]
+ self.invoke([
+ "appservice", "plan", "silo", "vnet-integration", "list",
+ "-g", "group", "-n", "primary", "--silo", "backup",
+ ], expected=1)
+ self.assertEqual(len(self.calls), 1)
+
+ def test_appservice_active_backup_plan_vnet_list_preserves_service_errors(self):
+ for status, expected in ((403, 1), (404, 3)):
+ self.calls.clear()
+ self.responses = [(status, {"error": {"code": "FixtureError", "message": "List rejected."}})]
+ self.invoke([
+ "appservice", "plan", "silo", "vnet-integration", "list",
+ "-g", "group", "-n", "primary", "--silo", "backup",
+ ], expected=expected)
+ self.assertEqual(len(self.calls), 1)
+
+ def test_appservice_active_backup_create_requires_explicit_locations(self):
+ self.invoke([
+ "webapp", "silo", "create", "-g", "group", "-n", "primary", "--silo", "backup",
+ "--primary-location", "eastus",
+ ], expected=2, location_default="centralus")
+ self.assertEqual(self.calls, [])
+
+ def test_appservice_active_backup_update_does_not_inherit_location_default(self):
+ self.invoke([
+ "webapp", "silo", "update", "-g", "group", "-n", "primary", "--silo", "backup",
+ "--endpoint-status", "Disabled",
+ ], location_default="centralus")
+ self.assertEqual(self.calls[-1]["body"]["location"], "westus2")
+
+ def test_appservice_active_backup_generic_set_is_supported(self):
+ self.invoke([
+ "appservice", "plan", "silo", "update", "-g", "group", "-n", "primary", "--silo", "backup",
+ "--set", "properties.instanceNumberOverride=2",
+ ])
+ self.assertEqual(self.calls[-1]["body"]["properties"]["instanceNumberOverride"], 2)
+
+ def test_appservice_active_backup_nested_ids(self):
+ for family, segment in (("appservice plan", "serverfarms"), ("webapp", "sites")):
+ self.calls.clear()
+ target = RESOURCE_ROOT + "/{}/primary/silos/backup".format(segment)
+ self.invoke(family.split() + ["silo", "show", "--ids", target])
+ self.assertEqual(self.calls[0]["path"], target)
+
+ def test_appservice_active_backup_ids_do_not_retarget_other_resource_types(self):
+ invalid = [
+ RESOURCE_ROOT + "/serverfarms/primary/silos/backup",
+ RESOURCE_ROOT + "/sites/primary/slots/staging/silos/backup",
+ RESOURCE_ROOT + "/sites/primary/providers/Other.Provider/silos/backup",
+ ]
+ for target in invalid:
+ with self.subTest(resource_id=target):
+ self.calls.clear()
+ self.invoke(["webapp", "silo", "show", "--ids", target], expected=1)
+ self.assertEqual(self.calls, [])
+
+ def test_appservice_active_backup_parent_ids(self):
+ for command in ("webapp managed-traffic show", "webapp last-known-good show", "webapp silo refresh"):
+ self.calls.clear()
+ self.invoke(command.split() + ["--ids", RESOURCE_ROOT + "/sites/primary"])
+ self.assertEqual(len(self.calls), 1)
+ self.calls.clear()
+ self.invoke(command.split() + ["--ids", RESOURCE_ROOT + "/serverfarms/primary"], expected=1)
+ self.assertEqual(self.calls, [])
+
+ def test_appservice_active_backup_invalid_ids_cannot_mutate_or_wait_on_other_silos(self):
+ for entry in COMMANDS:
+ if "{silo}" not in entry["path"] or entry["command"].endswith((" create", " show")):
+ continue
+ command = entry["command"]
+ wrong_parent = "sites" if command.startswith("appservice") else "serverfarms"
+ target = RESOURCE_ROOT + "/{}/primary/silos/backup".format(wrong_parent)
+ args = command.split() + ["--ids", target]
+ if command.endswith((" delete", " remove")):
+ args += ["--yes"]
+ if command.endswith(" wait"):
+ args += ["--exists"]
+ with self.subTest(command=command):
+ self.invoke(args, expected=2 if entry.get("key") == "C07" else 1)
+ self.assertEqual(self.calls, [])
+
+ def test_appservice_active_backup_selected_cloud(self):
+ from azure.cli.core.cloud import get_cloud
+ self.cloud = get_cloud(get_default_cli(), "AzureUSGovernment")
+ self.invoke(["webapp", "silo", "show", "-g", "group", "-n", "primary", "--silo", "backup"])
+ self.assertEqual(urlsplit(self.calls[0]["url"]).hostname, "management.usgovcloudapi.net")
+
+ def test_appservice_active_backup_does_not_mark_existing_groups_preview(self):
+ loader = COMMAND_LOADER_CLS(cli_ctx=get_default_cli())
+ table = loader.load_command_table(None)
+ self.assertIsNone(table["webapp show"].preview_info)
+ self.assertIsNone(table["appservice plan show"].preview_info)
+ self.assertNotIn("preview_info", loader.command_group_table["webapp"].group_kwargs)
+
+ def test_appservice_active_backup_location_customizations_inherit_generated_commands(self):
+ from azure.cli.command_modules.appservice.operations import active_backup as custom
+ from azure.cli.command_modules.appservice.aaz.latest.appservice.plan import silo as plan
+ from azure.cli.command_modules.appservice.aaz.latest.webapp import silo as site
+ from azure.cli.command_modules.appservice.aaz.latest.appservice.plan.silo.vnet_integration import List
+ self.assertEqual(custom.PlanSiloVnetList.__bases__, (List,))
+ self.assertEqual(
+ custom.PlanSiloVnetList.ServerFarmSiloResourcesGetServerFarmSiloVnetIntegrations.__bases__,
+ (List.ServerFarmSiloResourcesGetServerFarmSiloVnetIntegrations,),
+ )
+ for prefix, generated in (("PlanSilo", plan), ("SiteSilo", site)):
+ for verb in ("Create", "Update", "Delete", "Show", "List", "Wait"):
+ cls = getattr(custom, prefix + verb)
+ self.assertEqual(cls.__bases__, (getattr(generated, verb),))
+ self.assertEqual(
+ custom.PlanSiloCreate.ServerFarmSiloResourcesCreateOrUpdateServerFarmSilo.__bases__,
+ (plan.Create.ServerFarmSiloResourcesCreateOrUpdateServerFarmSilo,),
+ )
+ self.assertEqual(
+ custom.SiteSiloCreate.SiteSiloResourcesCreateOrUpdateSiteSilo.__bases__,
+ (site.Create.SiteSiloResourcesCreateOrUpdateSiteSilo,),
+ )
+
+ def test_appservice_active_backup_read_and_list_preserve_service_location(self):
+ for family in ("appservice plan", "webapp"):
+ shown = self.invoke(family.split() + ["silo", "show", "-g", "group", "-n", "primary", "--silo", "backup"])
+ self.assertEqual(shown["location"], "westus2")
+ listed = self.invoke(family.split() + ["silo", "list", "-g", "group", "-n", "primary"])
+ self.assertEqual(listed[0]["location"], "westus2")
+
+ def test_appservice_active_backup_update_accepts_nested_location_metadata(self):
+ for family, segment in (("appservice plan", "serverfarms"), ("webapp", "sites")):
+ self.calls.clear()
+ data = _resource(RESOURCE_ROOT + "/{}/primary/silos/backup".format(segment))
+ del data["location"]
+ data["properties"]["siloLocation"] = "westus2"
+ self.reads = [data]
+ self.invoke(family.split() + ["silo", "update", "-g", "group", "-n", "primary", "--silo", "backup"])
+ self.assertEqual(self.calls[-1]["body"]["location"], "westus2")
+
+ def test_appservice_active_backup_update_requires_real_location_metadata(self):
+ for missing in (True, False):
+ self.calls.clear()
+ data = _resource(RESOURCE_ROOT + "/sites/primary/silos/backup")
+ if missing:
+ del data["location"]
+ else:
+ data["properties"]["siloLocation"] = "eastus"
+ self.reads = [data]
+ self.invoke([
+ "webapp", "silo", "update", "-g", "group", "-n", "primary", "--silo", "backup",
+ "--endpoint-status", "Enabled",
+ ], expected=1, location_default="centralus")
+ self.assertEqual([call["method"] for call in self.calls], ["GET"])
+
+ def test_appservice_active_backup_update_cannot_change_or_remove_location(self):
+ for flags in (
+ ["--location", "eastus"],
+ ["--set", "location=eastus"],
+ ["--set", "properties.siloLocation=eastus"],
+ ["--remove", "location"],
+ ):
+ self.calls.clear()
+ self.invoke([
+ "webapp", "silo", "update", "-g", "group", "-n", "primary", "--silo", "backup",
+ ] + flags, expected=1)
+ self.assertEqual([call["method"] for call in self.calls], ["GET"])
+
+ def test_appservice_active_backup_update_retains_service_location_spelling(self):
+ data = _resource(RESOURCE_ROOT + "/sites/primary/silos/backup")
+ data["location"] = "West US 2"
+ self.reads = [data]
+ self.invoke([
+ "webapp", "silo", "update", "-g", "group", "-n", "primary", "--silo", "backup", "--location", "westus2",
+ ])
+ self.assertEqual(self.calls[-1]["body"]["location"], "West US 2")
+
+ def test_appservice_active_backup_location_polling_accepts_empty_pending_responses(self):
+ self.async_operation = True
+ self.location_operation = True
+ for entry in COMMANDS:
+ if not entry["command"].endswith(("silo create", "silo update", "silo delete")):
+ continue
+ with self.subTest(command=entry["command"]):
+ self.calls.clear()
+ self.location_responses = [202, 200]
+ result = self.invoke(self.arguments(entry))
+ self.assertEqual([call["method"] for call in self.calls],
+ entry["methods"] + ["GET", "GET"])
+ self.assertEqual(self.location_responses, [])
+ if entry["command"].endswith("delete"):
+ self.assertEqual(result, "")
+ else:
+ self.assertEqual(result["id"], self.calls[-1]["path"].rsplit("/operationresults/", 1)[0])
+
+ def test_appservice_active_backup_location_polling_preserves_no_wait(self):
+ self.async_operation = True
+ self.location_operation = True
+ for entry in COMMANDS:
+ if not entry["command"].endswith(("silo create", "silo update", "silo delete")):
+ continue
+ self.calls.clear()
+ self.assertEqual(self.invoke(self.arguments(entry) + ["--no-wait"]), "")
+ self.assertEqual([call["method"] for call in self.calls], entry["methods"])
+
+ def test_appservice_active_backup_location_polling_preserves_core_error_handling(self):
+ self.async_operation = True
+ self.location_operation = True
+ for entry in COMMANDS:
+ if not entry["command"].endswith(("silo create", "silo update", "silo delete")):
+ continue
+ for status, exit_code in ((409, 1), (404, 3)):
+ # CLI core already treats a polling 404 as completion for long-running deletes, not PUTs.
+ expected = 0 if status == 404 and entry["command"].endswith("delete") else exit_code
+ with self.subTest(command=entry["command"], status=status):
+ self.calls.clear()
+ self.location_responses = [202, status]
+ self.invoke(self.arguments(entry), expected=expected)
+ self.assertEqual(self.location_responses, [])
+ self.assertEqual([call["method"] for call in self.calls],
+ entry["methods"] + ["GET", "GET"])
+
+ def test_appservice_active_backup_polling_hook_preserves_other_hooks_and_async_only_headers(self):
+ from types import SimpleNamespace
+ from azure.cli.command_modules.appservice.operations.active_backup import _prefer_location_polling
+ original = CaseInsensitiveDict({"Location": "location-monitor", "Azure-AsyncOperation": "async-monitor"})
+ response = SimpleNamespace(http_response=SimpleNamespace(headers=original))
+ observed = []
+ _prefer_location_polling(response, hook=lambda value: observed.append(
+ value.http_response.headers["Azure-AsyncOperation"]))
+ self.assertEqual(observed, ["async-monitor"])
+ self.assertNotIn("Azure-AsyncOperation", original)
+ original.pop("Location")
+ original["Azure-AsyncOperation"] = "async-only"
+ _prefer_location_polling(response)
+ self.assertEqual(original["Azure-AsyncOperation"], "async-only")
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_client.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_client.py
deleted file mode 100644
index d443648cb3b..00000000000
--- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_client.py
+++ /dev/null
@@ -1,497 +0,0 @@
-# --------------------------------------------------------------------------------------------
-# Copyright (c) Microsoft Corporation. All rights reserved.
-# Licensed under the MIT License. See License.txt in the project root for license information.
-# --------------------------------------------------------------------------------------------
-
-import io
-import json
-from datetime import datetime, timezone
-from pathlib import Path
-import tempfile
-import unittest
-from unittest.mock import MagicMock, patch
-from urllib.parse import parse_qs, unquote, urlsplit
-
-import requests
-
-from azure.cli.core.azclierror import (AzureConnectionError, AzureInternalError, AzureResponseError, BadRequestError,
- FileOperationError, ForbiddenError, InvalidArgumentValueError,
- ResourceNotFoundError, UnauthorizedError, UnclassifiedUserFault)
-from azure.cli.command_modules.appservice._active_backup_client import (
- ActiveBackupClient, API_VERSION, _service_error, load_json, segment)
-
-
-MODULE = 'azure.cli.command_modules.appservice._active_backup_client'
-
-
-def response(status=200, payload=None, headers=None, content=None):
- result = requests.Response()
- result.status_code = status
- result.headers.update({'Content-Type': 'application/json'})
- result.headers.update(headers or {})
- result._content = (json.dumps(payload).encode('utf-8') if payload is not None else b'') if content is None else content
- result._content_consumed = True
- result.encoding = 'utf-8'
- return result
-
-
-def cli_context():
- cli_ctx = MagicMock()
- cli_ctx.cloud.endpoints.resource_manager = 'https://management.example.test/'
- cli_ctx.cloud.endpoints.active_directory_resource_id = 'https://audience.example.test/'
- cli_ctx.data = {'command': 'webapp silo show'}
- return cli_ctx
-
-
-class ActiveBackupClientTests(unittest.TestCase):
-
- def setUp(self):
- self.ctx = cli_context()
- self.client = ActiveBackupClient(self.ctx, 'group', 'primary', subscription='subscription')
- self.resource = self.client.resource('backup')
-
- def test_resource_identity_and_encoding(self):
- self.assertEqual(self.resource,
- '/subscriptions/subscription/resourceGroups/group/providers/Microsoft.Web/sites/primary/silos/backup')
- client = ActiveBackupClient(self.ctx, 'a group', 'plan', 'serverfarms', subscription='sub')
- self.assertIn('/resourceGroups/a%20group/providers/Microsoft.Web/serverfarms/plan', client.resource())
- self.assertEqual(segment('x?#%'), 'x%3F%23%25')
- for value in ('', '..', '.', 'a/b', 'a\\b', 'a\nb', None):
- with self.subTest(value=value), self.assertRaises(InvalidArgumentValueError):
- segment(value)
-
- def test_api_version_query_and_origin(self):
- url = self.client.url(self.resource, {'enabled': False, 'count': 0, 'missing': None})
- query = parse_qs(urlsplit(url).query)
- self.assertEqual(query, {'api-version': [API_VERSION], 'enabled': ['false'], 'count': ['0']})
- supplied = self.client.url(self.resource + '?api-version=2026-01-01&x=1')
- self.assertEqual(parse_qs(urlsplit(supplied).query)['api-version'], ['2026-01-01'])
- for bad in ('https://evil.test/path', '//evil.test/path',
- 'http://management.example.test/path', 'https://user@management.example.test/path',
- 'https://management.example.test/path#fragment', 'https://management.example.test:0/path'):
- with self.subTest(address=bad), self.assertRaises(AzureResponseError):
- self.client.url(bad)
-
- def test_crud_operation_url_uses_async_status_mode_in_the_selected_cloud(self):
- self.ctx.cloud.endpoints.resource_manager = 'https://management.sovereign.example.test/'
- for resource_type in ('sites', 'serverfarms'):
- with self.subTest(resource_type=resource_type):
- client = ActiveBackupClient(
- self.ctx, 'group', 'primary', resource_type, subscription='subscription')
- parsed = urlsplit(client.operation_url('back up', 'op?#%'))
- self.assertEqual(parsed.scheme, 'https')
- self.assertEqual(parsed.netloc, 'management.sovereign.example.test')
- self.assertEqual(parsed.path, client.resource('back up') + '/operationresults/op%3F%23%25')
- self.assertEqual(parse_qs(parsed.query),
- {'api-version': [API_VERSION], 'azureAsyncOperation': ['1']})
- self.assertEqual(parsed.fragment, '')
- for operation_id in ('', '..', 'a/b', 'a\\b', None):
- with self.subTest(operation_id=operation_id), self.assertRaises(InvalidArgumentValueError):
- self.client.operation_url('backup', operation_id)
-
- def test_request_uses_cli_auth_cloud_and_no_redirects(self):
- result = response(payload={'properties': {'secret': 'not-logged'}})
- with patch(MODULE + '.Profile') as profile, patch(MODULE + '.requests.Session') as factory, \
- patch(MODULE + '.get_az_user_agent', return_value='test-cli'), \
- patch(MODULE + '.should_disable_connection_verify', return_value=False):
- profile.return_value.get_raw_token.return_value = (('Bearer', 'private-token', {}), 'sub', 'tenant')
- session = factory.return_value.__enter__.return_value
- session.request.return_value = result
- actual = self.client.request('PUT', self.resource, {'properties': {'enabled': False}})
- self.assertEqual(self.client.decode(actual), {'properties': {'secret': 'not-logged'}})
- call = session.request.call_args
- self.assertEqual(call.args[0], 'PUT')
- self.assertTrue(call.args[1].startswith(self.ctx.cloud.endpoints.resource_manager))
- self.assertFalse(call.kwargs['allow_redirects'])
- self.assertTrue(call.kwargs['verify'])
- self.assertEqual(call.kwargs['headers']['Authorization'], 'Bearer private-token')
- self.assertEqual(json.loads(call.kwargs['data']), {'properties': {'enabled': False}})
- profile.return_value.get_raw_token.assert_called_once_with(
- resource='https://audience.example.test/', subscription='subscription')
-
- def test_service_errors_preserve_code_and_request_id(self):
- for status, error_type in (
- (400, BadRequestError), (401, UnauthorizedError), (403, ForbiddenError),
- (404, ResourceNotFoundError), (409, UnclassifiedUserFault), (429, UnclassifiedUserFault),
- (500, AzureInternalError), (503, AzureInternalError)):
- failed = response(status, {'error': {'code': 'SiloBusy', 'message': 'Try again.'}},
- {'x-ms-request-id': 'request-one'})
- with self.subTest(status=status), patch(MODULE + '.Profile') as profile, \
- patch(MODULE + '.requests.Session') as factory:
- profile.return_value.get_raw_token.return_value = (('Bearer', 'token', {}), None, None)
- factory.return_value.__enter__.return_value.request.return_value = failed
- with self.assertRaises(error_type) as caught:
- self.client.request('GET', self.resource)
- self.assertIn('SiloBusy', str(caught.exception))
- self.assertIn('request-one', str(caught.exception))
- self.assertEqual(caught.exception.response.status_code, status)
- legacy = response(400, {'Code': 'LegacyFailure', 'Message': 'Legacy detail.'})
- with patch(MODULE + '.Profile') as profile, patch(MODULE + '.requests.Session') as factory:
- profile.return_value.get_raw_token.return_value = (('Bearer', 'token', {}), None, None)
- factory.return_value.__enter__.return_value.request.return_value = legacy
- with self.assertRaises(BadRequestError) as caught:
- self.client.request('GET', self.resource)
- self.assertIn('LegacyFailure', str(caught.exception))
- self.assertIn('Legacy detail.', str(caught.exception))
-
- def test_cli_global_policy_headers_are_forwarded(self):
- policy = MagicMock()
- policy.on_request.side_effect = lambda request: request.http_request.headers.update(
- {'x-ms-policy-external-evaluations': 'policy-token'})
- with patch(MODULE + '.Profile') as profile, patch(MODULE + '.requests.Session') as factory, \
- patch(MODULE + '.get_custom_hook_policy', return_value=policy):
- profile.return_value.get_raw_token.return_value = (('Bearer', 'token', {}), None, None)
- session = factory.return_value.__enter__.return_value
- session.request.return_value = response(payload={'id': 'silo'})
- self.client.request('PUT', self.resource, {'properties': {}})
- self.assertEqual(session.request.call_args.kwargs['headers']['x-ms-policy-external-evaluations'],
- 'policy-token')
- policy.on_request.assert_called_once()
-
- def test_transport_error_does_not_echo_credentials(self):
- with patch(MODULE + '.Profile') as profile, patch(MODULE + '.requests.Session') as factory:
- profile.return_value.get_raw_token.return_value = (('Bearer', 'token', {}), None, None)
- factory.return_value.__enter__.return_value.request.side_effect = requests.ConnectionError('secret URL')
- with self.assertRaises(AzureConnectionError) as caught:
- self.client.request('GET', self.resource)
- self.assertNotIn('secret URL', str(caught.exception))
-
- def test_response_decoding(self):
- self.assertIsNone(self.client.decode(response(204)))
- self.assertEqual(self.client.decode(response(payload={'value': []})), {'value': []})
- self.assertEqual(self.client.decode(response(headers={'Content-Type': 'application/xml'},
- content=b'')), {'content': ''})
- with self.assertRaises(AzureResponseError):
- self.client.decode(response(content=b'bad json'))
- with self.assertRaises(InvalidArgumentValueError):
- self.client.decode(response(headers={'Content-Type': 'application/zip'}, content=b'zip'))
-
- def test_collection_pagination_and_repeated_link(self):
- second = self.resource + '?page=2'
- with patch.object(self.client, 'request', side_effect=[
- response(payload={'value': [{'name': 'one'}], 'nextLink': second}),
- response(payload={'value': [{'name': 'two'}]})]) as request:
- self.assertEqual(self.client.list(self.resource), [{'name': 'one'}, {'name': 'two'}])
- self.assertEqual(request.call_count, 2)
- with patch.object(self.client, 'request', return_value=response(
- payload={'value': [], 'nextLink': self.resource})), self.assertRaises(AzureResponseError):
- self.client.list(self.resource)
- with patch.object(self.client, 'request', return_value=response(payload={'unexpected': []})), \
- self.assertRaises(AzureResponseError):
- self.client.list(self.resource)
- with patch.object(self.client, 'request', return_value=response(
- payload={'value': [], 'nextLink': 'https://evil.test/page'})) as request, \
- self.assertRaises(AzureResponseError):
- self.client.list(self.resource)
- request.assert_called_once()
-
- def test_synchronous_mutation_and_empty_delete(self):
- with patch.object(self.client, 'request', return_value=response(payload={'id': self.resource})):
- self.assertEqual(self.client.mutate('PUT', self.resource), {'id': self.resource})
- self.assertIsNone(self.client.mutate('DELETE', self.resource))
- with patch.object(self.client, 'request', return_value=response(204)):
- self.assertIsNone(self.client.mutate('DELETE', self.resource))
-
- def test_origin_resource_read_prefers_a_working_point_endpoint(self):
- origin = self.client.resource('origin')
- payload = {'id': origin, 'properties': {'outboundIpAddresses': 'fixture-ip', 'unknown': 1}}
- with patch.object(self.client, 'request', return_value=response(payload=payload)) as request, \
- patch.object(self.client, 'list') as listing, patch(MODULE + '.logger.warning') as warning:
- self.assertEqual(self.client.get_resource(origin), payload)
- request.assert_called_once_with('GET', origin)
- listing.assert_not_called()
- warning.assert_not_called()
-
- def test_origin_404_uses_the_exact_paginated_resource_and_warns_once(self):
- client = ActiveBackupClient(self.ctx, 'grüp', 'primary', subscription='subscription')
- origin = client.resource('origin')
- payload = {'id': unquote(origin).upper(), 'name': 'origin', 'type': 'Microsoft.Web/sites/silos',
- 'properties': {'endpointStatus': 'Enabled', 'unknown': {'preserved': True}}}
- foreign = dict(payload, id=origin.replace('/primary/', '/different/'))
- missing = ResourceNotFoundError('Original origin error; request-id.')
- with patch.object(client, 'request', side_effect=[
- missing, response(payload={'value': [foreign], 'nextLink': client.resource() + '?page=2'}),
- response(payload={'value': [payload]}), missing, response(payload={'value': [payload]})]) as request, \
- patch(MODULE + '.logger.warning') as warning:
- self.assertEqual(client.get_resource(origin), payload)
- self.assertEqual(client.get_resource(origin), payload)
- warning.assert_called_once()
- self.assertIn('Origin point GET returned HTTP 404', warning.call_args.args[0])
- self.assertEqual(request.call_count, 5)
-
- def test_origin_fallback_never_applies_to_other_reads_or_errors(self):
- plan = ActiveBackupClient(self.ctx, 'group', 'primary', 'serverfarms', subscription='subscription')
- cases = (
- (self.client, self.resource, ResourceNotFoundError('Missing replica')),
- (self.client, self.client.resource('origin') + '/config/web', ResourceNotFoundError('Missing config')),
- (plan, plan.resource('origin'), ResourceNotFoundError('Missing plan origin')),
- (self.client, self.client.resource('origin'), AzureResponseError('Forbidden')),
- (self.client, self.client.resource('origin'), AzureResponseError('Service failed')),
- )
- for client, path, error in cases:
- with self.subTest(path=path, error=str(error)), \
- patch.object(client, 'request', side_effect=error), patch.object(client, 'list') as listing:
- with self.assertRaises(type(error)) as caught:
- client.get_resource(path)
- self.assertIs(caught.exception, error)
- listing.assert_not_called()
-
- def test_origin_collection_missing_duplicate_or_invalid_results_remain_errors(self):
- origin = self.client.resource('origin')
- payload = {'id': origin, 'type': 'Microsoft.Web/sites/silos', 'properties': {'endpointStatus': 'Enabled'}}
- for entries, error_type in (
- ([], ResourceNotFoundError),
- ([dict(payload, id=self.resource)], ResourceNotFoundError),
- ([payload, dict(payload)], AzureResponseError),
- ([dict(payload, type='Microsoft.Web/sites')], AzureResponseError),
- ([dict(payload, properties=None)], AzureResponseError)):
- with self.subTest(entries=entries), \
- patch.object(self.client, 'request', side_effect=ResourceNotFoundError('Original error')), \
- patch.object(self.client, 'list', return_value=entries), \
- patch(MODULE + '.logger.warning') as warning, self.assertRaises(error_type):
- self.client.get_resource(origin)
- warning.assert_not_called()
-
- def test_origin_collection_failures_are_not_hidden(self):
- origin = self.client.resource('origin')
- for error in (AzureResponseError('List forbidden'), ResourceNotFoundError('List missing')):
- with self.subTest(error=str(error)), \
- patch.object(self.client, 'request', side_effect=ResourceNotFoundError('Point missing')), \
- patch.object(self.client, 'list', side_effect=error):
- with self.assertRaises(type(error)) as caught:
- self.client.get_resource(origin)
- self.assertIs(caught.exception, error)
-
- def test_no_wait_returns_receipt_without_polling(self):
- poll = self.resource + '/operationresults/operation'
- for header in ('Location', 'Azure-AsyncOperation'):
- with self.subTest(header=header), patch.object(self.client, 'request', return_value=response(
- 202, {'id': self.resource}, {header: poll})) as request:
- result = self.client.mutate('PUT', self.resource, no_wait=True)
- self.assertEqual(result['status'], 'Accepted')
- self.assertEqual(result['operationId'], 'operation')
- self.assertEqual(result['resourceId'], self.resource)
- request.assert_called_once()
-
- def test_async_status_header_takes_precedence_and_preserves_its_query(self):
- location = self.resource + '/operationresults/operation'
- poll = self.client.url(location, {'azureAsyncOperation': 1})
- initial = response(202, headers={
- 'Location': location, 'Azure-AsyncOperation': poll, 'Retry-After': '0'})
- with patch.object(self.client, 'request', return_value=initial) as request:
- receipt = self.client.mutate('DELETE', self.resource, no_wait=True)
- self.assertEqual(receipt['operationUrl'], poll)
- request.assert_called_once()
- with patch.object(self.client, 'request', side_effect=[
- initial, response(payload={'status': 'Succeeded'})]) as request, patch(MODULE + '.time.sleep'):
- self.assertIsNone(self.client.mutate('DELETE', self.resource))
- self.assertEqual(request.call_args.args[:2], ('GET', poll))
- self.assertEqual(request.call_count, 2)
-
- def test_poll_status_body_and_final_resource(self):
- poll = self.resource + '/operationresults/operation'
- with patch.object(self.client, 'request', side_effect=[
- response(202, headers={'Location': poll, 'Retry-After': '0'}),
- response(payload={'status': 'InProgress'}, headers={'Retry-After': '0'}),
- response(payload={'status': 'Succeeded'}),
- response(payload={'id': self.resource, 'properties': {'revisionNumber': 7}})]) as request, \
- patch(MODULE + '.time.sleep'):
- result = self.client.mutate('PUT', self.resource)
- self.assertEqual(result['properties']['revisionNumber'], 7)
- self.assertEqual(request.call_args.args, ('GET', self.resource))
- self.assertEqual(request.call_count, 4)
-
- def test_operation_status_envelopes_are_not_resources(self):
- for identifier in (None, '', '/operations/op', 123):
- with self.subTest(identifier=identifier):
- result = self.client.operation(self.resource, response(payload={
- 'id': identifier, 'status': 'Succeeded', 'properties': None}))
- self.assertEqual(result['status'], 'Succeeded')
- self.assertNotIn('resource', result)
- resource = {'id': self.resource, 'properties': {'provisioningState': 'Succeeded'}}
- self.assertEqual(self.client.operation(self.resource, response(payload=resource))['resource'], resource)
-
- def test_async_writes_return_the_canonical_target_resource(self):
- poll = self.resource + '/operationresults/op?azureAsyncOperation=1'
- resource = {'id': self.resource, 'properties': {'revisionNumber': 8}}
- for method in ('PUT', 'PATCH'):
- for terminal in (
- {'status': 'Succeeded'},
- {'id': None, 'status': 'Succeeded', 'properties': None},
- {'id': '/operations/op', 'status': 'Succeeded'},
- {'id': self.resource, 'properties': {'revisionNumber': 7}}):
- with self.subTest(method=method, terminal=terminal), \
- patch.object(self.client, 'request', side_effect=[
- response(202, headers={'Azure-AsyncOperation': poll, 'Retry-After': '0'}),
- response(payload=terminal), response(payload=resource)]) as request, \
- patch(MODULE + '.time.sleep'):
- self.assertEqual(self.client.mutate(method, self.resource), resource)
- self.assertEqual(request.call_args.args, ('GET', self.resource))
- self.assertEqual(request.call_count, 3)
-
- def test_final_resource_read_errors_remain_errors(self):
- poll = self.resource + '/operationresults/op'
- with patch.object(self.client, 'request', side_effect=[
- response(202, headers={'Location': poll, 'Retry-After': '0'}),
- response(payload={'id': None, 'status': 'Succeeded'}),
- ResourceNotFoundError('The target was not found.')]) as request, \
- patch(MODULE + '.time.sleep'), self.assertRaises(ResourceNotFoundError):
- self.client.mutate('PUT', self.resource)
- self.assertEqual(request.call_args.args, ('GET', self.resource))
-
- def test_delete_and_post_do_not_get_deleted_resource_or_action(self):
- poll = self.resource + '/operationresults/operation'
- for method in ('DELETE', 'POST'):
- with self.subTest(method=method), patch.object(self.client, 'request', side_effect=[
- response(202, headers={'Location': poll, 'Retry-After': '0'}),
- response(payload={'id': None, 'status': 'Succeeded'})]) as request, patch(MODULE + '.time.sleep'):
- self.assertIsNone(self.client.mutate(method, self.resource))
- self.assertEqual(request.call_count, 2)
-
- def test_http_200_terminal_failure_is_not_success(self):
- for status in ('Failed', 'Cancelled', 'TimedOut'):
- with self.subTest(status=status), self.assertRaises(AzureResponseError) as caught:
- self.client.operation(self.resource, response(payload={
- 'status': status, 'error': {'code': 'SiloFailed', 'message': 'Provisioning failed.'}}))
- self.assertIn('SiloFailed', str(caught.exception))
-
- def test_missing_or_cross_origin_poll_address_fails(self):
- for headers in ({}, {'Location': 'https://evil.test/poll'}):
- with self.subTest(headers=headers), \
- patch.object(self.client, 'request', return_value=response(202, headers=headers)), \
- self.assertRaises(AzureResponseError):
- self.client.mutate('PUT', self.resource)
-
- def test_timeout_does_not_cancel_service(self):
- with patch(MODULE + '.time.monotonic', side_effect=[0, 2]), \
- patch.object(self.client, 'request') as request, self.assertRaises(AzureResponseError):
- self.client.wait(self.resource, timeout=1)
- request.assert_not_called()
- for value in (0, -1):
- with self.subTest(timeout=value), self.assertRaises(InvalidArgumentValueError):
- self.client.wait(self.resource, timeout=value)
- with patch.object(self.client, 'request') as request, self.assertRaises(InvalidArgumentValueError):
- self.client.mutate('PUT', self.resource, timeout=value)
- request.assert_not_called()
-
- def test_poll_io_timeout_is_bounded_by_remaining_deadline(self):
- with patch(MODULE + '.time.monotonic', side_effect=[0, 0.25]), \
- patch.object(self.client, 'request', return_value=response(payload={'status': 'Succeeded'})) as request:
- self.client.wait(self.resource, timeout=1)
- self.assertEqual(request.call_args.kwargs['timeout'], 0.75)
-
- def test_http_date_retry_after(self):
- with patch(MODULE + '.datetime') as clock, patch(MODULE + '.time.sleep') as sleep, \
- patch.object(self.client, 'request', return_value=response(payload={'status': 'Succeeded'})):
- clock.now.return_value = datetime(2026, 10, 18, 23, 0, 0, tzinfo=timezone.utc)
- self.client.wait(self.resource, initial=response(
- 202, headers={'Retry-After': 'Sun, 18 Oct 2026 23:00:05 GMT'}))
- sleep.assert_called_once_with(5)
-
- def test_only_transient_polling_reads_are_retried(self):
- for status in (429, 500, 502, 503, 504):
- error = _service_error(response(status, headers={'Retry-After': '0'}))
- with self.subTest(status=status), patch.object(self.client, 'request', side_effect=[
- error, response(payload={'status': 'Succeeded'})]) as request, patch(MODULE + '.time.sleep'):
- self.assertEqual(self.client.wait(self.resource)['status'], 'Succeeded')
- self.assertEqual(request.call_count, 2)
- for status in (400, 401, 403, 409):
- error = _service_error(response(status))
- with self.subTest(status=status), patch.object(self.client, 'request', side_effect=error) as request, \
- self.assertRaises(type(error)):
- self.client.wait(self.resource)
- request.assert_called_once()
-
- def test_operation_inspection_preserves_pending_and_success(self):
- running = self.client.operation(self.resource, response(202))
- succeeded = self.client.operation(self.resource, response())
- self.assertEqual(running['status'], 'InProgress')
- self.assertEqual(succeeded['status'], 'Succeeded')
- with self.assertRaises(AzureResponseError):
- self.client.operation(self.resource, response(payload={'status': 'Unexpected'}))
-
- def test_poll_retry_after_and_transient_missing_operation(self):
- initial = response(202, headers={'Retry-After': '0'})
- with patch.object(self.client, 'request', side_effect=[
- ResourceNotFoundError('Not yet visible'), response(payload={'status': 'Succeeded'})]) as request, \
- patch(MODULE + '.time.sleep'):
- self.assertEqual(self.client.wait(self.resource, initial=initial)['status'], 'Succeeded')
- self.assertEqual(request.call_count, 2)
- with patch.object(self.client, 'request', side_effect=ResourceNotFoundError('Missing')), \
- self.assertRaises(ResourceNotFoundError):
- self.client.wait(self.resource)
- for retry in ('malformed', 'nan', '-1', '0'):
- with self.subTest(header=retry), \
- patch.object(self.client, 'request', return_value=response(payload={'status': 'Succeeded'})), \
- patch(MODULE + '.time.sleep') as sleep:
- self.client.wait(self.resource, initial=response(202, headers={'Retry-After': retry}))
- self.assertGreaterEqual(sleep.call_args.args[0], 0.1)
- self.assertLessEqual(sleep.call_args.args[0], 5)
-
- def test_json_inputs(self):
- self.assertEqual(load_json('{"enabled":false}'), {'enabled': False})
- self.assertEqual(load_json('[1,2]', list), [1, 2])
- self.assertEqual(load_json('[1,2]', expected_type=None), [1, 2])
- for value in ('not json', '[1]', 'null'):
- with self.subTest(value=value), self.assertRaises(InvalidArgumentValueError):
- load_json(value)
- with tempfile.TemporaryDirectory() as directory:
- filename = Path(directory) / 'body.json'
- filename.write_text('{"value":1}', encoding='utf-8')
- self.assertEqual(load_json('@' + str(filename)), {'value': 1})
-
- def test_byte_exact_download_and_overwrite_policy(self):
- payload = b'\x00\xffbinary\x00'
- with tempfile.TemporaryDirectory() as directory:
- destination = Path(directory) / 'output.bin'
- with patch.object(self.client, '_response') as context:
- context.return_value.__enter__.return_value = response(content=payload)
- self.assertEqual(self.client.consume('GET', self.resource, str(destination)),
- {'file': str(destination)})
- self.assertEqual(destination.read_bytes(), payload)
- with self.assertRaises(FileOperationError):
- self.client.consume('GET', self.resource, str(destination))
- with patch.object(self.client, '_response') as context:
- context.return_value.__enter__.return_value = response(content=b'new')
- self.client.consume('GET', self.resource, str(destination), overwrite=True)
- self.assertEqual(destination.read_bytes(), b'new')
- self.assertEqual(list(Path(directory).iterdir()), [destination])
-
- def test_live_log_decoding_without_buffering(self):
- streamed = MagicMock()
- streamed.iter_content.return_value = iter([b'\xe2', b'\x82\xac', b'\xff'])
- with patch.object(self.client, '_response') as context, patch(MODULE + '.sys.stdout', new_callable=io.StringIO) as stdout:
- context.return_value.__enter__.return_value = streamed
- self.assertIsNone(self.client.consume('GET', self.resource, tail=True))
- self.assertEqual(stdout.getvalue(), '\u20ac\ufffd')
- streamed.json.assert_not_called()
-
- def test_interrupted_download_removes_only_its_own_partial_file(self):
- with tempfile.TemporaryDirectory() as directory:
- destination = Path(directory) / 'output.bin'
- keep = Path(directory) / 'unrelated'
- keep.write_bytes(b'keep')
- streamed = MagicMock()
- streamed.iter_content.side_effect = KeyboardInterrupt
- with patch.object(self.client, '_response') as context, self.assertRaises(KeyboardInterrupt):
- context.return_value.__enter__.return_value = streamed
- self.client.consume('GET', self.resource, str(destination))
- self.assertFalse(destination.exists())
- self.assertEqual(list(Path(directory).iterdir()), [keep])
- self.assertEqual(keep.read_bytes(), b'keep')
- context.return_value.__exit__.assert_called_once()
-
- def test_download_permission_failure_is_explicit(self):
- with tempfile.TemporaryDirectory() as directory:
- destination = Path(directory) / 'output.bin'
- with patch.object(self.client, '_response') as context, \
- patch(MODULE + '.tempfile.mkstemp', side_effect=PermissionError(13, 'Permission denied')), \
- self.assertRaises(FileOperationError) as caught:
- context.return_value.__enter__.return_value = response(content=b'bytes')
- self.client.consume('GET', self.resource, str(destination))
- self.assertIn('Permission denied', str(caught.exception))
- self.assertEqual(list(Path(directory).iterdir()), [])
-
-
-if __name__ == '__main__':
- unittest.main()
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands.py
index 0e3bfaa4124..0e00964ab0d 100644
--- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands.py
+++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands.py
@@ -30,16 +30,19 @@ def _lifecycle(self, resource_group, linux):
app = self.create_random_name('abapp', 24)
self.kwargs.update(rg=resource_group, plan=plan, app=app,
primary=PRIMARY_LOCATION, secondary=SILO_LOCATION,
- linux='true' if linux else 'false')
+ linux='true' if linux else 'false', vnet=self.create_random_name('abnet', 20))
self.cmd('appservice plan create -g {rg} -n {plan} --location {primary} '
'--sku P1V3 --number-of-workers 2 --is-linux {linux}')
self.cmd('webapp create -g {rg} -n {app} --plan {plan}' +
(' --runtime "PYTHON:3.12"' if linux else ''))
try:
self.cmd('appservice plan silo create -g {rg} -n {plan} --silo backup-plan '
- '--location {secondary} --primary-location {primary} --number-of-workers 2')
+ '--location {secondary} --primary-location {primary} --number-of-workers 2 --no-wait')
+ self.cmd('appservice plan silo wait -g {rg} -n {plan} --silo backup-plan --created')
+ self.cmd('appservice plan silo show -g {rg} -n {plan} --silo backup-plan')
self.cmd('webapp silo create -g {rg} -n {app} --silo backup-app --location {secondary} '
- '--primary-location {primary} --traffic-mode Active')
+ '--primary-location {primary} --traffic-mode Active --no-wait')
+ self.cmd('webapp silo wait -g {rg} -n {app} --silo backup-app --created')
self.cmd('webapp silo show -g {rg} -n {app} --silo backup-app',
checks=[self.check('properties.provisioningState', 'Succeeded')])
self.cmd('webapp silo list -g {rg} -n {app}')
@@ -47,16 +50,26 @@ def _lifecycle(self, resource_group, linux):
self.cmd('appservice plan silo update -g {rg} -n {plan} --silo backup-plan --number-of-workers 2')
self.cmd('webapp silo update -g {rg} -n {app} --silo backup-app --follow-last-known-good false')
self.cmd('webapp managed-traffic update -g {rg} -n {app} --traffic-mode Passive')
- self.cmd('webapp managed-traffic list -g {rg} -n {app} --expand-endpoints')
+ self.cmd('webapp managed-traffic list -g {rg} -n {app} --expand endpoints')
self.cmd('webapp managed-traffic show -g {rg} -n {app}')
self.cmd('webapp last-known-good mark -g {rg} -n {app}')
self.cmd('webapp last-known-good show -g {rg} -n {app}')
self.cmd('webapp last-known-good list -g {rg} -n {app}')
self.cmd('webapp silo refresh -g {rg} -n {app}')
- self.cmd('webapp silo config show -g {rg} -n {app} --silo backup-app')
- self.cmd('webapp silo instance list -g {rg} -n {app} --silo backup-app')
- self.cmd('webapp silo deployment list -g {rg} -n {app} --silo backup-app')
- self.cmd('webapp silo restart -g {rg} -n {app} --silo backup-app --synchronous true')
+ vnet_result = self.cmd(
+ 'network vnet create -g {rg} -n {vnet} --location {secondary} '
+ '--address-prefixes 10.237.16.0/24 --subnet-name integration --subnet-prefixes 10.237.16.0/26'
+ ).get_output_in_json()
+ self.kwargs['subnet'] = vnet_result['newVNet']['subnets'][0]['id']
+ self.cmd('network vnet subnet update -g {rg} --vnet-name {vnet} -n integration '
+ '--delegations Microsoft.Web/serverFarms')
+ self.cmd('webapp silo vnet-integration add -g {rg} -n {app} --silo backup-app --subnet {subnet}')
+ try:
+ self.cmd('webapp silo vnet-integration show -g {rg} -n {app} --silo backup-app')
+ self.cmd('webapp silo vnet-integration update -g {rg} -n {app} --silo backup-app --subnet {subnet}')
+ self.cmd('appservice plan silo vnet-integration list -g {rg} -n {plan} --silo backup-plan')
+ finally:
+ self.cmd('webapp silo vnet-integration remove -g {rg} -n {app} --silo backup-app --yes')
finally:
try:
self.cmd('webapp silo delete -g {rg} -n {app} --silo backup-app --yes')
diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py
deleted file mode 100644
index 21a324ae729..00000000000
--- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py
+++ /dev/null
@@ -1,939 +0,0 @@
-# --------------------------------------------------------------------------------------------
-# Copyright (c) Microsoft Corporation. All rights reserved.
-# Licensed under the MIT License. See License.txt in the project root for license information.
-# --------------------------------------------------------------------------------------------
-
-from contextlib import redirect_stderr, redirect_stdout
-import inspect
-import io
-import json
-import shlex
-import unittest
-from unittest.mock import patch
-from urllib.parse import parse_qs, urlsplit
-
-from azure.cli.core.azclierror import (AzureResponseError, InvalidArgumentValueError, MutuallyExclusiveArgumentError,
- RequiredArgumentMissingError, ResourceNotFoundError)
-from azure.cli.command_modules.appservice import active_backup as commands
-from azure.cli.command_modules.appservice._active_backup_client import ActiveBackupClient, API_VERSION
-from azure.cli.command_modules.appservice._active_backup_commands import (
- ADDITIONAL_EXAMPLES, ARGUMENT_HELP, COMMAND_GROUPS, COMMAND_SUMMARIES)
-from .test_active_backup_client import cli_context, response
-
-
-MODULE = 'azure.cli.command_modules.appservice.active_backup'
-
-# Independent route expectations from SitesCsmSurface, including each hidden API and input variant.
-PASS_THROUGH_CASES = (
- ('P01', 'show_silo_php_logging', 'GET', '/phplogging', {}),
- ('P02', 'list_silo_deployments', 'GET', '/deployments', {}),
- ('P03', 'show_silo_deployment', 'GET', '/deployments/deploy', {'deployment_id': 'deploy'}),
- ('P04', 'list_silo_deployment_logs', 'GET', '/deployments/deploy/log', {'deployment_id': 'deploy'}),
- ('P05', 'delete_silo_process', 'DELETE', '/processes/123', {'process_id': '123'}),
- ('P06', 'stop_silo_process', 'POST', '/processes/123/stop', {'process_id': '123'}),
- ('P07', 'restart_silo_container', 'POST', '/containers/main/restart', {'container_name': 'main'}),
- ('P08', 'show_silo_process', 'GET', '/processes/123', {'process_id': '123'}),
- ('P09', 'list_silo_process_threads', 'GET', '/processes/123/threads', {'process_id': '123'}),
- ('P10', 'show_silo_process_thread', 'GET', '/processes/123/threads/thread',
- {'process_id': '123', 'thread_id': 'thread'}),
- ('P11', 'list_silo_process_modules', 'GET', '/processes/123/modules', {'process_id': '123'}),
- ('P12', 'show_silo_process_module', 'GET', '/processes/123/modules/0x1',
- {'process_id': '123', 'base_address': '0x1'}),
- ('P13', 'dump_silo_process', 'GET', '/processes/123/dump', {'process_id': '123', 'file': 'out.bin'}),
- ('P14', 'download_silo_container_logs', 'POST', '/containerlogs', {'format': 'raw', 'file': 'out.bin'}),
- ('P15', 'download_silo_container_logs', 'POST', '/containerlogs/zip', {'format': 'zip', 'file': 'out.bin'}),
- ('P16', 'list_silo_startup_logs', 'GET', '/startuplogs', {}),
- ('P17', 'show_silo_startup_summary', 'GET', '/startuplogs/summary', {}),
- ('P18', 'show_silo_startup_log', 'GET', '/startuplogs/startup.log', {'filename': 'startup.log'}),
- ('P19', 'list_silo_logs', 'GET', '/logs', {}),
- ('P20', 'tail_silo_logs', 'GET', '/logs/live', {}),
- ('P21', 'list_silo_webjobs', 'GET', '/webjobs', {}),
- ('P22', 'show_silo_webjob', 'GET', '/webjobs/job', {'webjob_name': 'job'}),
- ('P23', 'list_silo_continuous_webjobs', 'GET', '/continuouswebjobs', {}),
- ('P24', 'show_silo_continuous_webjob', 'GET', '/continuouswebjobs/job', {'webjob_name': 'job'}),
- ('P25', 'start_silo_continuous_webjob', 'POST', '/continuouswebjobs/job/start', {'webjob_name': 'job'}),
- ('P26', 'stop_silo_continuous_webjob', 'POST', '/continuouswebjobs/job/stop', {'webjob_name': 'job'}),
- ('P27', 'list_silo_triggered_webjobs', 'GET', '/triggeredwebjobs', {}),
- ('P28', 'show_silo_triggered_webjob', 'GET', '/triggeredwebjobs/job', {'webjob_name': 'job'}),
- ('P29', 'list_silo_webjob_history', 'GET', '/triggeredwebjobs/job/history', {'webjob_name': 'job'}),
- ('P30', 'show_silo_webjob_history', 'GET', '/triggeredwebjobs/job/history/run',
- {'webjob_name': 'job', 'run_id': 'run'}),
- ('P31', 'run_silo_triggered_webjob', 'POST', '/triggeredwebjobs/job/run', {'webjob_name': 'job'}),
- ('P32', 'list_silo_site_extensions', 'GET', '/siteextensions', {}),
- ('P33', 'show_silo_site_extension', 'GET', '/siteextensions/ext', {'extension_name': 'ext'}),
- ('P34', 'check_silo_connectivity', 'POST', '/connectivityCheck', {'body': '{"properties":{}}'}),
- ('P35', 'check_silo_dns', 'POST', '/dnsCheck', {'body': '{"properties":{}}'}),
- ('P36', 'check_silo_tcp', 'POST', '/tcpPingCheck', {'body': '{"properties":{}}'}),
- ('P37', 'invoke_silo_extension', 'GET', '/instances/worker/extensions/api/settings',
- {'instance': 'worker', 'path': 'api/settings'}),
- ('P38', 'show_silo_onedeploy_status', 'GET', '/extensions/onedeploy', {}),
- ('P39', 'show_silo_msdeploy_log', 'GET', '/extensions/MSDeploy/log', {}),
- ('P40', 'list_silo_instances', 'GET', '/instances', {}),
- ('P41', 'show_silo_instance', 'GET', '/instances/worker', {'instance': 'worker'}),
- ('P42', 'show_silo_config', 'GET', '/config/web', {}),
- ('P43', 'list_silo_config', 'GET', '/config', {}),
- ('P44', 'list_silo_config_snapshots', 'GET', '/config/web/snapshots', {}),
- ('P45', 'show_silo_config_snapshot', 'GET', '/config/web/snapshots/snap', {'snapshot_id': 'snap'}),
- ('P46', 'show_silo_config_snapshot', 'POST', '/config/web/snapshots/snap/listsecrets',
- {'snapshot_id': 'snap', 'include_secrets': True}),
- ('P47', 'list_silo_keyvault_references', 'GET', '/config/configreferences/connectionstrings', {}),
- ('P48', 'refresh_silo_keyvault_references', 'POST', '/config/configreferences/appsettings/refresh', {}),
- ('P49', 'list_silo_detected_stacks', 'GET', '/detectedstacks', {}),
- ('P50', 'list_silo_appsettings', 'POST', '/config/appsettings/list', {}),
- ('P51', 'list_silo_storage_accounts', 'POST', '/config/azurestorageaccounts/list', {}),
- ('P52', 'show_silo_connection_strings', 'GET', '/config/connectionstrings', {}),
- ('P53', 'list_silo_connection_strings', 'POST', '/config/connectionstrings/list', {}),
- ('P54', 'show_silo_connection_strings', 'POST', '/config/web/connectionstrings/key', {'key': 'key'}),
- ('P55', 'show_silo_auth', 'GET', '/config/authsettings', {'metadata_only': True}),
- ('P56', 'show_silo_auth', 'POST', '/config/authsettings/list', {}),
- ('P57', 'show_silo_auth_v2', 'GET', '/config/authsettingsV2/list', {'legacy': True}),
- ('P58', 'show_silo_auth_v2', 'GET', '/config/authsettingsV2', {}),
- ('P59', 'show_silo_push_settings', 'POST', '/config/pushsettings/list', {}),
- ('P60', 'list_silo_metadata', 'POST', '/config/metadata/list', {}),
- ('P61', 'show_silo_log_config', 'GET', '/config/logs', {}),
- ('P62', 'list_silo_premier_addons', 'GET', '/premieraddons', {}),
- ('P63', 'show_silo_premier_addon', 'GET', '/premieraddons/addon', {'addon_name': 'addon'}),
- ('P64', 'list_silo_metrics', 'GET', '/metrics', {}),
- ('P65', 'list_silo_metric_definitions', 'GET', '/metricdefinitions', {}),
- ('P66', 'list_silo_perf_counters', 'GET', '/perfcounters', {}),
- ('P67', 'restart_site_silo', 'POST', '/restart', {}),
- ('P68', 'start_site_silo', 'POST', '/start', {}),
- ('P69', 'stop_site_silo', 'POST', '/stop', {}),
- ('P70', 'start_silo_network_trace', 'POST', '/networkTrace/start', {'legacy': True}),
- ('P71', 'start_silo_network_trace', 'POST', '/networkTrace/startOperation', {}),
- ('P72', 'stop_silo_network_trace', 'POST', '/networkTrace/stop', {}),
- ('P73', 'show_silo_network_trace_operation', 'GET', '/networkTrace/operationresults/op', {'operation_id': 'op'}),
- ('P74', 'show_silo_network_trace', 'GET', '/networkTrace/op', {'operation_id': 'op'}),
- ('P75', 'show_silo_runtime_operation', 'GET', '/operations/op', {'operation_id': 'op'}),
- ('P76', 'scan_silo_antivirus', 'POST', '/antivirus/queue', {}),
- ('P77', 'show_silo_antivirus_config', 'GET', '/antivirus/config', {}),
- ('P78', 'update_silo_antivirus_config', 'PUT', '/antivirus/config', {'body': '{"properties":{}}'}),
- ('P79', 'list_silo_antivirus_logs', 'GET', '/antiviruslogs', {}),
- ('P80', 'show_silo_antivirus_log', 'POST', '/antiviruslogs/scan/list',
- {'scan_id': 'scan', 'include_file_paths': True}),
- ('P81', 'show_silo_antivirus_log', 'GET', '/antiviruslogs/scan', {'scan_id': 'scan'}),
- ('P82', 'list_silo_deployment_statuses', 'GET', '/deploymentStatus', {}),
- ('P83', 'list_silo_sitecontainers', 'GET', '/sitecontainers', {}),
- ('P84', 'show_silo_sitecontainer', 'GET', '/sitecontainers/main', {'container_name': 'main'}),
-)
-
-RESOURCE_CASES = (
- ('C01', 'list_plan_silos', True, 'GET', '/silos', {}),
- ('C02', 'show_plan_silo', True, 'GET', '/silos/backup', {'silo': 'backup'}),
- ('C03', 'create_plan_silo', True, 'PUT', '/silos/backup',
- {'silo': 'backup', 'location': 'westus2', 'primary_location': 'eastus'}),
- ('C04', 'update_plan_silo', True, 'PUT', '/silos/backup', {'silo': 'backup', 'number_of_workers': 2}),
- ('C05', 'delete_plan_silo', True, 'DELETE', '/silos/backup', {'silo': 'backup'}),
- ('C06', 'show_plan_silo_operation', True, 'GET', '/silos/backup/operationresults/op',
- {'silo': 'backup', 'operation_id': 'op'}),
- ('C07', 'list_plan_silo_vnet_integrations', True, 'GET', '/silos/backup/virtualNetworkIntegrations',
- {'silo': 'backup'}),
- ('C08', 'list_site_silos', False, 'GET', '/silos', {}),
- ('C09', 'show_site_silo', False, 'GET', '/silos/backup', {'silo': 'backup'}),
- ('C10', 'create_site_silo', False, 'PUT', '/silos/backup',
- {'silo': 'backup', 'location': 'westus2', 'primary_location': 'eastus', 'traffic_mode': 'Active'}),
- ('C11', 'update_site_silo', False, 'PUT', '/silos/backup',
- {'silo': 'backup', 'endpoint_status': 'Disabled'}),
- ('C12', 'delete_site_silo', False, 'DELETE', '/silos/backup', {'silo': 'backup'}),
- ('C13', 'show_site_silo_operation', False, 'GET', '/silos/backup/operationresults/op',
- {'silo': 'backup', 'operation_id': 'op'}),
- ('C14', 'refresh_site_silos', False, 'POST', '/refreshSilos', {}),
- ('C15', 'list_last_known_good', False, 'GET', '/lastKnownGood', {}),
- ('C16', 'show_last_known_good', False, 'GET', '/lastKnownGood/default', {}),
- ('C17', 'mark_last_known_good', False, 'POST', '/lastKnownGood/default/mark', {}),
- ('C18', 'list_managed_traffic', False, 'GET', '/managedTrafficConfig', {}),
- ('C19', 'show_managed_traffic', False, 'GET', '/managedTrafficConfig/default', {}),
- ('C20', 'update_managed_traffic', False, 'PUT', '/managedTrafficConfig/default', {'traffic_mode': 'Active'}),
- ('C21', 'list_managed_traffic_metrics', False, 'GET', '/managedTrafficConfig/default/metrics', {}),
- ('C22', 'show_silo_vnet_integration', False, 'GET', '/silos/backup/virtualNetworkConnections/virtualNetwork',
- {'silo': 'backup'}),
- ('C23', 'add_silo_vnet_integration', False, 'PUT', '/silos/backup/virtualNetworkConnections/virtualNetwork',
- {'silo': 'backup', 'subnet': '/subscriptions/sub/subnets/net'}),
- ('C24', 'update_silo_vnet_integration', False, 'PATCH', '/silos/backup/virtualNetworkConnections/virtualNetwork',
- {'silo': 'backup', 'subnet': '/subscriptions/sub/subnets/net'}),
- ('C25', 'remove_silo_vnet_integration', False, 'DELETE', '/silos/backup/virtualNetworkConnections/virtualNetwork',
- {'silo': 'backup'}),
-)
-
-PAGED_COLLECTION_APIS = {
- 'P02', 'P09', 'P11', 'P21', 'P23', 'P27', 'P29', 'P32', 'P40',
- 'P43', 'P44', 'P47', 'P62', 'P64', 'P65', 'P66', 'P82', 'P83',
-}
-
-
-class ActiveBackupCommandTests(unittest.TestCase):
-
- def setUp(self):
- self.ctx = cli_context()
- self.cmd = type('Command', (), {'cli_ctx': self.ctx})()
- self.client = ActiveBackupClient(self.ctx, 'group', 'primary', subscription='subscription')
- self.client_patch = patch(MODULE + '._client', return_value=self.client)
- self.client_patch.start()
- self.addCleanup(self.client_patch.stop)
- self.request_patch = patch.object(self.client, 'request', return_value=response(payload={'value': []}))
- self.request = self.request_patch.start()
- self.addCleanup(self.request_patch.stop)
-
- def test_all_25_resource_action_mappings(self):
- self.assertEqual([case[0] for case in RESOURCE_CASES], ['C{:02}'.format(i) for i in range(1, 26)])
- for key, handler, plan, method, suffix, kwargs in RESOURCE_CASES:
- client = ActiveBackupClient(self.ctx, 'group', 'primary',
- 'serverfarms' if plan else 'sites', subscription='subscription')
- result = response(204) if key in ('C14', 'C25') else response(payload={
- 'location': 'westus2', 'properties': {'primaryLocation': 'eastus'}, 'value': []})
- with self.subTest(api=key), patch(MODULE + '._client', return_value=client), \
- patch.object(client, 'request', return_value=result) as request, patch(MODULE + '.logger'):
- getattr(commands, handler)(self.cmd, 'group', 'primary', **kwargs)
- call = request.call_args
- self.assertEqual(call.args[0], method)
- self.assertEqual(urlsplit(call.args[1]).path, client.parent + suffix)
- self.assertEqual(request.call_count, 2 if key in ('C04', 'C11') else 1)
-
- def test_all_84_pass_through_methods_have_exact_route_and_no_preflight(self):
- self.assertEqual([case[0] for case in PASS_THROUGH_CASES], ['P{:02}'.format(i) for i in range(1, 85)])
- registered = {handler for entries in COMMAND_GROUPS.values() for _, handler in entries}
- for key, handler, method, suffix, kwargs in PASS_THROUGH_CASES:
- with self.subTest(api=key, handler=handler), \
- patch.object(self.client, 'consume', return_value={'file': 'out'}) as consume, \
- patch(MODULE + '._primary_location') as primary:
- self.request.reset_mock()
- getattr(commands, handler)(self.cmd, 'group', 'primary', 'backup', **kwargs)
- call = consume.call_args if consume.called else self.request.call_args
- self.assertIsNotNone(call)
- self.assertEqual(call.args[0], method)
- self.assertEqual(urlsplit(call.args[1]).path, self.client.resource('backup') + suffix)
- self.assertEqual(self.request.call_count + consume.call_count, 1)
- primary.assert_not_called()
- self.assertIn(handler, registered)
-
- def test_instance_qualified_process_and_live_log_routes(self):
- for _, handler, method, suffix, kwargs in PASS_THROUGH_CASES:
- if not (suffix.startswith('/processes/') or handler in ('restart_silo_container', 'tail_silo_logs')):
- continue
- with self.subTest(handler=handler), patch.object(self.client, 'consume') as consume:
- self.request.reset_mock()
- getattr(commands, handler)(self.cmd, 'group', 'primary', 'backup',
- **dict(kwargs, instance='worker'))
- call = consume.call_args if consume.called else self.request.call_args
- self.assertEqual(call.args[0], method)
- self.assertEqual(urlsplit(call.args[1]).path,
- self.client.resource('backup') + '/instances/worker' + suffix)
-
- def test_every_paged_pass_through_returns_all_collection_items(self):
- for key, handler, _, suffix, kwargs in PASS_THROUGH_CASES:
- if key not in PAGED_COLLECTION_APIS:
- continue
- with self.subTest(api=key):
- self.request.reset_mock()
- path = self.client.resource('backup') + suffix
- next_link = path + '?api-version=' + API_VERSION + '&page=2'
- items = [{'name': 'first', 'properties': {'unknownField': 1}},
- {'name': 'second', 'properties': {'unknownField': 2}}]
- self.request.side_effect = [
- response(payload={'value': items[:1], 'nextLink': next_link}),
- response(payload={'value': items[1:]})]
- self.assertEqual(getattr(commands, handler)(self.cmd, 'group', 'primary', 'backup', **kwargs), items)
- self.assertEqual(self.request.call_count, 2)
- self.assertEqual(self.request.call_args.args, ('GET', self.client.url(next_link)))
-
- def test_collection_pagination_failure_does_not_return_partial_success(self):
- self.request.side_effect = [
- response(payload={'value': [{'name': 'first'}], 'nextLink': self.client.resource('backup') + '?page=2'}),
- ResourceNotFoundError('Second page missing')]
- with self.assertRaises(ResourceNotFoundError):
- commands.list_silo_instances(self.cmd, 'group', 'primary', 'backup')
- self.assertEqual(self.request.call_count, 2)
-
- def test_collection_query_parameters_are_not_reapplied_to_next_link(self):
- next_link = self.client.resource('backup') + '/metrics?api-version=' + API_VERSION + '&page=2'
- self.request.side_effect = [
- response(payload={'value': [], 'nextLink': next_link}), response(payload={'value': []})]
- self.assertEqual(commands.list_silo_metrics(
- self.cmd, 'group', 'primary', 'backup', names=['Requests'], time_grain='PT1H',
- details=False, slot_view=True), [])
- first_query = parse_qs(urlsplit(self.request.call_args_list[0].args[1]).query)
- self.assertIn("name.value eq 'Requests'", first_query['$filter'][0])
- self.assertEqual(first_query['details'], ['false'])
- self.assertEqual(first_query['slotView'], ['true'])
- self.assertEqual(parse_qs(urlsplit(self.request.call_args.args[1]).query),
- {'api-version': [API_VERSION], 'page': ['2']})
-
- def test_property_bags_and_startup_log_wrappers_are_not_flattened(self):
- bag = {'properties': {'value': 'setting value', 'other': False}, 'unknownField': 'preserved'}
- startup = {'files': [{'name': 'startup.log'}], 'nextToken': 'preserved'}
- for handler, payload in (
- (commands.list_silo_appsettings, bag), (commands.list_silo_storage_accounts, bag),
- (commands.list_silo_connection_strings, bag), (commands.list_silo_metadata, bag),
- (commands.list_silo_startup_logs, startup)):
- with self.subTest(handler=handler.__name__):
- self.request.reset_mock()
- self.request.return_value = response(payload=payload)
- self.assertEqual(handler(self.cmd, 'group', 'primary', 'backup'), payload)
- self.request.assert_called_once()
-
- def test_extension_verbs_bodies_and_path_validation(self):
- for method in ('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'):
- commands.invoke_silo_extension(self.cmd, 'group', 'primary', 'backup', 'worker', 'api/settings',
- method=method, body='[1,2]')
- self.assertEqual(self.request.call_args.args[0], method)
- self.assertEqual(self.request.call_args.args[2], [1, 2])
- for path in ('../primary', '%2e%2e/primary', 'api/%252e%252e', '/api/x', 'api/x?y=z',
- 'api\\x', 'api/%2fescape', 'https://other.test/api'):
- with self.subTest(path=path), self.assertRaises(InvalidArgumentValueError):
- commands.invoke_silo_extension(self.cmd, 'group', 'primary', 'backup', 'worker', path)
- commands.invoke_silo_extension(self.cmd, 'group', 'primary', 'backup', 'worker', 'api/settings',
- uri_parameters=['item=one', 'item=two'])
- self.assertEqual(self.request.call_args.args[3], {'item': ['one', 'two']})
- with self.assertRaises(InvalidArgumentValueError):
- commands.invoke_silo_extension(self.cmd, 'group', 'primary', 'backup', 'worker', 'api/settings',
- uri_parameters=['api-version=old'])
- commands.invoke_silo_extension(self.cmd, 'group', 'primary', 'backup', 'worker', 'api/settings',
- method='POST', body='null')
- self.assertEqual(self.request.call_args.args[2], b'null')
-
- def test_normal_not_found_is_not_empty_success_or_primary_fallback(self):
- self.request.side_effect = ResourceNotFoundError('Silo not found')
- with self.assertRaises(ResourceNotFoundError):
- commands.show_silo_config(self.cmd, 'group', 'primary', 'backup')
- self.request.assert_called_once()
-
- def test_named_diagnostic_fields_build_the_service_envelopes(self):
- commands.check_silo_dns(self.cmd, 'group', 'primary', 'backup', dns_name='example.com')
- self.assertEqual(self.request.call_args.args[:3], (
- 'POST', self.client.resource('backup') + '/dnsCheck', {'properties': {'dnsName': 'example.com'}}))
- commands.check_silo_tcp(self.cmd, 'group', 'primary', 'backup', host='example.com', port=443)
- self.assertEqual(self.request.call_args.args[:3], (
- 'POST', self.client.resource('backup') + '/tcpPingCheck',
- {'properties': {'host': 'example.com', 'port': '443'}}))
- commands.update_silo_antivirus_config(
- self.cmd, 'group', 'primary', 'backup', delete_infected_files=False)
- self.assertEqual(self.request.call_args.args[:3], (
- 'PUT', self.client.resource('backup') + '/antivirus/config',
- {'properties': {'deleteInfectedFiles': False}}))
-
- def test_named_request_fields_reject_ambiguous_or_incomplete_input(self):
- cases = (
- (commands.check_silo_dns, {}, RequiredArgumentMissingError),
- (commands.check_silo_dns, {'dns_name': ''}, InvalidArgumentValueError),
- (commands.check_silo_dns, {'dns_name': 'example.com', 'body': '{}'}, MutuallyExclusiveArgumentError),
- (commands.check_silo_tcp, {'host': 'example.com'}, RequiredArgumentMissingError),
- (commands.check_silo_tcp, {'host': '', 'port': 443}, InvalidArgumentValueError),
- (commands.check_silo_tcp, {'host': 'example.com', 'port': 65536}, InvalidArgumentValueError),
- (commands.check_silo_tcp, {'host': 'example.com', 'port': True}, InvalidArgumentValueError),
- (commands.check_silo_tcp, {'host': 'example.com', 'body': '{}'}, MutuallyExclusiveArgumentError),
- (commands.update_silo_antivirus_config, {}, RequiredArgumentMissingError),
- (commands.update_silo_antivirus_config,
- {'delete_infected_files': False, 'body': '{}'}, MutuallyExclusiveArgumentError),
- )
- for handler, kwargs, error in cases:
- with self.subTest(handler=handler.__name__, kwargs=kwargs), self.assertRaises(error):
- handler(self.cmd, 'group', 'primary', 'backup', **kwargs)
- self.request.assert_not_called()
-
- def test_operation_protocol_selection_is_explicit(self):
- with patch.object(self.client, 'mutate') as mutate:
- commands.start_site_silo(self.cmd, 'group', 'primary', 'backup', no_wait=True, timeout=7)
- mutate.assert_called_once_with(
- 'POST', self.client.resource('backup') + '/start', no_wait=True, timeout=7, query=None)
- self.request.assert_not_called()
- mutate.reset_mock()
- commands.show_silo_config(self.cmd, 'group', 'primary', 'backup')
- mutate.assert_not_called()
- self.request.assert_called_once()
-
- def test_plan_creation_and_explicit_false(self):
- commands.create_plan_silo(self.cmd, 'group', 'primary', 'backup', 'westus2',
- primary_location='eastus', number_of_workers=2, zone_redundant='false')
- self.assertEqual(self.request.call_args.args[2], {
- 'location': 'westus2', 'properties': {
- 'name': 'backup', 'primaryLocation': 'eastus',
- 'instanceNumberOverride': 2, 'zoneRedundantOverride': False}})
- self.request.assert_called_once()
-
- def test_site_creation_does_not_create_plan_or_invent_overrides(self):
- commands.create_site_silo(self.cmd, 'group', 'primary', 'backup', 'westus2',
- primary_location='eastus', traffic_mode='Active')
- self.assertEqual(self.request.call_args.args[2], {
- 'location': 'westus2', 'properties': {
- 'name': 'backup', 'primaryLocation': 'eastus', 'trafficMode': 'Active'}})
- self.request.assert_called_once()
-
- def test_create_resolves_primary_location_only_when_omitted(self):
- with patch(MODULE + '._primary_location', return_value='eastus') as primary:
- commands.create_site_silo(self.cmd, 'group', 'primary', 'backup', 'westus2', traffic_mode='Active')
- primary.assert_called_once_with(self.cmd, 'group', 'primary', False)
- self.assertEqual(self.request.call_args.args[2]['properties']['primaryLocation'], 'eastus')
-
- def test_update_reads_silo_and_sends_only_writable_requested_fields(self):
- self.request.side_effect = [
- response(payload={'location': 'westus2', 'properties': {
- 'primaryLocation': 'eastus', 'revisionNumber': 8, 'provisioningState': 'Succeeded',
- 'followLastKnownGood': True}}),
- response(payload={'id': 'silo'})]
- commands.update_site_silo(self.cmd, 'group', 'primary', 'backup', endpoint_status='Disabled')
- self.assertEqual(self.request.call_args.args[2], {
- 'location': 'westus2', 'properties': {
- 'name': 'backup', 'primaryLocation': 'eastus', 'endpointStatus': 'Disabled'}})
- self.assertEqual(self.request.call_count, 2)
-
- def test_worker_override_boundaries(self):
- for value in (0, -1, 31):
- with self.subTest(value=value), self.assertRaises(InvalidArgumentValueError):
- commands.create_plan_silo(self.cmd, 'group', 'primary', 'backup', 'westus2',
- primary_location='eastus', number_of_workers=value)
- self.request.assert_not_called()
- for value in (1, 30):
- commands.create_plan_silo(self.cmd, 'group', 'primary', 'backup', 'westus2',
- primary_location='eastus', number_of_workers=value)
- self.assertEqual(self.request.call_args.args[2]['properties']['instanceNumberOverride'], value)
-
- def test_refresh_204_is_one_request_and_notice_without_revision_wait(self):
- self.request.return_value = response(204)
- with patch(MODULE + '.logger') as logger:
- self.assertIsNone(commands.refresh_site_silos(self.cmd, 'group', 'primary'))
- self.request.assert_called_once_with('POST', self.client.parent + '/refreshSilos')
- logger.warning.assert_called_once()
- self.assertIn('accepted', logger.warning.call_args.args[0])
- self.assertNotIn('revision X', logger.warning.call_args.args[0])
-
- def test_refresh_conflict_does_not_report_acceptance(self):
- for status in (400, 409):
- error = AzureResponseError('Refresh rejected')
- error.response = response(status)
- self.request.side_effect = error
- with self.subTest(status=status), patch(MODULE + '.logger') as logger, \
- self.assertRaises(AzureResponseError):
- commands.refresh_site_silos(self.cmd, 'group', 'primary')
- logger.warning.assert_not_called()
-
- def test_managed_traffic_mode_only_and_partial_endpoint_overlay(self):
- commands.update_managed_traffic(self.cmd, 'group', 'primary', traffic_mode='Passive')
- self.assertEqual(self.request.call_args.args[2], {'properties': {'trafficMode': 'Passive'}})
- commands.update_managed_traffic(self.cmd, 'group', 'primary',
- endpoints='[{"region":"westus2","weight":3}]')
- self.assertEqual(self.request.call_args.args[2], {
- 'properties': {'endpoints': [{'region': 'westus2', 'weight': 3}]}})
- for endpoints in ('[{"weight":2}]', '[{"region":"x","weight":0}]',
- '[{"region":"x","priority":true}]', '[{"region":"x","isWeightAutoComputed":true}]'):
- with self.subTest(endpoints=endpoints), self.assertRaises(InvalidArgumentValueError):
- commands.update_managed_traffic(self.cmd, 'group', 'primary', endpoints=endpoints)
-
- def test_traffic_expansion_only_on_collection(self):
- commands.list_managed_traffic(self.cmd, 'group', 'primary', expand_endpoints=True)
- self.assertIn('%24expand=endpoints', self.request.call_args.args[1])
- commands.show_managed_traffic(self.cmd, 'group', 'primary')
- self.assertEqual(self.request.call_args.args, ('GET', self.client.parent + '/managedTrafficConfig/default'))
-
- def test_last_known_good_mark_does_not_refresh(self):
- commands.mark_last_known_good(self.cmd, 'group', 'primary')
- self.request.assert_called_once_with('POST', self.client.parent + '/lastKnownGood/default/mark')
-
- def test_networking_exact_routes_verbs_and_body(self):
- for handler, method in ((commands.add_silo_vnet_integration, 'PUT'),
- (commands.update_silo_vnet_integration, 'PATCH')):
- with self.subTest(method=method):
- handler(self.cmd, 'group', 'primary', 'backup', subnet='/subscriptions/sub/subnets/net')
- self.assertEqual(self.request.call_args.args[:3], (
- method, self.client.resource('backup') + '/virtualNetworkConnections/virtualNetwork',
- {'properties': {'subnetResourceId': '/subscriptions/sub/subnets/net'}}))
- self.request.return_value = response(204)
- self.assertIsNone(commands.remove_silo_vnet_integration(self.cmd, 'group', 'primary', 'backup'))
-
- def test_startup_log_queries_and_explicit_false_flags(self):
- commands.list_silo_startup_logs(self.cmd, 'group', 'primary', 'backup',
- log_type='All', instance='worker', latest=False)
- self.assertEqual(self.request.call_args.args[3], {
- 'type': 'All', 'targetSiteInstance': 'worker', 'latest': False})
- commands.restart_site_silo(self.cmd, 'group', 'primary', 'backup',
- soft_restart=False, synchronous=False)
- self.assertEqual(self.request.call_args.args[3], {'softRestart': False, 'synchronous': False})
-
- def test_metric_filters_and_service_validation(self):
- query = commands._metric_query('2026-10-18T00:00:00Z', None, 'PT1H', ['Requests'])
- self.assertIn("timeGrain eq duration'PT1H'", query['$filter'])
- self.assertIn("name.value eq 'Requests'", query['$filter'])
- with self.assertRaises(InvalidArgumentValueError):
- commands._metric_query(start_time='x', filter_string='x')
-
- def test_table_preserves_degraded_and_no_wait_receipt(self):
- row = commands.silo_table({'name': 'backup', 'properties': {
- 'provisioningState': 'Degraded', 'revisionNumber': 3, 'errorCode': 'SyncFailed'}})[0]
- self.assertEqual(row['State'], 'Degraded')
- self.assertEqual(row['Error'], 'SyncFailed')
- receipt = commands.silo_table({'status': 'Accepted', 'operationId': 'op', 'resourceId': 'id'})[0]
- self.assertEqual(receipt['Status'], 'Accepted')
-
- def test_all_registered_handlers_and_help_exist(self):
- from knack.help_files import helps
- seen = set()
- for group, entries in COMMAND_GROUPS.items():
- for verb, handler in entries:
- command = group + ' ' + verb
- with self.subTest(command=command):
- self.assertNotIn(command, seen)
- seen.add(command)
- self.assertTrue(callable(getattr(commands, handler)))
- self.assertIn(command, helps)
- self.assertIn('examples:', helps[command])
- self.assertIn('cmd', inspect.signature(getattr(commands, handler)).parameters)
- self.assertEqual(len(seen), 104)
- self.assertEqual({handler for entries in COMMAND_GROUPS.values() for _, handler in entries},
- set(COMMAND_SUMMARIES))
- self.assertEqual(set(COMMAND_SUMMARIES), set(ADDITIONAL_EXAMPLES))
- arguments = {
- argument for entries in COMMAND_GROUPS.values() for _, handler in entries
- for argument in inspect.signature(getattr(commands, handler)).parameters
- if argument not in ('cmd', 'name', 'resource_group_name', 'silo', 'no_wait')
- }
- self.assertFalse(arguments.difference(ARGUMENT_HELP))
-
- def test_authored_examples_cover_every_handler_parameter(self):
- import yaml
- from knack.help_files import helps
- aliases = {
- 'resource_group_name': {'-g', '--resource-group'}, 'name': {'-n', '--name'},
- 'filter_string': {'--filter'},
- }
- for group, entries in COMMAND_GROUPS.items():
- for verb, handler in entries:
- command = group + ' ' + verb
- with self.subTest(command=command):
- examples = yaml.safe_load(helps[command])['examples']
- self.assertGreaterEqual(len(examples), 2)
- flags = {token for example in examples for token in shlex.split(example['text'])}
- for parameter in inspect.signature(getattr(commands, handler)).parameters:
- if parameter == 'cmd':
- continue
- options = aliases.get(parameter, {'--' + parameter.replace('_', '-')})
- self.assertTrue(options.intersection(flags), '{} lacks an example.'.format(parameter))
-
- def test_no_wait_restart_examples_do_not_request_a_synchronous_service_restart(self):
- import yaml
- from knack.help_files import helps
- examples = yaml.safe_load(helps['webapp silo restart'])['examples']
- no_wait = [shlex.split(example['text']) for example in examples if '--no-wait' in example['text']]
- self.assertTrue(no_wait)
- for arguments in no_wait:
- self.assertIn('--synchronous', arguments)
- self.assertEqual(arguments[arguments.index('--synchronous') + 1], 'false')
-
-
-class ActiveBackupHelpTests(unittest.TestCase):
-
- @staticmethod
- def invoke_json(arguments):
- return ActiveBackupHelpTests.invoke_output(arguments)
-
- @staticmethod
- def invoke_output(arguments, output_format='json'):
- from azure.cli.core import get_default_cli
- stdout = io.StringIO()
- stderr = io.StringIO()
- with redirect_stdout(stdout), redirect_stderr(stderr):
- try:
- exit_code = get_default_cli().invoke(arguments + ['--output', output_format], out_file=stdout)
- except SystemExit as ex:
- exit_code = ex.code
- if exit_code:
- raise AssertionError(stderr.getvalue())
- return exit_code, stdout.getvalue()
-
- @staticmethod
- def invoke(arguments):
- from azure.cli.core import get_default_cli
- if '--help' not in arguments:
- arguments = arguments + ['--output', 'none']
- with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
- try:
- return get_default_cli().invoke(arguments)
- except SystemExit as ex:
- return ex.code
-
- def test_every_command_loads_help(self):
- for group, entries in COMMAND_GROUPS.items():
- for verb, _ in entries:
- command = group + ' ' + verb
- with self.subTest(command=command):
- self.assertEqual(self.invoke(command.split() + ['--help']), 0)
-
- def test_named_command_executes_exact_silo_request(self):
- client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
- with patch(MODULE + '._client', return_value=client), \
- patch.object(client, 'request', return_value=response(payload={'id': 'silo'})) as request:
- self.assertEqual(self.invoke(
- 'webapp silo config show -g group -n primary --silo backup'.split()), 0)
- self.assertEqual(request.call_args.args[:2], ('GET', client.resource('backup') + '/config/web'))
-
- def test_cli_named_request_options_preserve_false_and_port_types(self):
- cases = (
- ('webapp silo network-check dns --dns-name example.com', {'dnsName': 'example.com'}),
- ('webapp silo network-check tcp-ping --host example.com --port 443',
- {'host': 'example.com', 'port': '443'}),
- ('webapp silo antivirus config update --delete-infected-files false', {'deleteInfectedFiles': False}),
- )
- for command, properties in cases:
- with self.subTest(command=command):
- client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
- with patch(MODULE + '._client', return_value=client), \
- patch.object(client, 'request', return_value=response(payload={})) as request:
- self.assertEqual(self.invoke(
- command.split() + ['-g', 'group', '-n', 'primary', '--silo', 'backup']), 0)
- self.assertEqual(request.call_args.args[2], {'properties': properties})
-
- def test_cli_help_explains_units_and_endpoint_variants(self):
- for command, terms in (
- ('webapp silo log startup show-summary', ('hours', '24')),
- ('webapp silo network-trace start', ('seconds', 'bytes', 'legacy', 'asynchronous')),
- ('webapp silo auth v2 show', ('legacy', '/list')),
- ('webapp silo network-check dns', ('--dns-name', 'example.com')),
- ('webapp silo network-check tcp-ping', ('--host', '--port', '65535')),
- ('webapp silo antivirus config update', ('--delete-infected-files', 'false'))):
- with self.subTest(command=command):
- _, output = self.invoke_output(command.split() + ['--help'])
- for term in terms:
- self.assertIn(term, output)
-
- def test_cli_updates_ignore_configured_location_defaults(self):
- for group, resource_type in (('appservice plan silo', 'serverfarms'), ('webapp silo', 'sites')):
- for explicit_location in (False, True):
- with self.subTest(group=group, explicit_location=explicit_location):
- client = ActiveBackupClient(
- cli_context(), 'group', 'primary', resource_type, subscription='sub')
- resource = {'location': 'westus2', 'properties': {'primaryLocation': 'eastus'}}
- arguments = (group + ' update -g group -n primary --silo backup').split()
- arguments += (['--number-of-workers', '2'] if resource_type == 'serverfarms'
- else ['--endpoint-status', 'Disabled'])
- if explicit_location:
- arguments += ['--location', 'westus2']
- with patch.dict('os.environ', {'AZURE_DEFAULTS_LOCATION': 'eastus'}), \
- patch(MODULE + '._client', return_value=client), \
- patch.object(client, 'request', return_value=response(payload=resource)) as request:
- self.assertEqual(self.invoke(arguments), 0)
- self.assertEqual(request.call_count, 2)
- self.assertEqual(request.call_args.args[0], 'PUT')
- self.assertEqual(request.call_args.args[2]['location'], 'westus2')
-
- def test_cli_creates_require_an_explicit_silo_location_despite_defaults(self):
- for group in ('appservice plan silo', 'webapp silo'):
- with self.subTest(group=group), \
- patch.dict('os.environ', {'AZURE_DEFAULTS_LOCATION': 'eastus'}), \
- patch(MODULE + '._client') as factory:
- self.assertNotEqual(self.invoke(
- (group + ' create -g group -n primary --silo backup --primary-location eastus').split()), 0)
- factory.assert_not_called()
-
- def test_cli_collection_commands_return_arrays_from_all_pages(self):
- registered = {handler: group + ' ' + verb
- for group, entries in COMMAND_GROUPS.items() for verb, handler in entries}
- for key, handler, _, suffix, kwargs in PASS_THROUGH_CASES:
- if key not in PAGED_COLLECTION_APIS:
- continue
- with self.subTest(api=key):
- client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
- arguments = registered[handler].split() + ['-g', 'group', '-n', 'primary', '--silo', 'backup']
- for key_name, value in kwargs.items():
- arguments += ['--' + key_name.replace('_', '-'), value]
- with patch(MODULE + '._client', return_value=client), \
- patch.object(client, 'request', side_effect=[
- response(payload={'value': [{'name': 'first'}],
- 'nextLink': client.resource('backup') + suffix + '?page=2'}),
- response(payload={'value': [{'name': 'second'}]})]) as request:
- _, output = self.invoke_json(arguments)
- self.assertEqual(json.loads(output), [{'name': 'first'}, {'name': 'second'}])
- self.assertEqual(request.call_count, 2)
-
- def test_cli_collection_table_and_list_queries_include_all_pages(self):
- for output_format, query in (('table', None), ('json', '[].name')):
- with self.subTest(output_format=output_format, query=query):
- client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
- arguments = 'webapp silo instance list -g group -n primary --silo backup'.split()
- if query:
- arguments += ['--query', query]
- with patch(MODULE + '._client', return_value=client), \
- patch.object(client, 'request', side_effect=[
- response(payload={'value': [{'name': 'first'}],
- 'nextLink': client.resource('backup') + '/instances?page=2'}),
- response(payload={'value': [{'name': 'second'}]})]) as request:
- _, output = self.invoke_output(arguments, output_format)
- self.assertEqual(request.call_count, 2)
- if query:
- self.assertEqual(json.loads(output), ['first', 'second'])
- else:
- self.assertIn('first', output)
- self.assertIn('second', output)
-
- def test_nested_ids_are_typed_and_do_not_silently_override_names(self):
- resource_id = (
- '/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/group/'
- 'providers/Microsoft.Web/sites/primary/silos/backup')
- client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
- with patch(MODULE + '._client', return_value=client) as factory, \
- patch.object(client, 'request', return_value=response(payload={'id': 'silo'})) as request:
- self.assertEqual(self.invoke(['webapp', 'silo', 'show', '--ids', resource_id]), 0)
- self.assertEqual(factory.call_args.args[1:3], ('group', 'primary'))
- self.assertEqual(request.call_args.args[:2], ('GET', client.resource('backup')))
- for arguments in (
- ['webapp', 'silo', 'show', '--ids', resource_id, '-n', 'another'],
- ['webapp', 'silo', 'show', '--ids', resource_id.replace('/sites/', '/serverfarms/')]):
- request.reset_mock()
- self.assertNotEqual(self.invoke(arguments), 0)
- request.assert_not_called()
-
- def test_cli_ids_reject_abbreviated_explicit_identity_options(self):
- subscription = '00000000-0000-0000-0000-000000000001'
- for group, resource_type in (('webapp silo', 'sites'), ('appservice plan silo', 'serverfarms')):
- resource_id = '/subscriptions/{}/resourceGroups/group/providers/Microsoft.Web/{}/primary/silos/backup'.format(
- subscription, resource_type)
- for verb in ('show', 'delete'):
- for option, value in (
- ('--na', 'other'), ('--resource-g', 'other'), ('--sil', 'other'),
- ('--sub', subscription), ('--subscription', subscription)):
- with self.subTest(group=group, verb=verb, option=option), \
- patch(MODULE + '._client') as factory, \
- patch('azure.cli.core._profile.Profile.load_cached_subscriptions',
- return_value=[{'id': subscription, 'name': 'test'}]):
- arguments = (group + ' ' + verb).split() + ['--ids', resource_id, option, value]
- if verb == 'delete':
- arguments += ['--yes']
- self.assertNotEqual(self.invoke(arguments), 0)
- factory.assert_not_called()
-
- def test_cli_id_only_show_and_delete_remain_valid_for_both_resource_families(self):
- subscription = '00000000-0000-0000-0000-000000000001'
- for group, resource_type in (('webapp silo', 'sites'), ('appservice plan silo', 'serverfarms')):
- client = ActiveBackupClient(cli_context(), 'group', 'primary', resource_type, subscription=subscription)
- resource_id = client.resource('backup')
- for verb in ('show', 'delete'):
- with self.subTest(group=group, verb=verb), \
- patch(MODULE + '._client', return_value=client), \
- patch.object(client, 'request', return_value=response(payload={'id': resource_id})) as request:
- arguments = (group + ' ' + verb).split() + ['--ids', resource_id]
- if verb == 'delete':
- arguments += ['--yes']
- self.assertEqual(self.invoke(arguments), 0)
- request.assert_called_once()
- self.assertEqual(request.call_args.args[:2],
- ('DELETE' if verb == 'delete' else 'GET', resource_id))
-
- def test_cli_explicit_false_and_no_wait(self):
- client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
- initial = response(202, headers={'Location': client.resource('backup') + '/operationresults/op'})
- with patch(MODULE + '._client', return_value=client), \
- patch.object(client, 'request', return_value=initial) as request:
- self.assertEqual(self.invoke(
- 'appservice plan silo create -g group -n primary --silo backup --location westus2 '
- '--primary-location eastus --zone-redundant false --no-wait'.split()), 0)
- self.assertIs(request.call_args.args[2]['properties']['zoneRedundantOverride'], False)
- request.assert_called_once()
-
- def test_cli_no_wait_receipts_are_not_discarded(self):
- for group, entries in COMMAND_GROUPS.items():
- for verb, handler_name in entries:
- parameters = inspect.signature(getattr(commands, handler_name)).parameters
- if 'no_wait' not in parameters:
- continue
- with self.subTest(command=group + ' ' + verb):
- client = ActiveBackupClient(
- cli_context(), 'group', 'primary',
- 'serverfarms' if group.startswith('appservice plan') else 'sites',
- subscription='sub')
- resource_id = client.resource('backup')
- poll = resource_id + '/operationresults/op'
- initial = response(202, headers={'Location': poll})
- existing = response(payload={
- 'id': resource_id, 'location': 'westus2',
- 'properties': {'primaryLocation': 'eastus'}})
- arguments = (group + ' ' + verb).split() + [
- '-g', 'group', '-n', 'primary', '--silo', 'backup', '--no-wait']
- if 'location' in parameters:
- arguments += ['--location', 'westus2', '--primary-location', 'eastus']
- if verb == 'delete':
- arguments += ['--yes']
- with patch(MODULE + '._client', return_value=client), \
- patch.object(client, 'request', side_effect=lambda method, *args, **kwargs:
- existing if method == 'GET' else initial) as request:
- exit_code, output = self.invoke_json(arguments)
- self.assertEqual(exit_code, 0)
- self.assertTrue(output.strip(), 'The CLI discarded the no-wait operation receipt.')
- receipt = json.loads(output)
- self.assertEqual(receipt['status'], 'Accepted')
- self.assertEqual(receipt['operationId'], 'op')
- self.assertEqual(receipt['resourceId'], request.call_args.args[1])
- self.assertEqual(receipt['operationUrl'], client.url(poll))
- self.assertEqual(request.call_count, 2 if verb == 'update' else 1)
-
- def test_cli_no_wait_preserves_synchronous_resource_results(self):
- for group in ('appservice plan silo', 'webapp silo'):
- with self.subTest(command=group + ' create'):
- client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
- resource = {'id': client.resource('backup'), 'properties': {'provisioningState': 'Succeeded'}}
- with patch(MODULE + '._client', return_value=client), \
- patch.object(client, 'request', return_value=response(payload=resource)) as request:
- exit_code, output = self.invoke_json(
- (group + ' create -g group -n primary --silo backup --location westus2 '
- '--primary-location eastus --no-wait').split())
- self.assertEqual(exit_code, 0)
- self.assertTrue(output.strip(), 'The CLI discarded the synchronous resource result.')
- self.assertEqual(json.loads(output), dict(resource, resourceGroup='group'))
- request.assert_called_once()
-
- def test_cli_waited_writes_return_resource_json_not_operation_status(self):
- for group in ('appservice plan silo', 'webapp silo'):
- for verb in ('create', 'update'):
- with self.subTest(command=group + ' ' + verb):
- client = ActiveBackupClient(
- cli_context(), 'group', 'primary',
- 'serverfarms' if group.startswith('appservice plan') else 'sites',
- subscription='sub')
- resource_id = client.resource('backup')
- existing = {'id': resource_id, 'location': 'westus2',
- 'properties': {'primaryLocation': 'eastus'}}
- resource = dict(existing, properties={
- 'primaryLocation': 'eastus', 'provisioningState': 'Succeeded',
- 'instanceNumberOverride': 2, 'zoneRedundantOverride': False})
- poll = resource_id + '/operationresults/op?azureAsyncOperation=1'
- replies = [response(payload=existing)] if verb == 'update' else []
- replies += [
- response(202, headers={'Azure-AsyncOperation': poll, 'Retry-After': '0'}),
- response(payload={'id': None, 'status': 'Succeeded', 'properties': None}),
- response(payload=resource)]
- with patch(MODULE + '._client', return_value=client), \
- patch.object(client, 'request', side_effect=replies) as request, \
- patch('azure.cli.command_modules.appservice._active_backup_client.time.sleep'):
- exit_code, output = self.invoke_json(
- (group + ' ' + verb + ' -g group -n primary --silo backup '
- '--location westus2 --primary-location eastus').split())
- self.assertEqual(exit_code, 0)
- self.assertEqual(json.loads(output), dict(resource, resourceGroup='group'))
- self.assertEqual(request.call_args.args, ('GET', resource_id))
- self.assertEqual(request.call_count, 4 if verb == 'update' else 3)
-
- def test_cli_operation_show_uses_async_status_mode(self):
- for group, resource_type in (('appservice plan silo', 'serverfarms'), ('webapp silo', 'sites')):
- for state in ('InProgress', 'Succeeded'):
- with self.subTest(group=group, state=state):
- client = ActiveBackupClient(
- cli_context(), 'group', 'primary', resource_type, subscription='sub')
- with patch(MODULE + '._client', return_value=client), \
- patch.object(client, 'request', return_value=response(
- payload={'id': None, 'status': state, 'properties': None})) as request:
- exit_code, output = self.invoke_json(
- (group + ' operation show -g group -n primary --silo backup --operation-id op').split())
- self.assertEqual(exit_code, 0)
- request.assert_called_once()
- method, address = request.call_args.args
- self.assertEqual(method, 'GET')
- self.assertEqual(urlsplit(address).path, client.resource('backup') + '/operationresults/op')
- self.assertEqual(parse_qs(urlsplit(address).query),
- {'api-version': [API_VERSION], 'azureAsyncOperation': ['1']})
- self.assertEqual(json.loads(output),
- {'operationId': 'op', 'operationUrl': address, 'status': state})
-
- def test_cli_wait_uses_async_status_mode_without_resource_readback(self):
- for group, resource_type in (('appservice plan silo', 'serverfarms'), ('webapp silo', 'sites')):
- with self.subTest(group=group):
- client = ActiveBackupClient(cli_context(), 'group', 'primary', resource_type, subscription='sub')
- with patch(MODULE + '._client', return_value=client), \
- patch.object(client, 'request', side_effect=[
- response(202, headers={'Retry-After': '0'}),
- response(payload={'id': None, 'status': 'Succeeded', 'properties': None})]) as request, \
- patch('azure.cli.command_modules.appservice._active_backup_client.time.sleep'):
- exit_code, output = self.invoke_json(
- (group + ' wait -g group -n primary --silo backup --operation-id op').split())
- self.assertEqual(exit_code, 0)
- self.assertEqual(request.call_count, 2)
- for call in request.call_args_list:
- self.assertEqual(call.args[0], 'GET')
- self.assertEqual(urlsplit(call.args[1]).path, client.resource('backup') + '/operationresults/op')
- self.assertEqual(parse_qs(urlsplit(call.args[1]).query),
- {'api-version': [API_VERSION], 'azureAsyncOperation': ['1']})
- self.assertEqual(json.loads(output),
- {'operationId': 'op', 'operationUrl': request.call_args.args[1], 'status': 'Succeeded'})
-
- def test_cli_operation_failures_are_not_masked(self):
- forbidden = AzureResponseError('Operation access denied.')
- forbidden.response = response(403)
- failures = [ResourceNotFoundError('Operation missing.'), forbidden] + [
- response(payload={'status': status, 'error': {'code': 'SiloFailed', 'message': 'Operation failed.'}})
- for status in ('Failed', 'Cancelled', 'TimedOut')]
- for group, resource_type in (('appservice plan silo', 'serverfarms'), ('webapp silo', 'sites')):
- for action in ('operation show', 'wait'):
- for failure in failures:
- with self.subTest(group=group, action=action, failure=failure):
- client = ActiveBackupClient(
- cli_context(), 'group', 'primary', resource_type, subscription='sub')
- with patch(MODULE + '._client', return_value=client), \
- patch.object(client, 'request', side_effect=[failure]) as request:
- exit_code = self.invoke(
- (group + ' ' + action + ' -g group -n primary --silo origin --operation-id op').split())
- self.assertNotEqual(exit_code, 0)
- request.assert_called_once()
- self.assertEqual(request.call_args.args[0], 'GET')
- self.assertEqual(urlsplit(request.call_args.args[1]).path,
- client.resource('origin') + '/operationresults/op')
- self.assertEqual(parse_qs(urlsplit(request.call_args.args[1]).query),
- {'api-version': [API_VERSION], 'azureAsyncOperation': ['1']})
-
- def test_cli_origin_show_reads_the_collection_after_point_404(self):
- client = ActiveBackupClient(cli_context(), 'group', 'primary',
- subscription='00000000-0000-0000-0000-000000000001')
- origin = client.resource('origin')
- resource = {'id': origin, 'name': 'origin', 'type': 'Microsoft.Web/sites/silos',
- 'location': 'eastus', 'properties': {'primaryLocation': 'eastus', 'endpointStatus': 'Enabled'}}
- with patch(MODULE + '._client', return_value=client), \
- patch.object(client, 'request', side_effect=[
- ResourceNotFoundError('Origin point missing'), response(payload={'value': [resource]})]) as request, \
- patch('azure.cli.command_modules.appservice._active_backup_client.logger.warning') as warning:
- exit_code, output = self.invoke_json(['webapp', 'silo', 'show', '--ids', origin])
- self.assertEqual(exit_code, 0)
- self.assertEqual(json.loads(output), dict(resource, resourceGroup='group'))
- self.assertEqual([call.args[:2] for call in request.call_args_list],
- [('GET', origin), ('GET', client.url(client.resource()))])
- warning.assert_called_once()
-
- def test_cli_origin_update_uses_the_collection_before_and_after_async_write(self):
- client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
- origin = client.resource('origin')
- before = {'id': origin, 'name': 'origin', 'type': 'Microsoft.Web/sites/silos', 'location': 'eastus',
- 'properties': {'primaryLocation': 'eastus', 'endpointStatus': 'Enabled'}}
- after = dict(before, properties={'primaryLocation': 'eastus', 'endpointStatus': 'Disabled'})
- poll = origin + '/operationresults/op'
- with patch(MODULE + '._client', return_value=client), \
- patch.object(client, 'request', side_effect=[
- ResourceNotFoundError('Point missing'), response(payload={'value': [before]}),
- response(202, headers={'Azure-AsyncOperation': poll, 'Retry-After': '0'}),
- response(payload={'id': None, 'status': 'Succeeded'}),
- ResourceNotFoundError('Point still missing'), response(payload={'value': [after]})]) as request, \
- patch('azure.cli.command_modules.appservice._active_backup_client.time.sleep'), \
- patch('azure.cli.command_modules.appservice._active_backup_client.logger.warning') as warning:
- exit_code, output = self.invoke_json(
- 'webapp silo update -g group -n primary --silo origin --endpoint-status Disabled'.split())
- self.assertEqual(exit_code, 0)
- self.assertEqual(json.loads(output), dict(after, resourceGroup='group'))
- self.assertEqual(request.call_count, 6)
- self.assertEqual(request.call_args_list[2].args[:2], ('PUT', origin))
- expected_body = {
- 'location': 'eastus',
- 'properties': {'name': 'origin', 'primaryLocation': 'eastus', 'endpointStatus': 'Disabled'}}
- self.assertEqual(request.call_args_list[2].args[2], expected_body)
- warning.assert_called_once()
-
- def test_cli_origin_pass_through_does_not_use_collection_fallback(self):
- client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
- with patch(MODULE + '._client', return_value=client), \
- patch.object(client, 'request', side_effect=ResourceNotFoundError('Config missing')) as request:
- self.assertEqual(self.invoke(
- 'webapp silo config show -g group -n primary --silo origin'.split()), 3)
- request.assert_called_once()
- self.assertEqual(request.call_args.args[:2], ('GET', client.resource('origin') + '/config/web'))
-
- def test_cli_not_found_has_show_exit_code(self):
- client = ActiveBackupClient(cli_context(), 'group', 'primary', subscription='sub')
- with patch(MODULE + '._client', return_value=client), \
- patch.object(client, 'request', side_effect=ResourceNotFoundError('Not found')) as request:
- self.assertEqual(self.invoke('webapp silo show -g group -n primary --silo backup'.split()), 3)
- request.assert_called_once()
-
-
-if __name__ == '__main__':
- unittest.main()