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/doc/active_backup_preview.md b/doc/active_backup_preview.md new file mode 100644 index 00000000000..018548014a7 --- /dev/null +++ b/doc/active_backup_preview.md @@ -0,0 +1,257 @@ +# 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 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. + +```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. + +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 +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. + +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 +``` diff --git a/scripts/appservice/ActiveBackupTestHarness.psm1 b/scripts/appservice/ActiveBackupTestHarness.psm1 new file mode 100644 index 00000000000..d76efa9da36 --- /dev/null +++ b/scripts/appservice/ActiveBackupTestHarness.psm1 @@ -0,0 +1,1782 @@ +# 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 new file mode 100644 index 00000000000..1512ae1090e --- /dev/null +++ b/scripts/appservice/README.md @@ -0,0 +1,326 @@ +# 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 new file mode 100644 index 00000000000..bbeb51e7664 --- /dev/null +++ b/scripts/appservice/test_active_backup.ps1 @@ -0,0 +1,152 @@ +# 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 new file mode 100644 index 00000000000..6f8c59c4dd0 --- /dev/null +++ b/scripts/appservice/tests/Test-ActiveBackupHarness.ps1 @@ -0,0 +1,1129 @@ +# 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 new file mode 100644 index 00000000000..10abeb30899 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_client.py @@ -0,0 +1,375 @@ +# -------------------------------------------------------------------------------------------- +# 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 new file mode 100644 index 00000000000..d2b78e53758 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/appservice/_active_backup_commands.py @@ -0,0 +1,861 @@ +# -------------------------------------------------------------------------------------------- +# 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 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..747041a6e8d --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/appservice/active_backup.py @@ -0,0 +1,718 @@ +# -------------------------------------------------------------------------------------------- +# 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 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..d443648cb3b --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_client.py @@ -0,0 +1,497 @@ +# -------------------------------------------------------------------------------------------- +# 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 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..21a324ae729 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_active_backup_commands_thru_mock.py @@ -0,0 +1,939 @@ +# -------------------------------------------------------------------------------------------- +# 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()