From cfa816d6be98ecacb957be09db21e5168e6f1c76 Mon Sep 17 00:00:00 2001 From: "Cristian Velez (from Dev Box)" Date: Wed, 3 Jun 2026 20:15:25 -0700 Subject: [PATCH 01/13] [Chaos] Initial codegen seed: spec @ f228b86c, package-2026-05-01-preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initial codegen validation run — verify skill mechanics end-to-end against the latest Microsoft.Chaos spec; no upstream PR will be opened from this run. Generated via chaos-automation-codegen skill family (run-id 20260604-020139-f228b86). Spec pin: Azure/azure-rest-api-specs @ f228b86c API version: 2026-05-01-preview Swagger tag: package-2026-05-01-preview Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/chaos/HISTORY.rst | 8 + src/chaos/README.md | 91 ++ src/chaos/azext_chaos/__init__.py | 40 + src/chaos/azext_chaos/_help.py | 606 ++++++++ src/chaos/azext_chaos/_params.py | 207 +++ src/chaos/azext_chaos/_table_format.py | 191 +++ src/chaos/azext_chaos/_validators.py | 61 + src/chaos/azext_chaos/aaz/__init__.py | 6 + src/chaos/azext_chaos/aaz/latest/__init__.py | 10 + .../aaz/latest/chaos/__cmd_group.py | 24 + .../azext_chaos/aaz/latest/chaos/__init__.py | 11 + .../chaos/discovered_resource/__cmd_group.py | 24 + .../chaos/discovered_resource/__init__.py | 13 + .../latest/chaos/discovered_resource/_list.py | 239 +++ .../latest/chaos/discovered_resource/_show.py | 241 +++ .../aaz/latest/chaos/scenario/__cmd_group.py | 24 + .../aaz/latest/chaos/scenario/__init__.py | 16 + .../aaz/latest/chaos/scenario/_create.py | 565 +++++++ .../aaz/latest/chaos/scenario/_delete.py | 158 ++ .../aaz/latest/chaos/scenario/_list.py | 325 ++++ .../aaz/latest/chaos/scenario/_show.py | 327 ++++ .../aaz/latest/chaos/scenario/_update.py | 721 +++++++++ .../chaos/scenario/config/__cmd_group.py | 24 + .../latest/chaos/scenario/config/__init__.py | 20 + .../latest/chaos/scenario/config/_create.py | 493 +++++++ .../latest/chaos/scenario/config/_delete.py | 196 +++ .../latest/chaos/scenario/config/_execute.py | 171 +++ .../chaos/scenario/config/_fix_permissions.py | 200 +++ .../aaz/latest/chaos/scenario/config/_list.py | 298 ++++ .../aaz/latest/chaos/scenario/config/_show.py | 301 ++++ .../latest/chaos/scenario/config/_update.py | 664 +++++++++ .../latest/chaos/scenario/config/_validate.py | 171 +++ .../aaz/latest/chaos/scenario/config/_wait.py | 296 ++++ .../latest/chaos/scenario/run/__cmd_group.py | 24 + .../aaz/latest/chaos/scenario/run/__init__.py | 14 + .../aaz/latest/chaos/scenario/run/_cancel.py | 170 +++ .../aaz/latest/chaos/scenario/run/_list.py | 460 ++++++ .../aaz/latest/chaos/scenario/run/_show.py | 511 +++++++ .../aaz/latest/chaos/workspace/__cmd_group.py | 24 + .../aaz/latest/chaos/workspace/__init__.py | 18 + .../aaz/latest/chaos/workspace/_create.py | 369 +++++ .../aaz/latest/chaos/workspace/_delete.py | 168 +++ .../aaz/latest/chaos/workspace/_list.py | 454 ++++++ .../workspace/_refresh_recommendation.py | 143 ++ .../aaz/latest/chaos/workspace/_show.py | 260 ++++ .../aaz/latest/chaos/workspace/_update.py | 482 ++++++ .../aaz/latest/chaos/workspace/_wait.py | 255 ++++ .../chaos/workspace/identity/__cmd_group.py | 24 + .../chaos/workspace/identity/__init__.py | 15 + .../chaos/workspace/identity/_assign.py | 461 ++++++ .../chaos/workspace/identity/_remove.py | 461 ++++++ .../latest/chaos/workspace/identity/_show.py | 298 ++++ .../latest/chaos/workspace/identity/_wait.py | 282 ++++ src/chaos/azext_chaos/azext_metadata.json | 4 + src/chaos/azext_chaos/commands.py | 135 ++ src/chaos/azext_chaos/custom.py | 648 ++++++++ src/chaos/azext_chaos/custom_wait.py | 213 +++ src/chaos/azext_chaos/tests/__init__.py | 4 + src/chaos/azext_chaos/tests/latest/README.md | 52 + .../azext_chaos/tests/latest/__init__.py | 4 + .../tests/latest/test_aaz_pristine.py | 50 + .../latest/test_chaos_discovered_resource.py | 63 + .../tests/latest/test_chaos_scenario.py | 80 + .../latest/test_chaos_scenario_config.py | 130 ++ .../tests/latest/test_chaos_scenario_run.py | 337 +++++ .../tests/latest/test_chaos_workspace.py | 116 ++ .../tests/latest/test_command_registration.py | 251 ++++ .../tests/latest/test_custom_commands.py | 1311 +++++++++++++++++ .../tests/latest/test_table_format.py | 556 +++++++ .../tests/latest/test_validators.py | 125 ++ src/chaos/setup.cfg | 2 + src/chaos/setup.py | 47 + 72 files changed, 15763 insertions(+) create mode 100644 src/chaos/HISTORY.rst create mode 100644 src/chaos/README.md create mode 100644 src/chaos/azext_chaos/__init__.py create mode 100644 src/chaos/azext_chaos/_help.py create mode 100644 src/chaos/azext_chaos/_params.py create mode 100644 src/chaos/azext_chaos/_table_format.py create mode 100644 src/chaos/azext_chaos/_validators.py create mode 100644 src/chaos/azext_chaos/aaz/__init__.py create mode 100644 src/chaos/azext_chaos/aaz/latest/__init__.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/__cmd_group.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/__init__.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/__cmd_group.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/__init__.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/_list.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/_show.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/__cmd_group.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/__init__.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/_create.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/_delete.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/_list.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/_show.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/_update.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/__cmd_group.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/__init__.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_create.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_delete.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_execute.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_fix_permissions.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_list.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_show.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_update.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_validate.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_wait.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/__cmd_group.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/__init__.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/_cancel.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/_list.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/_show.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/workspace/__cmd_group.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/workspace/__init__.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/workspace/_create.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/workspace/_delete.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/workspace/_list.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/workspace/_refresh_recommendation.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/workspace/_show.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/workspace/_update.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/workspace/_wait.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/__cmd_group.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/__init__.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/_assign.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/_remove.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/_show.py create mode 100644 src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/_wait.py create mode 100644 src/chaos/azext_chaos/azext_metadata.json create mode 100644 src/chaos/azext_chaos/commands.py create mode 100644 src/chaos/azext_chaos/custom.py create mode 100644 src/chaos/azext_chaos/custom_wait.py create mode 100644 src/chaos/azext_chaos/tests/__init__.py create mode 100644 src/chaos/azext_chaos/tests/latest/README.md create mode 100644 src/chaos/azext_chaos/tests/latest/__init__.py create mode 100644 src/chaos/azext_chaos/tests/latest/test_aaz_pristine.py create mode 100644 src/chaos/azext_chaos/tests/latest/test_chaos_discovered_resource.py create mode 100644 src/chaos/azext_chaos/tests/latest/test_chaos_scenario.py create mode 100644 src/chaos/azext_chaos/tests/latest/test_chaos_scenario_config.py create mode 100644 src/chaos/azext_chaos/tests/latest/test_chaos_scenario_run.py create mode 100644 src/chaos/azext_chaos/tests/latest/test_chaos_workspace.py create mode 100644 src/chaos/azext_chaos/tests/latest/test_command_registration.py create mode 100644 src/chaos/azext_chaos/tests/latest/test_custom_commands.py create mode 100644 src/chaos/azext_chaos/tests/latest/test_table_format.py create mode 100644 src/chaos/azext_chaos/tests/latest/test_validators.py create mode 100644 src/chaos/setup.cfg create mode 100644 src/chaos/setup.py diff --git a/src/chaos/HISTORY.rst b/src/chaos/HISTORY.rst new file mode 100644 index 00000000000..f15c96cdac9 --- /dev/null +++ b/src/chaos/HISTORY.rst @@ -0,0 +1,8 @@ +.. :changelog: + +Release History +=============== + +0.1.0 ++++++ +* Initial scaffolding. Commands will be populated when aaz-dev codegen is complete (pending 2026-05-01-preview spec availability). diff --git a/src/chaos/README.md b/src/chaos/README.md new file mode 100644 index 00000000000..fe18df2cbde --- /dev/null +++ b/src/chaos/README.md @@ -0,0 +1,91 @@ +# Microsoft Azure CLI 'chaos' Extension + +Azure CLI extension for Azure Chaos Studio v2 Workspaces. + +Provides the `az chaos` command group for workspace lifecycle management, +scenario browsing, scenario configuration with validation and execution, +and run history with per-run cancellation. + +## Installation + +```bash +az extension add --name chaos +``` + +## Usage + +```bash +# Create a workspace with a system-assigned managed identity +az chaos workspace create -g MyRG --workspace-name my-workspace --location eastus \ + --system-assigned "" \ + --scopes "/subscriptions//resourceGroups/MyRG" + +# Create a workspace with a user-assigned managed identity +az chaos workspace create -g MyRG --workspace-name my-workspace --location eastus \ + --mi-user-assigned "/subscriptions//resourceGroups/MyRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/myId" \ + --scopes "/subscriptions//resourceGroups/MyRG" + +# List workspaces +az chaos workspace list +az chaos workspace list -g MyRG + +# List scenarios +az chaos scenario list -g MyRG --workspace-name my-workspace + +# Create a scenario configuration (--scenario-id is auto-derived) +az chaos scenario config create -g MyRG --workspace-name my-workspace \ + --scenario-name ZoneDown-1.0 -n my-config \ + --parameters "[{key:duration,value:PT10M}]" \ + --filters "{locations:[westus2],zones:[1]}" + +# Validate a scenario configuration +az chaos scenario config validate -g MyRG --workspace-name my-workspace \ + --scenario-name ZoneDown-1.0 -n my-config + +# Fix resource permissions +az chaos scenario config fix-permissions -g MyRG --workspace-name my-workspace \ + --scenario-name ZoneDown-1.0 -n my-config + +# Start a run +az chaos scenario run start -g MyRG --workspace-name my-workspace \ + --scenario-name ZoneDown-1.0 --config-name my-config + +# Show run status +az chaos scenario run show -g MyRG --workspace-name my-workspace \ + --scenario-name ZoneDown-1.0 --run-id + +# List runs +az chaos scenario run list -g MyRG --workspace-name my-workspace \ + --scenario-name ZoneDown-1.0 +``` + +For full documentation, see the [Azure Chaos Studio CLI reference](https://learn.microsoft.com/azure/chaos-studio/). + +## Modifying the AAZ-generated code + +**NEVER edit files under `azext_chaos/aaz/`.** They are generated by `aaz-dev` from +the pinned spec and overwritten on every regen. The regression test +`tests/latest/test_aaz_pristine.py` fails any PR that hand-edits `pre_operations` +or `post_operations` bodies inside `aaz/`. + +To customize: + +- **Arg aliases, command renames, help text, examples** → edit + `automation/cli-extension/scripts/customize_workspace.py` (the workspace + customization driver), then run + `automation/cli-extension/scripts/regen.cmd` to regenerate the + `azext_chaos/aaz/` tree. The pinned spec + commit SHA are recorded in + `automation/cli-extension/aaz-models/chaos/CODEGEN_SOURCE.md`. +- **`pre_operations` / `post_operations` hooks, computed args, derived defaults** + → subclass the generated class in `azext_chaos/custom.py` and register the + subclass in `azext_chaos/commands.py` via + `self.command_table[""] = MySubclass(loader=self)`. + Existing examples: `ScenarioConfigCreate` (auto-derives `--scenario-id`). +- **New commands not in the spec** → write them in `azext_chaos/custom.py` + (regular Python functions wired with `g.custom_command(...)`) or, for full + AAZ-shaped commands, a sibling module like `azext_chaos/custom_wait.py` using + `@register_command` + an `AAZCommand` / `AAZWaitCommand` subclass that is + registered explicitly in `azext_chaos/commands.py`. + +Reference: `Azure/azure-cli-extensions/src/connectedmachine/azext_connectedmachine` +is the canonical pattern for AAZ subclass + manual `command_table` registration. diff --git a/src/chaos/azext_chaos/__init__.py b/src/chaos/azext_chaos/__init__.py new file mode 100644 index 00000000000..28757876af4 --- /dev/null +++ b/src/chaos/azext_chaos/__init__.py @@ -0,0 +1,40 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core import AzCommandsLoader +from azext_chaos._help import helps # pylint: disable=unused-import + + +class ChaosCommandsLoader(AzCommandsLoader): + + def __init__(self, cli_ctx=None): + from azure.cli.core.commands import CliCommandType + chaos_custom = CliCommandType( + operations_tmpl='azext_chaos.custom#{}') + super().__init__(cli_ctx=cli_ctx, + custom_command_type=chaos_custom) + + def load_command_table(self, args): + from azext_chaos.commands import load_command_table + from azure.cli.core.aaz import load_aaz_command_table + try: + from . import aaz + except ImportError: + aaz = None + if aaz: + load_aaz_command_table( + loader=self, + aaz_pkg_name=aaz.__name__, + args=args + ) + load_command_table(self, args) + return self.command_table + + def load_arguments(self, command): + from azext_chaos._params import load_arguments + load_arguments(self, command) + + +COMMAND_LOADER_CLS = ChaosCommandsLoader diff --git a/src/chaos/azext_chaos/_help.py b/src/chaos/azext_chaos/_help.py new file mode 100644 index 00000000000..ab7ac86c4b2 --- /dev/null +++ b/src/chaos/azext_chaos/_help.py @@ -0,0 +1,606 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from knack.help_files import helps # pylint: disable=unused-import + + +# ── Groups ─────────────────────────────────────────────────────────────── + +helps['chaos'] = """ +type: group +short-summary: Manage Azure Chaos Studio resources. +long-summary: | + Commands for Azure Chaos Studio v2 Workspaces — create and manage workspaces, + scenarios, scenario configurations, and runs for chaos engineering experiments. +""" + +helps['chaos workspace'] = """ +type: group +short-summary: Manage Chaos Studio workspaces. +long-summary: | + Workspaces are the top-level resource for Chaos Studio v2. They define + the scope of resources that can be targeted by chaos scenarios and the + managed identity used for fault injection. +""" + +helps['chaos scenario'] = """ +type: group +short-summary: Manage Chaos Studio scenarios within a workspace. +long-summary: | + Scenarios define the fault-injection actions available in a workspace. + Catalog scenarios are populated by workspace evaluation (see + 'az chaos workspace refresh-recommendations'); custom scenarios can be + created directly. +""" + +helps['chaos scenario config'] = """ +type: group +short-summary: Manage scenario configurations for a Chaos Studio scenario. +long-summary: | + Scenario configurations define the steps, branches, and fault parameters + for a chaos experiment run. Use 'validate' to check a configuration before + execution and 'fix-permissions' to grant required RBAC roles. +""" + +helps['chaos scenario run'] = """ +type: group +short-summary: Manage scenario runs for a Chaos Studio scenario. +long-summary: | + Scenario runs represent individual executions of a scenario configuration. + Use 'start' to begin a new run, 'show' to inspect its status, and + 'cancel' to stop a running execution. +""" + +helps['chaos discovered-resource'] = """ +type: group +short-summary: Browse discovered resources in a Chaos Studio workspace. +long-summary: | + Discovered resources are populated by workspace discovery scans + (triggered via 'az chaos workspace refresh-recommendations'). + Use 'list' to see all discovered resources and 'show' to inspect + a specific one. +""" + +# ── workspace commands ─────────────────────────────────────────────────── + +helps['chaos workspace create'] = """ +type: command +short-summary: Create a Chaos Studio workspace. +long-summary: | + Creates a new workspace with the specified scope and managed identity + configuration. The workspace is the top-level container for scenarios, + configurations, and runs. This is an LRO that is polled to completion + by default. + + Identity is configured via the standard Azure CLI flags: + --system-assigned "" (enables a system-assigned identity) + --user-assigned [ ...] (assigns one or more user-assigned identities) + Either, both, or neither may be supplied. +examples: + - name: Create a workspace with a system-assigned identity + text: > + az chaos workspace create --name MyWorkspace --resource-group MyRG + --location westus2 + --scopes "/subscriptions/{sub}/resourceGroups/MyRG" + --system-assigned "" + - name: Create a workspace with a user-assigned identity + text: > + az chaos workspace create --name MyWorkspace --resource-group MyRG + --location westus2 + --scopes "/subscriptions/{sub}/resourceGroups/MyRG" + --user-assigned "/subscriptions/{sub}/resourceGroups/MyRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/myId" +""" + +helps['chaos workspace show'] = """ +type: command +short-summary: Get a Chaos Studio workspace. +examples: + - name: Show a workspace + text: > + az chaos workspace show --name MyWorkspace --resource-group MyRG + - name: Show a workspace with JSON output + text: > + az chaos workspace show --name MyWorkspace --resource-group MyRG --output json +""" + +helps['chaos workspace list'] = """ +type: command +short-summary: List Chaos Studio workspaces. +long-summary: | + Without --resource-group, lists all workspaces in the subscription. + With --resource-group, lists only workspaces in that resource group. +examples: + - name: List all workspaces in the subscription + text: > + az chaos workspace list + - name: List workspaces in a specific resource group + text: > + az chaos workspace list --resource-group MyRG +""" + +helps['chaos workspace delete'] = """ +type: command +short-summary: Delete a Chaos Studio workspace. +examples: + - name: Delete a workspace + text: > + az chaos workspace delete --name MyWorkspace --resource-group MyRG --yes + - name: Delete a workspace without waiting for completion + text: > + az chaos workspace delete --name MyWorkspace --resource-group MyRG --yes --no-wait +""" + +helps['chaos workspace update'] = """ +type: command +short-summary: Update a Chaos Studio workspace. +long-summary: | + Update workspace properties such as tags and identity configuration. + This is an LRO that is polled to completion by default. +examples: + - name: Update workspace tags + text: > + az chaos workspace update --name MyWorkspace --resource-group MyRG + --tags env=dev team=chaos + - name: Update the workspace scopes + text: > + az chaos workspace update --name MyWorkspace --resource-group MyRG + --scopes /subscriptions/SUB/resourceGroups/MyRG/providers/Microsoft.Compute/virtualMachines/MyVM +""" + +helps['chaos workspace refresh-recommendations'] = """ +type: command +short-summary: Refresh scenario recommendations and trigger resource discovery for a workspace. +long-summary: | + Triggers workspace evaluation, which refreshes scenario recommendations and + runs resource discovery across all in-scope resources. For non-custom (catalog) + scenarios, this operation satisfies the evaluation gate required by + 'az chaos scenario config validate' and 'az chaos scenario run start'. +examples: + - name: Refresh recommendations for a workspace + text: > + az chaos workspace refresh-recommendations --name MyWorkspace --resource-group MyRG + - name: Refresh recommendations without waiting for completion + text: > + az chaos workspace refresh-recommendations --name MyWorkspace --resource-group MyRG --no-wait +""" + +helps['chaos workspace evaluate-scenarios'] = """ +type: command +short-summary: >- + Alias of `az chaos workspace refresh-recommendations`. + Refresh scenario recommendations and trigger resource discovery for a workspace. +long-summary: | + This command is a CLI-side alias for 'az chaos workspace refresh-recommendations'. + Both commands invoke the same API operation (Workspaces_RefreshRecommendations) + with identical arguments and behavior. The canonical command name is + 'az chaos workspace refresh-recommendations'. +examples: + - name: Evaluate scenarios for a workspace + text: > + az chaos workspace evaluate-scenarios --name MyWorkspace --resource-group MyRG + - name: Evaluate scenarios without waiting for completion + text: > + az chaos workspace evaluate-scenarios --name MyWorkspace --resource-group MyRG --no-wait +""" + +helps['chaos workspace show-discovery'] = """ +type: command +short-summary: Get the latest resource-discovery result for a workspace. +long-summary: | + Retrieves the latest workspace-scope resource-discovery operation result. + Returns the discovery operation's state (in-progress, succeeded, failed) + and any failure details. This is a read-only GET — it does NOT trigger + a new discovery. Use 'az chaos workspace refresh-recommendations' to + trigger a new discovery scan. +examples: + - name: Show the latest discovery result + text: > + az chaos workspace show-discovery --name MyWorkspace --resource-group MyRG + - name: Show discovery result with JSON output + text: > + az chaos workspace show-discovery --name MyWorkspace --resource-group MyRG --output json +""" + +helps['chaos workspace show-evaluation'] = """ +type: command +short-summary: Get the latest scenario-evaluation result for a workspace. +long-summary: | + Retrieves the latest workspace scenario-evaluation operation result. + Returns the evaluation state — useful for checking whether the workspace + has been evaluated (a prerequisite for 'scenario config validate' and + 'scenario run start' on catalog scenarios). This is a read-only GET — it + does NOT trigger a new evaluation. Use + 'az chaos workspace refresh-recommendations' to trigger evaluation. +examples: + - name: Show the latest evaluation result + text: > + az chaos workspace show-evaluation --name MyWorkspace --resource-group MyRG + - name: Show evaluation result with JSON output + text: > + az chaos workspace show-evaluation --name MyWorkspace --resource-group MyRG --output json +""" + +# ── scenario commands ──────────────────────────────────────────────────── + +helps['chaos scenario create'] = """ +type: command +short-summary: Create or replace a scenario in a workspace. +long-summary: | + Creates a custom scenario. Custom scenarios are authored directly via this + command and define their own actions and parameters. Catalog scenarios + (populated by 'az chaos workspace refresh-recommendations') are not + created this way. + + Use --actions, --parameters, and --description to populate the scenario + body. Each --actions element uses the shorthand-syntax form + 'action-id= name= duration=' — see + https://learn.microsoft.com/en-us/cli/azure/use-azure-cli-successfully-tips#use-shorthand-syntax + for the full syntax reference. +examples: + - name: Create a minimal custom scenario (no actions yet — add via update) + text: > + az chaos scenario create --workspace-name MyWorkspace --resource-group MyRG + --name MyScenario --description "My custom scenario" + - name: Create a custom scenario with one action + text: > + az chaos scenario create --workspace-name MyWorkspace --resource-group MyRG + --name MyScenario --description "VM shutdown experiment" + --actions "[{action-id:'microsoft-compute-shutdown/1.0',name:'step1',duration:'PT5M'}]" +""" + +helps['chaos scenario show'] = """ +type: command +short-summary: Get a scenario by name. +examples: + - name: Show a scenario + text: > + az chaos scenario show --workspace-name MyWorkspace --resource-group MyRG + --name ZoneDown-1.0 + - name: Show a scenario with JSON output + text: > + az chaos scenario show --workspace-name MyWorkspace --resource-group MyRG + --name ZoneDown-1.0 --output json +""" + +helps['chaos scenario list'] = """ +type: command +short-summary: List scenarios in a workspace. +examples: + - name: List all scenarios + text: > + az chaos scenario list --workspace-name MyWorkspace --resource-group MyRG + - name: List scenarios with table output + text: > + az chaos scenario list --workspace-name MyWorkspace --resource-group MyRG --output table +""" + +helps['chaos scenario delete'] = """ +type: command +short-summary: Delete a scenario. +examples: + - name: Delete a scenario + text: > + az chaos scenario delete --workspace-name MyWorkspace --resource-group MyRG + --name MyScenario --yes + - name: Delete a scenario with confirmation prompt + text: > + az chaos scenario delete --workspace-name MyWorkspace --resource-group MyRG + --name MyScenario +""" + +# ── scenario config commands ───────────────────────────────────────────── + +helps['chaos scenario config create'] = """ +type: command +short-summary: Create a scenario configuration with parameters and filters. +long-summary: | + Creates a scenario configuration that defines the fault parameters and + resource targeting filters for a chaos experiment run. + + --parameters accepts either inline JSON or a file reference using the + standard '@filename.json' syntax (e.g., --parameters @params.json). + + Supported --filters keys (shorthand syntax shown; all keys are optional): + + locations=[...] Azure region strings (e.g. 'westus2'). Only + resources in these regions are included. Omit + for all regions. + zones=[...] Logical availability zone IDs ('1', '2', '3', + 'zone-redundant'). Only resources whose zones + intersect this list are included. The `zones` + and `physical-zones` keys are mutually exclusive. + physical-zones=[...] Physical zone IDs in `{region}-az{N}` form + (e.g. 'westus2-az1'). Resolved per-subscription + to logical zones at execution time. Only ONE + physical zone is supported in preview. As noted + above, this is mutually exclusive with `zones`. + + Supported --exclusions keys (shorthand syntax shown; all keys are optional): + + resources=[...] ARM resource IDs to exclude verbatim. + tags=[{key,value},..] Resources with any matching tag are excluded. + types=[...] Resource types (e.g. 'Microsoft.Compute/...'). + All resources of these types are excluded. + + Setting both `zones` and `physical-zones` is rejected by the server with a + 400. No CLI-side validation is performed for this mutual exclusion — the + server is authoritative. +examples: + - name: Create a configuration with logical zone targeting + text: > + az chaos scenario config create --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 --name zone1 + --parameters "[{key:duration,value:PT10M}]" + --filters "{locations:[westus2],zones:[1]}" + - name: Create a configuration with physical zone targeting + text: > + az chaos scenario config create --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 --name zone-physical + --parameters "[{key:duration,value:PT10M}]" + --filters "{locations:[westus2],physical-zones:[westus2-az1]}" + - name: Create a configuration with exclusions by resource ID, tag, and type + text: > + az chaos scenario config create --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 --name with-exclusions + --parameters "[{key:duration,value:PT10M}]" + --filters "{locations:[eastus],zones:[1]}" + --exclusions "{resources:[/subscriptions/.../virtualMachines/protectedVM],tags:[{key:env,value:prod}],types:[Microsoft.Compute/virtualMachineScaleSets]}" +""" + +helps['chaos scenario config show'] = """ +type: command +short-summary: Get a scenario configuration. +examples: + - name: Show a scenario configuration + text: > + az chaos scenario config show --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 --name zone1 + - name: Show a configuration with JSON output + text: > + az chaos scenario config show --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 --name zone1 --output json +""" + +helps['chaos scenario config list'] = """ +type: command +short-summary: List scenario configurations for a scenario. +examples: + - name: List all configurations for a scenario + text: > + az chaos scenario config list --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 + - name: List configurations with table output + text: > + az chaos scenario config list --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 --output table +""" + +helps['chaos scenario config delete'] = """ +type: command +short-summary: Delete a scenario configuration. +examples: + - name: Delete a scenario configuration + text: > + az chaos scenario config delete --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 --name zone1 --yes + - name: Delete without waiting for completion + text: > + az chaos scenario config delete --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 --name zone1 --yes --no-wait +""" + +helps['chaos scenario config validate'] = """ +type: command +short-summary: Validate a scenario configuration. +long-summary: | + Submits a validation request for a scenario configuration and, by default, + waits for the validation to complete and auto-fetches the validation result. + + With --no-wait, the command submits the validation and returns immediately. + Use 'az chaos scenario config show-validation' to retrieve the result + once the operation completes. + + For non-custom (catalog) scenarios, the workspace must be evaluated before + validation can succeed. If the workspace has not been evaluated, the command + will fail with a hint to run 'az chaos workspace refresh-recommendations'. +examples: + - name: Validate a scenario configuration and display results + text: > + az chaos scenario config validate --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 --name zone1 + - name: Submit validation without waiting (use 'show-validation' to fetch the result later) + text: > + az chaos scenario config validate --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 --name zone1 --no-wait +""" + +helps['chaos scenario config show-validation'] = """ +type: command +short-summary: Get the latest validation result for a scenario configuration. +long-summary: | + Retrieves the most recent validation result for a scenario configuration. + This is a read-only GET — it does NOT trigger a new validation. Use + 'az chaos scenario config validate' to submit a new validation request. +examples: + - name: Show the latest validation result + text: > + az chaos scenario config show-validation --workspace-name MyWorkspace + --resource-group MyRG --scenario-name ZoneDown-1.0 --name zone1 + - name: Show validation result with JSON output + text: > + az chaos scenario config show-validation --workspace-name MyWorkspace + --resource-group MyRG --scenario-name ZoneDown-1.0 --name zone1 + --output json +""" + +helps['chaos scenario config fix-permissions'] = """ +type: command +short-summary: Fix RBAC permissions for a scenario configuration. +long-summary: | + Assigns the RBAC roles required for the configuration's targeted resources. + The operation runs its own internal validation before fixing permissions, + so a prior 'az chaos scenario config validate' call is NOT required. + However, the workspace, scenario, and configuration must all exist; + a 404 NotFound typically means one of these resources is missing or + the workspace has not finished provisioning. + Use --what-if for a server-side dry-run that returns the role assignments + that would be made without actually creating them. Note that --what-if is + server-evaluated (not client-only like PowerShell's -WhatIf) and the + response shape is the same PermissionsFix resource regardless of mode. +examples: + - name: Fix permissions for a configuration + text: > + az chaos scenario config fix-permissions --workspace-name MyWorkspace + --resource-group MyRG --scenario-name ZoneDown-1.0 --name zone1 + - name: Preview what permissions would be assigned (what-if mode) + text: > + az chaos scenario config fix-permissions --workspace-name MyWorkspace + --resource-group MyRG --scenario-name ZoneDown-1.0 --name zone1 + --what-if +""" + +helps['chaos scenario config show-permission-fix'] = """ +type: command +short-summary: Get the latest permission-fix result for a scenario configuration. +long-summary: | + Retrieves the most recent permission-fix result. This is a read-only GET — + it does NOT trigger a new fix. The response body carries + 'properties.whatIfMode' indicating whether the latest fix was a what-if + dry run or an actual fix; the singleton returns whichever was most recently + submitted. Use 'az chaos scenario config fix-permissions' to trigger a new + fix (with optional --what-if for a server-side dry run). +examples: + - name: Show the latest permission-fix result + text: > + az chaos scenario config show-permission-fix --workspace-name MyWorkspace + --resource-group MyRG --scenario-name ZoneDown-1.0 --name zone1 + - name: Show the latest permission-fix result with JSON output + text: > + az chaos scenario config show-permission-fix --workspace-name MyWorkspace + --resource-group MyRG --scenario-name ZoneDown-1.0 --name zone1 + --output json +""" + +# ── scenario run commands ──────────────────────────────────────────────── + +helps['chaos scenario run start'] = """ +type: command +short-summary: Start a scenario run from a scenario configuration. +long-summary: | + By default, runs a pre-flight validation before executing the scenario + configuration. If validation fails, the run is not started and the command + exits non-zero with the validation errors. + + --skip-validation bypasses pre-flight validation and proceeds directly to + execute. Use for CI re-runs of an already-validated configuration or when + driving the validate→execute sequence manually. + + --no-wait applies only to the execute phase. Pre-flight validation (if not + skipped) is always awaited to completion. With --no-wait, the command returns + immediately after kicking off execute, with the run ID parsed from the + Location header. + + The fastest fire-and-forget invocation is --skip-validation --no-wait. +examples: + - name: Start a scenario run (default — validates first, then executes) + text: > + az chaos scenario run start --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 --config-name zone1 + - name: Start a run skipping pre-flight validation + text: > + az chaos scenario run start --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 --config-name zone1 --skip-validation + - name: Start a run without waiting for execute to complete + text: > + az chaos scenario run start --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 --config-name zone1 --no-wait + - name: Fire-and-forget (skip validation and don't wait for execute) + text: > + az chaos scenario run start --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 --config-name zone1 --skip-validation --no-wait +""" + +helps['chaos scenario run list'] = """ +type: command +short-summary: List scenario runs for a scenario. +long-summary: | + Lists execution-mode runs for a scenario. To filter runs to a specific + configuration, post-filter the output by the run's configurationName + property (visible in --output json/yaml and in table output). +examples: + - name: List all runs for a scenario + text: > + az chaos scenario run list --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 + - name: List runs with table output + text: > + az chaos scenario run list --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 --output table +""" + +helps['chaos scenario run show'] = """ +type: command +short-summary: Get a specific scenario run. +examples: + - name: Show a scenario run by ID + text: > + az chaos scenario run show --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 --run-id 12345678-1234-1234-1234-123456789012 + - name: Show a scenario run with JSON output + text: > + az chaos scenario run show --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 --run-id 12345678-1234-1234-1234-123456789012 + --output json +""" + +helps['chaos scenario run cancel'] = """ +type: command +short-summary: Cancel a running scenario run. +long-summary: | + Sends a cancellation request for a running scenario run. The cancellation + is an LRO that is polled to completion by default. +examples: + - name: Cancel a scenario run + text: > + az chaos scenario run cancel --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 --run-id 12345678-1234-1234-1234-123456789012 + - name: Cancel without waiting for confirmation + text: > + az chaos scenario run cancel --workspace-name MyWorkspace --resource-group MyRG + --scenario-name ZoneDown-1.0 --run-id 12345678-1234-1234-1234-123456789012 --no-wait +""" + +# ── discovered-resource commands ───────────────────────────────────────── + +helps['chaos discovered-resource list'] = """ +type: command +short-summary: List discovered resources in a workspace. +long-summary: | + Lists all resources discovered by workspace discovery scans. Use + 'az chaos workspace refresh-recommendations' to trigger a new discovery + scan if the list appears stale. +examples: + - name: List all discovered resources + text: > + az chaos discovered-resource list --workspace-name MyWorkspace --resource-group MyRG + - name: List discovered resources with table output + text: > + az chaos discovered-resource list --workspace-name MyWorkspace --resource-group MyRG + --output table +""" + +helps['chaos discovered-resource show'] = """ +type: command +short-summary: Get a discovered resource by name. +examples: + - name: Show a discovered resource + text: > + az chaos discovered-resource show --workspace-name MyWorkspace --resource-group MyRG + --name myvm + - name: Show a discovered resource with JSON output + text: > + az chaos discovered-resource show --workspace-name MyWorkspace --resource-group MyRG + --name myvm --output json +""" diff --git a/src/chaos/azext_chaos/_params.py b/src/chaos/azext_chaos/_params.py new file mode 100644 index 00000000000..19895d5e27b --- /dev/null +++ b/src/chaos/azext_chaos/_params.py @@ -0,0 +1,207 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core.commands.parameters import ( + get_location_type, + resource_group_name_type, +) +from azext_chaos._validators import validate_scope, validate_parameters_json + + +def load_arguments(self, _): + # ── chaos workspace ────────────────────────────────────────────── + with self.argument_context('chaos workspace') as c: + c.argument( + 'resource_group_name', + resource_group_name_type, + ) + # Spec pattern: ^[^<>%&:?#/\\]+$ (minLength: 1) + c.argument( + 'workspace_name', + options_list=['--name', '-n'], + help='Name of the Chaos Studio workspace.', + ) + + with self.argument_context('chaos workspace create') as c: + c.argument( + 'location', + arg_type=get_location_type(self.cli_ctx), + ) + c.argument( + 'scopes', + nargs='+', + validator=validate_scope, + help='Space-separated list of ARM resource IDs defining the ' + 'scope of this workspace.', + ) + + with self.argument_context('chaos workspace update') as c: + c.argument( + 'tags', + help='Space-separated tags in KEY=VALUE form. Use "" to clear ' + 'existing tags.', + ) + + with self.argument_context('chaos workspace delete') as c: + c.argument( + 'yes', + options_list=['--yes', '-y'], + action='store_true', + help='Do not prompt for confirmation.', + ) + + # evaluate-scenarios alias inherits all args from refresh-recommendations; + # register the same overrides so short flags resolve correctly. + for ctx_name in ('chaos workspace refresh-recommendations', + 'chaos workspace evaluate-scenarios'): + with self.argument_context(ctx_name) as c: + c.argument( + 'workspace_name', + options_list=['--name', '-n'], + help='Name of the Chaos Studio workspace.', + ) + + # ── chaos scenario ─────────────────────────────────────────────── + with self.argument_context('chaos scenario') as c: + c.argument( + 'resource_group_name', + resource_group_name_type, + ) + c.argument( + 'workspace_name', + options_list=['--workspace-name'], + help='Name of the parent Chaos Studio workspace.', + ) + c.argument( + 'scenario_name', + options_list=['--name', '-n'], + help='Name of the scenario.', + ) + + with self.argument_context('chaos scenario delete') as c: + c.argument( + 'yes', + options_list=['--yes', '-y'], + action='store_true', + help='Do not prompt for confirmation.', + ) + + # ── chaos scenario config ──────────────────────────────────────── + with self.argument_context('chaos scenario config') as c: + c.argument( + 'resource_group_name', + resource_group_name_type, + ) + c.argument( + 'workspace_name', + options_list=['--workspace-name'], + help='Name of the parent Chaos Studio workspace.', + ) + c.argument( + 'scenario_name', + options_list=['--scenario-name'], + help='Name of the parent scenario.', + ) + c.argument( + 'scenario_configuration_name', + options_list=['--name', '-n'], + help='Name of the scenario configuration.', + ) + + with self.argument_context('chaos scenario config create') as c: + c.argument( + 'parameters', + options_list=['--parameters'], + validator=validate_parameters_json, + help='Action parameters as a JSON string or @file.json reference.', + ) + + with self.argument_context('chaos scenario config delete') as c: + c.argument( + 'yes', + options_list=['--yes', '-y'], + action='store_true', + help='Do not prompt for confirmation.', + ) + + # ── chaos scenario run ─────────────────────────────────────────── + with self.argument_context('chaos scenario run') as c: + c.argument( + 'resource_group_name', + resource_group_name_type, + ) + c.argument( + 'workspace_name', + options_list=['--workspace-name'], + help='Name of the parent Chaos Studio workspace.', + ) + c.argument( + 'scenario_name', + options_list=['--scenario-name'], + help='Name of the parent scenario.', + ) + + with self.argument_context('chaos scenario run start') as c: + c.argument( + 'scenario_configuration_name', + options_list=['--config-name'], + help='Name of the scenario configuration to execute.', + ) + c.argument( + 'skip_validation', + options_list=['--skip-validation'], + action='store_true', + default=False, + help='Skip pre-flight validation and proceed directly to execute. ' + 'Use for CI re-runs of an already-validated configuration.', + ) + + with self.argument_context('chaos scenario run show') as c: + # Spec pattern: ^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$ + c.argument( + 'run_id', + options_list=['--run-id'], + help='GUID of the scenario run.', + ) + + with self.argument_context('chaos scenario run cancel') as c: + c.argument( + 'run_id', + options_list=['--run-id'], + help='GUID of the scenario run to cancel.', + ) + + # ── chaos scenario config fix-permissions (custom override) ────── + # The custom handler accepts an optional --what-if Body bool for + # server-side dry-run. + with self.argument_context('chaos scenario config fix-permissions') as c: + c.argument( + 'what_if', + options_list=['--what-if'], + action='store_true', + default=False, + help='Submit a server-side dry run that reports the role ' + 'assignments that would be made, without actually ' + 'creating them.', + ) + + # ── chaos discovered-resource ──────────────────────────────────── + with self.argument_context('chaos discovered-resource') as c: + c.argument( + 'resource_group_name', + resource_group_name_type, + ) + c.argument( + 'workspace_name', + options_list=['--workspace-name'], + help='Name of the parent Chaos Studio workspace.', + ) + + with self.argument_context('chaos discovered-resource show') as c: + c.argument( + 'discovered_resource_name', + options_list=['--name', '-n'], + help='Name of the discovered resource.', + ) diff --git a/src/chaos/azext_chaos/_table_format.py b/src/chaos/azext_chaos/_table_format.py new file mode 100644 index 00000000000..1fca5d1c40c --- /dev/null +++ b/src/chaos/azext_chaos/_table_format.py @@ -0,0 +1,191 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from collections import OrderedDict + +from azure.mgmt.core.tools import parse_resource_id +from jmespath import compile as compile_jmes, Options + + +def _project(result, expr): + parsed = compile_jmes(expr) + return parsed.search(result, Options(dict_cls=OrderedDict)) + + +# ── workspace ──────────────────────────────────────────────────────────── + + +def workspace_show_table_format(result): + """Table formatter for a single workspace. + + ``resourceGroup`` is not on the ARM tracked-resource body; aaz-dev-generated + commands return raw API responses without the CLI core's resource-group + injection. Derive it from ``id`` to match the column users expect. + """ + proj = dict(result) # copy to avoid mutating the input + rid = proj.get('id') or '' + proj['_resourceGroup'] = ( + parse_resource_id(rid).get('resource_group', '') if rid else '' + ) + return _project(proj, """{ + Name: name, + ResourceGroup: _resourceGroup, + Location: location, + ProvisioningState: properties.provisioningState, + IdentityType: identity.type + }""") + + +def workspace_list_table_format(results): + return [workspace_show_table_format(r) for r in results] + + +# ── workspace show-discovery / show-evaluation ─────────────────────────── + + +def workspace_discovery_show_table_format(result): + """Table formatter for the latest workspace discovery result.""" + return _project(result, """{ + Status: properties.status, + StartTime: properties.startTime, + EndTime: properties.endTime + }""") + + +def workspace_evaluation_show_table_format(result): + """Table formatter for the latest workspace evaluation result. + + Intentionally identical to workspace_discovery_show_table_format — + both API operations return the same shape but represent semantically + distinct operations (discovery vs. evaluation). + """ + return _project(result, """{ + Status: properties.status, + StartTime: properties.startTime, + EndTime: properties.endTime + }""") + + +# ── scenario ───────────────────────────────────────────────────────────── + + +def scenario_show_table_format(result): + return _project(result, """{ + Name: name, + Version: properties.version, + Description: properties.description, + Recommendation: properties.recommendation.recommendationStatus + }""") + + +def scenario_list_table_format(results): + return [scenario_show_table_format(r) for r in results] + + +# ── scenario config ────────────────────────────────────────────────────── + + +def scenario_config_show_table_format(result): + """Table formatter for a single scenario configuration. + + ``properties.scenarioId`` is a full ARM resource ID + (e.g., ``/subscriptions/.../scenarios/ZoneDown-1.0``). We extract the + scenario name for display — this is the case that forces a Python + callable; a bare JMESPath string cannot call ``parse_resource_id()``. + """ + proj = dict(result) # copy to avoid mutating the input + scenario_id = (proj.get('properties') or {}).get('scenarioId') or '' + # parse_resource_id returns 'resource_name' for the deepest child segment + # (scenarios/{name}); 'name' would return the workspace, not the scenario. + proj['_scenarioName'] = ( + parse_resource_id(scenario_id).get('resource_name', '') + if scenario_id else '' + ) + return _project(proj, """{ + Name: name, + Scenario: _scenarioName, + ProvisioningState: properties.provisioningState + }""") + + +def scenario_config_list_table_format(results): + return [scenario_config_show_table_format(r) for r in results] + + +# ── scenario run ───────────────────────────────────────────────────────── + + +def scenario_run_show_table_format(result): + return _project(result, """{ + RunId: name, + Status: properties.status, + StartTime: properties.startTime, + EndTime: properties.endTime + }""") + + +def scenario_run_list_table_format(results): + return [scenario_run_show_table_format(r) for r in results] + + +# ── validation ─────────────────────────────────────────────────────────── + + +def validation_show_table_format(result): + """Table formatter for validation results. + + Surfaces status, timing, and error counts so failed validations are + diagnosable from ``--output table``; full bodies live in ``--output json``. + """ + props = result.get('properties') or {} + sys_errs = props.get('errors') or [] + biz_errs = (props.get('validationErrors') or {}).get('errors') or [] + error_summary = ( + '' if not (sys_errs or biz_errs) + else f'{len(sys_errs)} system, {len(biz_errs)} validation' + ) + proj = dict(result) + proj['_errorSummary'] = error_summary + return _project(proj, """{ + Status: properties.status, + StartTime: properties.startTime, + EndTime: properties.endTime, + Errors: _errorSummary + }""") + + +# ── permission fix ─────────────────────────────────────────────────────── + + +def permission_fix_show_table_format(result): + """Table formatter for permission-fix results.""" + return _project(result, """{ + State: properties.state, + Summary: properties.summary, + WhatIfMode: properties.whatIfMode + }""") + + +# ── discovered resource ────────────────────────────────────────────────── + + +def discovered_resource_show_table_format(result): + """Table formatter for a single discovered resource. + + Columns derived from the ``DiscoveredResourceProperties`` schema: + namespace, resourceName, resourceType, fullyQualifiedIdentifier, + discoveredAt, scope. We surface the most useful subset for table width. + """ + return _project(result, """{ + Name: name, + ResourceName: properties.resourceName, + ResourceType: properties.resourceType, + Namespace: properties.namespace, + DiscoveredAt: properties.discoveredAt + }""") + + +def discovered_resource_list_table_format(results): + return [discovered_resource_show_table_format(r) for r in results] diff --git a/src/chaos/azext_chaos/_validators.py b/src/chaos/azext_chaos/_validators.py new file mode 100644 index 00000000000..3db17b7270e --- /dev/null +++ b/src/chaos/azext_chaos/_validators.py @@ -0,0 +1,61 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import json +import os + +from knack.util import CLIError + + +def validate_scope(namespace): + """Validate that --scopes values are well-formed ARM resource IDs. + + Each scope must start with ``/subscriptions/`` followed by a subscription + GUID and optionally deeper path segments (resource group, provider, etc.). + """ + scopes = getattr(namespace, 'scopes', None) + if not scopes: + return + for scope in scopes: + if not scope or not scope.startswith('/subscriptions/'): + raise CLIError( + f"Invalid ARM resource ID: '{scope}'. Each scope must be a " + "fully qualified ARM resource ID starting with " + "'/subscriptions/'." + ) + + +def validate_parameters_json(namespace): + """Validate and parse the --parameters argument. + + Accepts either ``@filename.json`` (file reference) or a raw JSON string, + following the same convention as ``az rest --body``. The parsed Python + object replaces the raw string on *namespace.parameters* so downstream + code receives a dict/list, not a string. + """ + value = getattr(namespace, 'parameters', None) + if not value: + return + + if value.startswith('@'): + file_path = value[1:] + if not os.path.isfile(file_path): + raise CLIError( + f"Parameters file not found: '{file_path}'." + ) + with open(file_path, 'r', encoding='utf-8') as f: + try: + namespace.parameters = json.load(f) + except json.JSONDecodeError as exc: + raise CLIError( + f"Invalid JSON in parameters file '{file_path}': {exc}" + ) from exc + else: + try: + namespace.parameters = json.loads(value) + except json.JSONDecodeError as exc: + raise CLIError( + f"Invalid JSON for --parameters: {exc}" + ) from exc diff --git a/src/chaos/azext_chaos/aaz/__init__.py b/src/chaos/azext_chaos/aaz/__init__.py new file mode 100644 index 00000000000..5757aea3175 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/__init__.py @@ -0,0 +1,6 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- diff --git a/src/chaos/azext_chaos/aaz/latest/__init__.py b/src/chaos/azext_chaos/aaz/latest/__init__.py new file mode 100644 index 00000000000..f6acc11aa4e --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/__init__.py @@ -0,0 +1,10 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/__cmd_group.py b/src/chaos/azext_chaos/aaz/latest/chaos/__cmd_group.py new file mode 100644 index 00000000000..661f823f562 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/__cmd_group.py @@ -0,0 +1,24 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command_group( + "chaos", + is_preview=True, +) +class __CMDGroup(AAZCommandGroup): + """Manage Chaos + """ + pass + + +__all__ = ["__CMDGroup"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/__init__.py b/src/chaos/azext_chaos/aaz/latest/chaos/__init__.py new file mode 100644 index 00000000000..5a9d61963d6 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/__init__.py @@ -0,0 +1,11 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from .__cmd_group import * diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/__cmd_group.py b/src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/__cmd_group.py new file mode 100644 index 00000000000..a01eac9410e --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/__cmd_group.py @@ -0,0 +1,24 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command_group( + "chaos discovered-resource", + is_preview=True, +) +class __CMDGroup(AAZCommandGroup): + """Manage Discovered Resource + """ + pass + + +__all__ = ["__CMDGroup"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/__init__.py b/src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/__init__.py new file mode 100644 index 00000000000..2df85698253 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/__init__.py @@ -0,0 +1,13 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from .__cmd_group import * +from ._list import * +from ._show import * diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/_list.py b/src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/_list.py new file mode 100644 index 00000000000..21f31dbed63 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/_list.py @@ -0,0 +1,239 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos discovered-resource list", + is_preview=True, +) +class List(AAZCommand): + """List a list of discovered resources for a workspace. + + :example: List discovered resources for a workspace. + az chaos discovered-resource list --resource-group exampleRG --workspace-name exampleWorkspace + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/discoveredresources", "2026-05-01-preview"], + ] + } + + AZ_SUPPORT_PAGINATION = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_paging(self._execute_operations, self._output) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.DiscoveredResourcesListByWorkspace(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance.value, client_flatten=True) + next_link = self.deserialize_output(self.ctx.vars.instance.next_link) + return result, next_link + + class DiscoveredResourcesListByWorkspace(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/discoveredResources", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.next_link = AAZStrType( + serialized_name="nextLink", + ) + _schema_on_200.value = AAZListType( + flags={"required": True}, + ) + + value = cls._schema_on_200.value + value.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element + _element.id = AAZStrType( + flags={"read_only": True}, + ) + _element.name = AAZStrType( + flags={"read_only": True}, + ) + _element.properties = AAZObjectType() + _element.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _element.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.value.Element.properties + properties.discovered_at = AAZStrType( + serialized_name="discoveredAt", + flags={"read_only": True}, + ) + properties.fully_qualified_identifier = AAZStrType( + serialized_name="fullyQualifiedIdentifier", + flags={"read_only": True}, + ) + properties.namespace = AAZStrType( + flags={"read_only": True}, + ) + properties.resource_name = AAZStrType( + serialized_name="resourceName", + flags={"read_only": True}, + ) + properties.resource_type = AAZStrType( + serialized_name="resourceType", + flags={"read_only": True}, + ) + properties.scope = AAZStrType( + flags={"read_only": True}, + ) + + system_data = cls._schema_on_200.value.Element.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + return cls._schema_on_200 + + +class _ListHelper: + """Helper class for List""" + + +__all__ = ["List"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/_show.py b/src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/_show.py new file mode 100644 index 00000000000..cbc698bfc2f --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/_show.py @@ -0,0 +1,241 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos discovered-resource show", + is_preview=True, +) +class Show(AAZCommand): + """Get a discovered resource. + + :example: Get a discovered resource. + az chaos discovered-resource show --resource-group exampleRG --workspace-name exampleWorkspace --discovered-resource-name a1b2c3d4-e5f6-7890-abcd-ef1234567890 + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/discoveredresources/{}", "2026-05-01-preview"], + ] + } + + def _handler(self, command_args): + super()._handler(command_args) + self._execute_operations() + return self._output() + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.discovered_resource_name = AAZStrArg( + options=["-n", "--name", "--discovered-resource-name"], + help="Name of the discovered resource.", + required=True, + id_part="child_name_1", + fmt=AAZStrArgFormat( + pattern="^[a-zA-Z0-9-]+$", + min_length=1, + ), + ) + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.DiscoveredResourcesGet(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) + return result + + class DiscoveredResourcesGet(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/discoveredResources/{discoveredResourceName}", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "discoveredResourceName", self.ctx.args.discovered_resource_name, + required=True, + ), + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.id = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.name = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.properties = AAZObjectType() + _schema_on_200.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _schema_on_200.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.properties + properties.discovered_at = AAZStrType( + serialized_name="discoveredAt", + flags={"read_only": True}, + ) + properties.fully_qualified_identifier = AAZStrType( + serialized_name="fullyQualifiedIdentifier", + flags={"read_only": True}, + ) + properties.namespace = AAZStrType( + flags={"read_only": True}, + ) + properties.resource_name = AAZStrType( + serialized_name="resourceName", + flags={"read_only": True}, + ) + properties.resource_type = AAZStrType( + serialized_name="resourceType", + flags={"read_only": True}, + ) + properties.scope = AAZStrType( + flags={"read_only": True}, + ) + + system_data = cls._schema_on_200.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + return cls._schema_on_200 + + +class _ShowHelper: + """Helper class for Show""" + + +__all__ = ["Show"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/__cmd_group.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/__cmd_group.py new file mode 100644 index 00000000000..9cf2f3c10bf --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/__cmd_group.py @@ -0,0 +1,24 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command_group( + "chaos scenario", + is_preview=True, +) +class __CMDGroup(AAZCommandGroup): + """Manage Scenario + """ + pass + + +__all__ = ["__CMDGroup"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/__init__.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/__init__.py new file mode 100644 index 00000000000..c401f439385 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/__init__.py @@ -0,0 +1,16 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from .__cmd_group import * +from ._create import * +from ._delete import * +from ._list import * +from ._show import * +from ._update import * diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/_create.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/_create.py new file mode 100644 index 00000000000..4fc28cbe8fd --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/_create.py @@ -0,0 +1,565 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos scenario create", + is_preview=True, +) +class Create(AAZCommand): + """Create a scenario. + + :example: Create or update a scenario. + az chaos scenario create --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name zoneDownScenario --description 'Induces an outage of all discovered VM and VMSS instances in the target zone.' --parameters "[{name:duration,type:string,default:PT15M,required:False,description:'The duration of the outage scenario.'}]" --actions "[{name:vmZoneDown,action-id:'urn:csci:microsoft:compute:shutdown/1.0.0',description:'Force shutdown VM instances in target zone',duration:'%%{parameters.duration}%%',parameters:[{key:zones,value:'%%{filters.zones}%%'}]}]" + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/scenarios/{}", "2026-05-01-preview"], + ] + } + + def _handler(self, command_args): + super()._handler(command_args) + self._execute_operations() + return self._output() + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.scenario_name = AAZStrArg( + options=["-n", "--name", "--scenario-name"], + help="Name of the scenario.", + required=True, + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + + # define Arg Group "Properties" + + _args_schema = cls._args_schema + _args_schema.actions = AAZListArg( + options=["--actions"], + arg_group="Properties", + help="Array of actions that define the scenario's orchestration.", + fmt=AAZListArgFormat( + min_length=1, + ), + ) + _args_schema.description = AAZStrArg( + options=["--description"], + arg_group="Properties", + help="Description of what this scenario does (optional).", + ) + _args_schema.parameters = AAZListArg( + options=["--parameters"], + arg_group="Properties", + help="Parameter definitions for the scenario.", + ) + + actions = cls._args_schema.actions + actions.Element = AAZObjectArg() + + _element = cls._args_schema.actions.Element + _element.action_id = AAZStrArg( + options=["action-id"], + help="Identifier of the action and version (e.g., \"microsoft-compute-shutdown/1.0\").", + required=True, + ) + _element.description = AAZStrArg( + options=["description"], + help="Human-readable description of what this action does.", + ) + _element.duration = AAZStrArg( + options=["duration"], + help="ISO 8601 duration for how long the action runs (e.g., PT30M for 30 minutes). Supports template macro syntax (%%\\{parameters.\\\\}%%).", + required=True, + ) + _element.external_resource = AAZObjectArg( + options=["external-resource"], + help="External resource reference for the action.", + ) + _element.name = AAZStrArg( + options=["name"], + help="Unique name for the action.", + required=True, + fmt=AAZStrArgFormat( + pattern="^[a-zA-Z0-9-_]+$", + ), + ) + _element.parameters = AAZListArg( + options=["parameters"], + help="Action-specific parameter values.", + ) + _element.run_after = AAZObjectArg( + options=["run-after"], + help="Action dependencies that control when this action starts.", + ) + _element.timeout = AAZStrArg( + options=["timeout"], + help="ISO 8601 duration for maximum action execution time. Supports template macro syntax.", + ) + _element.wait_before = AAZStrArg( + options=["wait-before"], + help="ISO 8601 duration to wait before action starts (e.g., PT30S for 30 seconds). Supports template macro syntax.", + ) + + external_resource = cls._args_schema.actions.Element.external_resource + external_resource.resource_id = AAZResourceIdArg( + options=["resource-id"], + help="The resource ID of the external resource.", + ) + + parameters = cls._args_schema.actions.Element.parameters + parameters.Element = AAZObjectArg() + + _element = cls._args_schema.actions.Element.parameters.Element + _element.key = AAZStrArg( + options=["key"], + help="The name of the setting for the action.", + required=True, + fmt=AAZStrArgFormat( + min_length=1, + ), + ) + _element.value = AAZStrArg( + options=["value"], + help="The value of the setting for the action.", + required=True, + fmt=AAZStrArgFormat( + min_length=1, + ), + ) + + run_after = cls._args_schema.actions.Element.run_after + run_after.behavior = AAZStrArg( + options=["behavior"], + help="Defines how multiple dependencies are evaluated.", + default="Any", + enum={"All": "All", "Any": "Any", "AtLeastOne": "AtLeastOne"}, + ) + run_after.items = AAZListArg( + options=["items"], + help="Array of action dependencies.", + required=True, + fmt=AAZListArgFormat( + min_length=1, + ), + ) + + items = cls._args_schema.actions.Element.run_after.items + items.Element = AAZObjectArg() + + _element = cls._args_schema.actions.Element.run_after.items.Element + _element.name = AAZStrArg( + options=["name"], + help="Name of the action this depends on.", + required=True, + ) + _element.on_action_lifecycle = AAZStrArg( + options=["on-action-lifecycle"], + help="The lifecycle state of the dependency action that triggers this action to start.", + enum={"AnyTerminal": "AnyTerminal", "Failure": "Failure", "Running": "Running", "Skipped": "Skipped", "Start": "Start", "Success": "Success"}, + ) + _element.type = AAZStrArg( + options=["type"], + help="The type of dependency.", + required=True, + enum={"Action": "Action"}, + ) + + parameters = cls._args_schema.parameters + parameters.Element = AAZObjectArg() + + _element = cls._args_schema.parameters.Element + _element.default = AAZStrArg( + options=["default"], + help="Default value for the parameter.", + ) + _element.description = AAZStrArg( + options=["description"], + help="Description of the parameter.", + ) + _element.name = AAZStrArg( + options=["name"], + help="The name of the parameter.", + required=True, + fmt=AAZStrArgFormat( + min_length=1, + ), + ) + _element.required = AAZBoolArg( + options=["required"], + help="Whether this parameter is required.", + ) + _element.type = AAZStrArg( + options=["type"], + help="Parameter data type.", + required=True, + enum={"array": "array", "boolean": "boolean", "number": "number", "object": "object", "string": "string"}, + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.ScenariosCreateOrUpdate(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) + return result + + class ScenariosCreateOrUpdate(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200, 201]: + return self.on_200_201(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios/{scenarioName}", + **self.url_parameters + ) + + @property + def method(self): + return "PUT" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "scenarioName", self.ctx.args.scenario_name, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Content-Type", "application/json", + ), + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + @property + def content(self): + _content_value, _builder = self.new_content_builder( + self.ctx.args, + typ=AAZObjectType, + typ_kwargs={"flags": {"required": True, "client_flatten": True}} + ) + _builder.set_prop("properties", AAZObjectType) + + properties = _builder.get(".properties") + if properties is not None: + properties.set_prop("actions", AAZListType, ".actions", typ_kwargs={"flags": {"required": True}}) + properties.set_prop("description", AAZStrType, ".description") + properties.set_prop("parameters", AAZListType, ".parameters", typ_kwargs={"flags": {"required": True}}) + + actions = _builder.get(".properties.actions") + if actions is not None: + actions.set_elements(AAZObjectType, ".") + + _elements = _builder.get(".properties.actions[]") + if _elements is not None: + _elements.set_prop("actionId", AAZStrType, ".action_id", typ_kwargs={"flags": {"required": True}}) + _elements.set_prop("description", AAZStrType, ".description") + _elements.set_prop("duration", AAZStrType, ".duration", typ_kwargs={"flags": {"required": True}}) + _elements.set_prop("externalResource", AAZObjectType, ".external_resource") + _elements.set_prop("name", AAZStrType, ".name", typ_kwargs={"flags": {"required": True}}) + _elements.set_prop("parameters", AAZListType, ".parameters") + _elements.set_prop("runAfter", AAZObjectType, ".run_after") + _elements.set_prop("timeout", AAZStrType, ".timeout") + _elements.set_prop("waitBefore", AAZStrType, ".wait_before") + + external_resource = _builder.get(".properties.actions[].externalResource") + if external_resource is not None: + external_resource.set_prop("resourceId", AAZStrType, ".resource_id") + + parameters = _builder.get(".properties.actions[].parameters") + if parameters is not None: + parameters.set_elements(AAZObjectType, ".") + + _elements = _builder.get(".properties.actions[].parameters[]") + if _elements is not None: + _elements.set_prop("key", AAZStrType, ".key", typ_kwargs={"flags": {"required": True}}) + _elements.set_prop("value", AAZStrType, ".value", typ_kwargs={"flags": {"required": True}}) + + run_after = _builder.get(".properties.actions[].runAfter") + if run_after is not None: + run_after.set_prop("behavior", AAZStrType, ".behavior") + run_after.set_prop("items", AAZListType, ".items", typ_kwargs={"flags": {"required": True}}) + + items = _builder.get(".properties.actions[].runAfter.items") + if items is not None: + items.set_elements(AAZObjectType, ".") + + _elements = _builder.get(".properties.actions[].runAfter.items[]") + if _elements is not None: + _elements.set_prop("name", AAZStrType, ".name", typ_kwargs={"flags": {"required": True}}) + _elements.set_prop("onActionLifecycle", AAZStrType, ".on_action_lifecycle") + _elements.set_prop("type", AAZStrType, ".type", typ_kwargs={"flags": {"required": True}}) + + parameters = _builder.get(".properties.parameters") + if parameters is not None: + parameters.set_elements(AAZObjectType, ".") + + _elements = _builder.get(".properties.parameters[]") + if _elements is not None: + _elements.set_prop("default", AAZStrType, ".default") + _elements.set_prop("description", AAZStrType, ".description") + _elements.set_prop("name", AAZStrType, ".name", typ_kwargs={"flags": {"required": True}}) + _elements.set_prop("required", AAZBoolType, ".required") + _elements.set_prop("type", AAZStrType, ".type", typ_kwargs={"flags": {"required": True}}) + + return self.serialize_content(_content_value) + + def on_200_201(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200_201 + ) + + _schema_on_200_201 = None + + @classmethod + def _build_schema_on_200_201(cls): + if cls._schema_on_200_201 is not None: + return cls._schema_on_200_201 + + cls._schema_on_200_201 = AAZObjectType() + + _schema_on_200_201 = cls._schema_on_200_201 + _schema_on_200_201.id = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200_201.name = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200_201.properties = AAZObjectType() + _schema_on_200_201.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _schema_on_200_201.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200_201.properties + properties.actions = AAZListType( + flags={"required": True}, + ) + properties.created_from = AAZStrType( + serialized_name="createdFrom", + flags={"read_only": True}, + ) + properties.description = AAZStrType() + properties.parameters = AAZListType( + flags={"required": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.recommendation = AAZObjectType( + flags={"read_only": True}, + ) + properties.version = AAZStrType( + flags={"read_only": True}, + ) + + actions = cls._schema_on_200_201.properties.actions + actions.Element = AAZObjectType() + + _element = cls._schema_on_200_201.properties.actions.Element + _element.action_id = AAZStrType( + serialized_name="actionId", + flags={"required": True}, + ) + _element.description = AAZStrType() + _element.duration = AAZStrType( + flags={"required": True}, + ) + _element.external_resource = AAZObjectType( + serialized_name="externalResource", + ) + _element.name = AAZStrType( + flags={"required": True}, + ) + _element.parameters = AAZListType() + _element.run_after = AAZObjectType( + serialized_name="runAfter", + ) + _element.timeout = AAZStrType() + _element.wait_before = AAZStrType( + serialized_name="waitBefore", + ) + + external_resource = cls._schema_on_200_201.properties.actions.Element.external_resource + external_resource.resource_id = AAZStrType( + serialized_name="resourceId", + ) + + parameters = cls._schema_on_200_201.properties.actions.Element.parameters + parameters.Element = AAZObjectType() + + _element = cls._schema_on_200_201.properties.actions.Element.parameters.Element + _element.key = AAZStrType( + flags={"required": True}, + ) + _element.value = AAZStrType( + flags={"required": True}, + ) + + run_after = cls._schema_on_200_201.properties.actions.Element.run_after + run_after.behavior = AAZStrType() + run_after.items = AAZListType( + flags={"required": True}, + ) + + items = cls._schema_on_200_201.properties.actions.Element.run_after.items + items.Element = AAZObjectType() + + _element = cls._schema_on_200_201.properties.actions.Element.run_after.items.Element + _element.name = AAZStrType( + flags={"required": True}, + ) + _element.on_action_lifecycle = AAZStrType( + serialized_name="onActionLifecycle", + ) + _element.type = AAZStrType( + flags={"required": True}, + ) + + parameters = cls._schema_on_200_201.properties.parameters + parameters.Element = AAZObjectType() + + _element = cls._schema_on_200_201.properties.parameters.Element + _element.default = AAZStrType() + _element.description = AAZStrType() + _element.name = AAZStrType( + flags={"required": True}, + ) + _element.required = AAZBoolType() + _element.type = AAZStrType( + flags={"required": True}, + ) + + recommendation = cls._schema_on_200_201.properties.recommendation + recommendation.evaluation_run_at = AAZStrType( + serialized_name="evaluationRunAt", + flags={"read_only": True}, + ) + recommendation.recommendation_status = AAZStrType( + serialized_name="recommendationStatus", + flags={"read_only": True}, + ) + + system_data = cls._schema_on_200_201.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + return cls._schema_on_200_201 + + +class _CreateHelper: + """Helper class for Create""" + + +__all__ = ["Create"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/_delete.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/_delete.py new file mode 100644 index 00000000000..3004bdd3503 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/_delete.py @@ -0,0 +1,158 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos scenario delete", + is_preview=True, + confirmation="Are you sure you want to perform this operation?", +) +class Delete(AAZCommand): + """Delete a scenario. + + :example: Delete a scenario in a workspace. + az chaos scenario delete --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name myScenario + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/scenarios/{}", "2026-05-01-preview"], + ] + } + + def _handler(self, command_args): + super()._handler(command_args) + self._execute_operations() + return None + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.scenario_name = AAZStrArg( + options=["-n", "--name", "--scenario-name"], + help="Name of the scenario.", + required=True, + id_part="child_name_1", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.ScenariosDelete(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + class ScenariosDelete(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + if session.http_response.status_code in [204]: + return self.on_204(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios/{scenarioName}", + **self.url_parameters + ) + + @property + def method(self): + return "DELETE" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "scenarioName", self.ctx.args.scenario_name, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + def on_200(self, session): + pass + + def on_204(self, session): + pass + + +class _DeleteHelper: + """Helper class for Delete""" + + +__all__ = ["Delete"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/_list.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/_list.py new file mode 100644 index 00000000000..b8f48190198 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/_list.py @@ -0,0 +1,325 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos scenario list", + is_preview=True, +) +class List(AAZCommand): + """List a list of scenarios. + + :example: List scenarios in a workspace. + az chaos scenario list --resource-group exampleRG --workspace-name exampleWorkspace + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/scenarios", "2026-05-01-preview"], + ] + } + + AZ_SUPPORT_PAGINATION = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_paging(self._execute_operations, self._output) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.ScenariosListAll(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance.value, client_flatten=True) + next_link = self.deserialize_output(self.ctx.vars.instance.next_link) + return result, next_link + + class ScenariosListAll(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.next_link = AAZStrType( + serialized_name="nextLink", + ) + _schema_on_200.value = AAZListType( + flags={"required": True}, + ) + + value = cls._schema_on_200.value + value.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element + _element.id = AAZStrType( + flags={"read_only": True}, + ) + _element.name = AAZStrType( + flags={"read_only": True}, + ) + _element.properties = AAZObjectType() + _element.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _element.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.value.Element.properties + properties.actions = AAZListType( + flags={"required": True}, + ) + properties.created_from = AAZStrType( + serialized_name="createdFrom", + flags={"read_only": True}, + ) + properties.description = AAZStrType() + properties.parameters = AAZListType( + flags={"required": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.recommendation = AAZObjectType( + flags={"read_only": True}, + ) + properties.version = AAZStrType( + flags={"read_only": True}, + ) + + actions = cls._schema_on_200.value.Element.properties.actions + actions.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element.properties.actions.Element + _element.action_id = AAZStrType( + serialized_name="actionId", + flags={"required": True}, + ) + _element.description = AAZStrType() + _element.duration = AAZStrType( + flags={"required": True}, + ) + _element.external_resource = AAZObjectType( + serialized_name="externalResource", + ) + _element.name = AAZStrType( + flags={"required": True}, + ) + _element.parameters = AAZListType() + _element.run_after = AAZObjectType( + serialized_name="runAfter", + ) + _element.timeout = AAZStrType() + _element.wait_before = AAZStrType( + serialized_name="waitBefore", + ) + + external_resource = cls._schema_on_200.value.Element.properties.actions.Element.external_resource + external_resource.resource_id = AAZStrType( + serialized_name="resourceId", + ) + + parameters = cls._schema_on_200.value.Element.properties.actions.Element.parameters + parameters.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element.properties.actions.Element.parameters.Element + _element.key = AAZStrType( + flags={"required": True}, + ) + _element.value = AAZStrType( + flags={"required": True}, + ) + + run_after = cls._schema_on_200.value.Element.properties.actions.Element.run_after + run_after.behavior = AAZStrType() + run_after.items = AAZListType( + flags={"required": True}, + ) + + items = cls._schema_on_200.value.Element.properties.actions.Element.run_after.items + items.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element.properties.actions.Element.run_after.items.Element + _element.name = AAZStrType( + flags={"required": True}, + ) + _element.on_action_lifecycle = AAZStrType( + serialized_name="onActionLifecycle", + ) + _element.type = AAZStrType( + flags={"required": True}, + ) + + parameters = cls._schema_on_200.value.Element.properties.parameters + parameters.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element.properties.parameters.Element + _element.default = AAZStrType() + _element.description = AAZStrType() + _element.name = AAZStrType( + flags={"required": True}, + ) + _element.required = AAZBoolType() + _element.type = AAZStrType( + flags={"required": True}, + ) + + recommendation = cls._schema_on_200.value.Element.properties.recommendation + recommendation.evaluation_run_at = AAZStrType( + serialized_name="evaluationRunAt", + flags={"read_only": True}, + ) + recommendation.recommendation_status = AAZStrType( + serialized_name="recommendationStatus", + flags={"read_only": True}, + ) + + system_data = cls._schema_on_200.value.Element.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + return cls._schema_on_200 + + +class _ListHelper: + """Helper class for List""" + + +__all__ = ["List"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/_show.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/_show.py new file mode 100644 index 00000000000..a84d9d510e1 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/_show.py @@ -0,0 +1,327 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos scenario show", + is_preview=True, +) +class Show(AAZCommand): + """Get a scenario. + + :example: Get a scenario. + az chaos scenario show --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name zoneDownScenario + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/scenarios/{}", "2026-05-01-preview"], + ] + } + + def _handler(self, command_args): + super()._handler(command_args) + self._execute_operations() + return self._output() + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.scenario_name = AAZStrArg( + options=["-n", "--name", "--scenario-name"], + help="Name of the scenario.", + required=True, + id_part="child_name_1", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.ScenariosGet(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) + return result + + class ScenariosGet(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios/{scenarioName}", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "scenarioName", self.ctx.args.scenario_name, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.id = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.name = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.properties = AAZObjectType() + _schema_on_200.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _schema_on_200.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.properties + properties.actions = AAZListType( + flags={"required": True}, + ) + properties.created_from = AAZStrType( + serialized_name="createdFrom", + flags={"read_only": True}, + ) + properties.description = AAZStrType() + properties.parameters = AAZListType( + flags={"required": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.recommendation = AAZObjectType( + flags={"read_only": True}, + ) + properties.version = AAZStrType( + flags={"read_only": True}, + ) + + actions = cls._schema_on_200.properties.actions + actions.Element = AAZObjectType() + + _element = cls._schema_on_200.properties.actions.Element + _element.action_id = AAZStrType( + serialized_name="actionId", + flags={"required": True}, + ) + _element.description = AAZStrType() + _element.duration = AAZStrType( + flags={"required": True}, + ) + _element.external_resource = AAZObjectType( + serialized_name="externalResource", + ) + _element.name = AAZStrType( + flags={"required": True}, + ) + _element.parameters = AAZListType() + _element.run_after = AAZObjectType( + serialized_name="runAfter", + ) + _element.timeout = AAZStrType() + _element.wait_before = AAZStrType( + serialized_name="waitBefore", + ) + + external_resource = cls._schema_on_200.properties.actions.Element.external_resource + external_resource.resource_id = AAZStrType( + serialized_name="resourceId", + ) + + parameters = cls._schema_on_200.properties.actions.Element.parameters + parameters.Element = AAZObjectType() + + _element = cls._schema_on_200.properties.actions.Element.parameters.Element + _element.key = AAZStrType( + flags={"required": True}, + ) + _element.value = AAZStrType( + flags={"required": True}, + ) + + run_after = cls._schema_on_200.properties.actions.Element.run_after + run_after.behavior = AAZStrType() + run_after.items = AAZListType( + flags={"required": True}, + ) + + items = cls._schema_on_200.properties.actions.Element.run_after.items + items.Element = AAZObjectType() + + _element = cls._schema_on_200.properties.actions.Element.run_after.items.Element + _element.name = AAZStrType( + flags={"required": True}, + ) + _element.on_action_lifecycle = AAZStrType( + serialized_name="onActionLifecycle", + ) + _element.type = AAZStrType( + flags={"required": True}, + ) + + parameters = cls._schema_on_200.properties.parameters + parameters.Element = AAZObjectType() + + _element = cls._schema_on_200.properties.parameters.Element + _element.default = AAZStrType() + _element.description = AAZStrType() + _element.name = AAZStrType( + flags={"required": True}, + ) + _element.required = AAZBoolType() + _element.type = AAZStrType( + flags={"required": True}, + ) + + recommendation = cls._schema_on_200.properties.recommendation + recommendation.evaluation_run_at = AAZStrType( + serialized_name="evaluationRunAt", + flags={"read_only": True}, + ) + recommendation.recommendation_status = AAZStrType( + serialized_name="recommendationStatus", + flags={"read_only": True}, + ) + + system_data = cls._schema_on_200.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + return cls._schema_on_200 + + +class _ShowHelper: + """Helper class for Show""" + + +__all__ = ["Show"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/_update.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/_update.py new file mode 100644 index 00000000000..58cfddd612b --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/_update.py @@ -0,0 +1,721 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos scenario update", + is_preview=True, +) +class Update(AAZCommand): + """Update a scenario. + + :example: Update a scenario. + az chaos scenario update --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name zoneDownScenario + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/scenarios/{}", "2026-05-01-preview"], + ] + } + + AZ_SUPPORT_GENERIC_UPDATE = True + + def _handler(self, command_args): + super()._handler(command_args) + self._execute_operations() + return self._output() + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.scenario_name = AAZStrArg( + options=["-n", "--name", "--scenario-name"], + help="Name of the scenario.", + required=True, + id_part="child_name_1", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + + # define Arg Group "Properties" + + _args_schema = cls._args_schema + _args_schema.actions = AAZListArg( + options=["--actions"], + arg_group="Properties", + help="Array of actions that define the scenario's orchestration.", + fmt=AAZListArgFormat( + min_length=1, + ), + ) + _args_schema.description = AAZStrArg( + options=["--description"], + arg_group="Properties", + help="Description of what this scenario does (optional).", + nullable=True, + ) + _args_schema.parameters = AAZListArg( + options=["--parameters"], + arg_group="Properties", + help="Parameter definitions for the scenario.", + ) + + actions = cls._args_schema.actions + actions.Element = AAZObjectArg( + nullable=True, + ) + + _element = cls._args_schema.actions.Element + _element.action_id = AAZStrArg( + options=["action-id"], + help="Identifier of the action and version (e.g., \"microsoft-compute-shutdown/1.0\").", + ) + _element.description = AAZStrArg( + options=["description"], + help="Human-readable description of what this action does.", + nullable=True, + ) + _element.duration = AAZStrArg( + options=["duration"], + help="ISO 8601 duration for how long the action runs (e.g., PT30M for 30 minutes). Supports template macro syntax (%%\\{parameters.\\\\}%%).", + ) + _element.external_resource = AAZObjectArg( + options=["external-resource"], + help="External resource reference for the action.", + nullable=True, + ) + _element.name = AAZStrArg( + options=["name"], + help="Unique name for the action.", + fmt=AAZStrArgFormat( + pattern="^[a-zA-Z0-9-_]+$", + ), + ) + _element.parameters = AAZListArg( + options=["parameters"], + help="Action-specific parameter values.", + nullable=True, + ) + _element.run_after = AAZObjectArg( + options=["run-after"], + help="Action dependencies that control when this action starts.", + nullable=True, + ) + _element.timeout = AAZStrArg( + options=["timeout"], + help="ISO 8601 duration for maximum action execution time. Supports template macro syntax.", + nullable=True, + ) + _element.wait_before = AAZStrArg( + options=["wait-before"], + help="ISO 8601 duration to wait before action starts (e.g., PT30S for 30 seconds). Supports template macro syntax.", + nullable=True, + ) + + external_resource = cls._args_schema.actions.Element.external_resource + external_resource.resource_id = AAZResourceIdArg( + options=["resource-id"], + help="The resource ID of the external resource.", + nullable=True, + ) + + parameters = cls._args_schema.actions.Element.parameters + parameters.Element = AAZObjectArg( + nullable=True, + ) + + _element = cls._args_schema.actions.Element.parameters.Element + _element.key = AAZStrArg( + options=["key"], + help="The name of the setting for the action.", + fmt=AAZStrArgFormat( + min_length=1, + ), + ) + _element.value = AAZStrArg( + options=["value"], + help="The value of the setting for the action.", + fmt=AAZStrArgFormat( + min_length=1, + ), + ) + + run_after = cls._args_schema.actions.Element.run_after + run_after.behavior = AAZStrArg( + options=["behavior"], + help="Defines how multiple dependencies are evaluated.", + nullable=True, + enum={"All": "All", "Any": "Any", "AtLeastOne": "AtLeastOne"}, + ) + run_after.items = AAZListArg( + options=["items"], + help="Array of action dependencies.", + fmt=AAZListArgFormat( + min_length=1, + ), + ) + + items = cls._args_schema.actions.Element.run_after.items + items.Element = AAZObjectArg( + nullable=True, + ) + + _element = cls._args_schema.actions.Element.run_after.items.Element + _element.name = AAZStrArg( + options=["name"], + help="Name of the action this depends on.", + ) + _element.on_action_lifecycle = AAZStrArg( + options=["on-action-lifecycle"], + help="The lifecycle state of the dependency action that triggers this action to start.", + nullable=True, + enum={"AnyTerminal": "AnyTerminal", "Failure": "Failure", "Running": "Running", "Skipped": "Skipped", "Start": "Start", "Success": "Success"}, + ) + _element.type = AAZStrArg( + options=["type"], + help="The type of dependency.", + enum={"Action": "Action"}, + ) + + parameters = cls._args_schema.parameters + parameters.Element = AAZObjectArg( + nullable=True, + ) + + _element = cls._args_schema.parameters.Element + _element.default = AAZStrArg( + options=["default"], + help="Default value for the parameter.", + nullable=True, + ) + _element.description = AAZStrArg( + options=["description"], + help="Description of the parameter.", + nullable=True, + ) + _element.name = AAZStrArg( + options=["name"], + help="The name of the parameter.", + fmt=AAZStrArgFormat( + min_length=1, + ), + ) + _element.required = AAZBoolArg( + options=["required"], + help="Whether this parameter is required.", + nullable=True, + ) + _element.type = AAZStrArg( + options=["type"], + help="Parameter data type.", + enum={"array": "array", "boolean": "boolean", "number": "number", "object": "object", "string": "string"}, + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.ScenariosGet(ctx=self.ctx)() + self.pre_instance_update(self.ctx.vars.instance) + self.InstanceUpdateByJson(ctx=self.ctx)() + self.InstanceUpdateByGeneric(ctx=self.ctx)() + self.post_instance_update(self.ctx.vars.instance) + self.ScenariosCreateOrUpdate(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + @register_callback + def pre_instance_update(self, instance): + pass + + @register_callback + def post_instance_update(self, instance): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) + return result + + class ScenariosGet(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios/{scenarioName}", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "scenarioName", self.ctx.args.scenario_name, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + _UpdateHelper._build_schema_scenario_read(cls._schema_on_200) + + return cls._schema_on_200 + + class ScenariosCreateOrUpdate(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200, 201]: + return self.on_200_201(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios/{scenarioName}", + **self.url_parameters + ) + + @property + def method(self): + return "PUT" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "scenarioName", self.ctx.args.scenario_name, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Content-Type", "application/json", + ), + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + @property + def content(self): + _content_value, _builder = self.new_content_builder( + self.ctx.args, + value=self.ctx.vars.instance, + ) + + return self.serialize_content(_content_value) + + def on_200_201(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200_201 + ) + + _schema_on_200_201 = None + + @classmethod + def _build_schema_on_200_201(cls): + if cls._schema_on_200_201 is not None: + return cls._schema_on_200_201 + + cls._schema_on_200_201 = AAZObjectType() + _UpdateHelper._build_schema_scenario_read(cls._schema_on_200_201) + + return cls._schema_on_200_201 + + class InstanceUpdateByJson(AAZJsonInstanceUpdateOperation): + + def __call__(self, *args, **kwargs): + self._update_instance(self.ctx.vars.instance) + + def _update_instance(self, instance): + _instance_value, _builder = self.new_content_builder( + self.ctx.args, + value=instance, + typ=AAZObjectType + ) + _builder.set_prop("properties", AAZObjectType) + + properties = _builder.get(".properties") + if properties is not None: + properties.set_prop("actions", AAZListType, ".actions", typ_kwargs={"flags": {"required": True}}) + properties.set_prop("description", AAZStrType, ".description") + properties.set_prop("parameters", AAZListType, ".parameters", typ_kwargs={"flags": {"required": True}}) + + actions = _builder.get(".properties.actions") + if actions is not None: + actions.set_elements(AAZObjectType, ".") + + _elements = _builder.get(".properties.actions[]") + if _elements is not None: + _elements.set_prop("actionId", AAZStrType, ".action_id", typ_kwargs={"flags": {"required": True}}) + _elements.set_prop("description", AAZStrType, ".description") + _elements.set_prop("duration", AAZStrType, ".duration", typ_kwargs={"flags": {"required": True}}) + _elements.set_prop("externalResource", AAZObjectType, ".external_resource") + _elements.set_prop("name", AAZStrType, ".name", typ_kwargs={"flags": {"required": True}}) + _elements.set_prop("parameters", AAZListType, ".parameters") + _elements.set_prop("runAfter", AAZObjectType, ".run_after") + _elements.set_prop("timeout", AAZStrType, ".timeout") + _elements.set_prop("waitBefore", AAZStrType, ".wait_before") + + external_resource = _builder.get(".properties.actions[].externalResource") + if external_resource is not None: + external_resource.set_prop("resourceId", AAZStrType, ".resource_id") + + parameters = _builder.get(".properties.actions[].parameters") + if parameters is not None: + parameters.set_elements(AAZObjectType, ".") + + _elements = _builder.get(".properties.actions[].parameters[]") + if _elements is not None: + _elements.set_prop("key", AAZStrType, ".key", typ_kwargs={"flags": {"required": True}}) + _elements.set_prop("value", AAZStrType, ".value", typ_kwargs={"flags": {"required": True}}) + + run_after = _builder.get(".properties.actions[].runAfter") + if run_after is not None: + run_after.set_prop("behavior", AAZStrType, ".behavior") + run_after.set_prop("items", AAZListType, ".items", typ_kwargs={"flags": {"required": True}}) + + items = _builder.get(".properties.actions[].runAfter.items") + if items is not None: + items.set_elements(AAZObjectType, ".") + + _elements = _builder.get(".properties.actions[].runAfter.items[]") + if _elements is not None: + _elements.set_prop("name", AAZStrType, ".name", typ_kwargs={"flags": {"required": True}}) + _elements.set_prop("onActionLifecycle", AAZStrType, ".on_action_lifecycle") + _elements.set_prop("type", AAZStrType, ".type", typ_kwargs={"flags": {"required": True}}) + + parameters = _builder.get(".properties.parameters") + if parameters is not None: + parameters.set_elements(AAZObjectType, ".") + + _elements = _builder.get(".properties.parameters[]") + if _elements is not None: + _elements.set_prop("default", AAZStrType, ".default") + _elements.set_prop("description", AAZStrType, ".description") + _elements.set_prop("name", AAZStrType, ".name", typ_kwargs={"flags": {"required": True}}) + _elements.set_prop("required", AAZBoolType, ".required") + _elements.set_prop("type", AAZStrType, ".type", typ_kwargs={"flags": {"required": True}}) + + return _instance_value + + class InstanceUpdateByGeneric(AAZGenericInstanceUpdateOperation): + + def __call__(self, *args, **kwargs): + self._update_instance_by_generic( + self.ctx.vars.instance, + self.ctx.generic_update_args + ) + + +class _UpdateHelper: + """Helper class for Update""" + + _schema_scenario_read = None + + @classmethod + def _build_schema_scenario_read(cls, _schema): + if cls._schema_scenario_read is not None: + _schema.id = cls._schema_scenario_read.id + _schema.name = cls._schema_scenario_read.name + _schema.properties = cls._schema_scenario_read.properties + _schema.system_data = cls._schema_scenario_read.system_data + _schema.type = cls._schema_scenario_read.type + return + + cls._schema_scenario_read = _schema_scenario_read = AAZObjectType() + + scenario_read = _schema_scenario_read + scenario_read.id = AAZStrType( + flags={"read_only": True}, + ) + scenario_read.name = AAZStrType( + flags={"read_only": True}, + ) + scenario_read.properties = AAZObjectType() + scenario_read.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + scenario_read.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = _schema_scenario_read.properties + properties.actions = AAZListType( + flags={"required": True}, + ) + properties.created_from = AAZStrType( + serialized_name="createdFrom", + flags={"read_only": True}, + ) + properties.description = AAZStrType() + properties.parameters = AAZListType( + flags={"required": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.recommendation = AAZObjectType( + flags={"read_only": True}, + ) + properties.version = AAZStrType( + flags={"read_only": True}, + ) + + actions = _schema_scenario_read.properties.actions + actions.Element = AAZObjectType() + + _element = _schema_scenario_read.properties.actions.Element + _element.action_id = AAZStrType( + serialized_name="actionId", + flags={"required": True}, + ) + _element.description = AAZStrType() + _element.duration = AAZStrType( + flags={"required": True}, + ) + _element.external_resource = AAZObjectType( + serialized_name="externalResource", + ) + _element.name = AAZStrType( + flags={"required": True}, + ) + _element.parameters = AAZListType() + _element.run_after = AAZObjectType( + serialized_name="runAfter", + ) + _element.timeout = AAZStrType() + _element.wait_before = AAZStrType( + serialized_name="waitBefore", + ) + + external_resource = _schema_scenario_read.properties.actions.Element.external_resource + external_resource.resource_id = AAZStrType( + serialized_name="resourceId", + ) + + parameters = _schema_scenario_read.properties.actions.Element.parameters + parameters.Element = AAZObjectType() + + _element = _schema_scenario_read.properties.actions.Element.parameters.Element + _element.key = AAZStrType( + flags={"required": True}, + ) + _element.value = AAZStrType( + flags={"required": True}, + ) + + run_after = _schema_scenario_read.properties.actions.Element.run_after + run_after.behavior = AAZStrType() + run_after.items = AAZListType( + flags={"required": True}, + ) + + items = _schema_scenario_read.properties.actions.Element.run_after.items + items.Element = AAZObjectType() + + _element = _schema_scenario_read.properties.actions.Element.run_after.items.Element + _element.name = AAZStrType( + flags={"required": True}, + ) + _element.on_action_lifecycle = AAZStrType( + serialized_name="onActionLifecycle", + ) + _element.type = AAZStrType( + flags={"required": True}, + ) + + parameters = _schema_scenario_read.properties.parameters + parameters.Element = AAZObjectType() + + _element = _schema_scenario_read.properties.parameters.Element + _element.default = AAZStrType() + _element.description = AAZStrType() + _element.name = AAZStrType( + flags={"required": True}, + ) + _element.required = AAZBoolType() + _element.type = AAZStrType( + flags={"required": True}, + ) + + recommendation = _schema_scenario_read.properties.recommendation + recommendation.evaluation_run_at = AAZStrType( + serialized_name="evaluationRunAt", + flags={"read_only": True}, + ) + recommendation.recommendation_status = AAZStrType( + serialized_name="recommendationStatus", + flags={"read_only": True}, + ) + + system_data = _schema_scenario_read.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + _schema.id = cls._schema_scenario_read.id + _schema.name = cls._schema_scenario_read.name + _schema.properties = cls._schema_scenario_read.properties + _schema.system_data = cls._schema_scenario_read.system_data + _schema.type = cls._schema_scenario_read.type + + +__all__ = ["Update"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/__cmd_group.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/__cmd_group.py new file mode 100644 index 00000000000..700c44e47d8 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/__cmd_group.py @@ -0,0 +1,24 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command_group( + "chaos scenario config", + is_preview=True, +) +class __CMDGroup(AAZCommandGroup): + """Manage Configuration + """ + pass + + +__all__ = ["__CMDGroup"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/__init__.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/__init__.py new file mode 100644 index 00000000000..1a4bb0923a1 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/__init__.py @@ -0,0 +1,20 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from .__cmd_group import * +from ._create import * +from ._delete import * +from ._execute import * +from ._fix_permissions import * +from ._list import * +from ._show import * +from ._update import * +from ._validate import * +from ._wait import * diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_create.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_create.py new file mode 100644 index 00000000000..10b358d477a --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_create.py @@ -0,0 +1,493 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos scenario config create", + is_preview=True, +) +class Create(AAZCommand): + """Create a scenario definition. + + :example: Create or update a scenario configuration with physical zone targeting. + az chaos scenario config create --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 --scenario-configuration-name my-config --exclusions "{resources:[/subscriptions/6b052e15-03d3-4f17-b2e1-be7f07588291/resourceGroups/exampleRG/providers/Microsoft.Compute/virtualMachines/protectedVM]}" --parameters "[{key:duration,value:PT10M}]" --filters "{locations:[westus2],physical-zones:[westus2-az1]}" + + :example: Create or update a scenario configuration with availability zone targeting. + az chaos scenario config create --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 --scenario-configuration-name my-config --filters "{locations:[eastus],zones:[1]}" --parameters "[{key:duration,value:PT10M}]" + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/scenarios/{}/configurations/{}", "2026-05-01-preview"], + ] + } + + AZ_SUPPORT_NO_WAIT = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_lro_poller(self._execute_operations, self._output) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.scenario_configuration_name = AAZStrArg( + options=["-n", "--name", "--scenario-configuration-name"], + help="Name of the scenario definition.", + required=True, + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.scenario_name = AAZStrArg( + options=["--scenario-name"], + help="Name of the scenario.", + required=True, + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + + # define Arg Group "Properties" + + _args_schema = cls._args_schema + _args_schema.exclusions = AAZObjectArg( + options=["--exclusions"], + arg_group="Properties", + help="Exclusion criteria for protecting resources from fault injection.", + ) + _args_schema.filters = AAZObjectArg( + options=["--filters"], + arg_group="Properties", + help="Filter criteria used to constrain which discovered resources participate in fault injection.", + ) + _args_schema.parameters = AAZListArg( + options=["--parameters"], + arg_group="Properties", + help="Runtime parameter values for the scenario. Keys must match parameter names defined in the scenario.", + ) + _args_schema.scenario_id = AAZResourceIdArg( + options=["--scenario-id"], + arg_group="Properties", + help="Resource ID of the scenario this configuration applies to.", + ) + + exclusions = cls._args_schema.exclusions + exclusions.resources = AAZListArg( + options=["resources"], + help="Array of specific resource IDs to exclude from fault injection.", + ) + exclusions.tags = AAZListArg( + options=["tags"], + help="Array of tag key-value pairs. Resources with matching tags are excluded.", + ) + exclusions.types = AAZListArg( + options=["types"], + help="Array of resource types. All resources of these types are excluded.", + ) + + resources = cls._args_schema.exclusions.resources + resources.Element = AAZResourceIdArg() + + tags = cls._args_schema.exclusions.tags + tags.Element = AAZObjectArg() + cls._build_args_key_value_pair_create(tags.Element) + + types = cls._args_schema.exclusions.types + types.Element = AAZStrArg() + + filters = cls._args_schema.filters + filters.locations = AAZListArg( + options=["locations"], + help="Array of Azure location strings. Only resources in these locations are included. Null or omitted means all locations (no filter). Empty array means include nothing.", + ) + filters.physical_zones = AAZListArg( + options=["physical-zones"], + help="Array of physical availability zone identifiers in `{region}-az{N}` format (e.g., `\"westus2-az1\"`). Only resources in the corresponding logical zone for each subscription are included. At execution time, each physical zone is resolved to per-subscription logical zones via the Azure locations API. The resolved mapping is surfaced on the scenario run response (`zoneResolution`). Null or omitted means physical zone targeting is not used. Only one physical zone is supported in preview. Mutually exclusive with `zones` — set one or the other, not both.", + ) + filters.zones = AAZListArg( + options=["zones"], + help="Array of availability zone identifiers (\"1\", \"2\", \"3\", \"zone-redundant\"). Only resources whose zones intersect this list are included. Null or omitted means all zones (including non-zonal). Empty array means include nothing. Mutually exclusive with `physicalZones` — set one or the other, not both.", + ) + + locations = cls._args_schema.filters.locations + locations.Element = AAZStrArg() + + physical_zones = cls._args_schema.filters.physical_zones + physical_zones.Element = AAZStrArg() + + zones = cls._args_schema.filters.zones + zones.Element = AAZStrArg() + + parameters = cls._args_schema.parameters + parameters.Element = AAZObjectArg() + cls._build_args_key_value_pair_create(parameters.Element) + return cls._args_schema + + _args_key_value_pair_create = None + + @classmethod + def _build_args_key_value_pair_create(cls, _schema): + if cls._args_key_value_pair_create is not None: + _schema.key = cls._args_key_value_pair_create.key + _schema.value = cls._args_key_value_pair_create.value + return + + cls._args_key_value_pair_create = AAZObjectArg() + + key_value_pair_create = cls._args_key_value_pair_create + key_value_pair_create.key = AAZStrArg( + options=["key"], + help="The name of the setting for the action.", + required=True, + fmt=AAZStrArgFormat( + min_length=1, + ), + ) + key_value_pair_create.value = AAZStrArg( + options=["value"], + help="The value of the setting for the action.", + required=True, + fmt=AAZStrArgFormat( + min_length=1, + ), + ) + + _schema.key = cls._args_key_value_pair_create.key + _schema.value = cls._args_key_value_pair_create.value + + def _execute_operations(self): + self.pre_operations() + yield self.ScenarioConfigurationsCreateOrUpdate(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) + return result + + class ScenarioConfigurationsCreateOrUpdate(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [202]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200_201, + self.on_error, + lro_options={"final-state-via": "azure-async-operation"}, + path_format_arguments=self.url_parameters, + ) + if session.http_response.status_code in [200, 201]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200_201, + self.on_error, + lro_options={"final-state-via": "azure-async-operation"}, + path_format_arguments=self.url_parameters, + ) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios/{scenarioName}/configurations/{scenarioConfigurationName}", + **self.url_parameters + ) + + @property + def method(self): + return "PUT" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "scenarioConfigurationName", self.ctx.args.scenario_configuration_name, + required=True, + ), + **self.serialize_url_param( + "scenarioName", self.ctx.args.scenario_name, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Content-Type", "application/json", + ), + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + @property + def content(self): + _content_value, _builder = self.new_content_builder( + self.ctx.args, + typ=AAZObjectType, + typ_kwargs={"flags": {"required": True, "client_flatten": True}} + ) + _builder.set_prop("properties", AAZObjectType) + + properties = _builder.get(".properties") + if properties is not None: + properties.set_prop("exclusions", AAZObjectType, ".exclusions") + properties.set_prop("filters", AAZObjectType, ".filters") + properties.set_prop("parameters", AAZListType, ".parameters") + properties.set_prop("scenarioId", AAZStrType, ".scenario_id", typ_kwargs={"flags": {"required": True}}) + + exclusions = _builder.get(".properties.exclusions") + if exclusions is not None: + exclusions.set_prop("resources", AAZListType, ".resources") + exclusions.set_prop("tags", AAZListType, ".tags") + exclusions.set_prop("types", AAZListType, ".types") + + resources = _builder.get(".properties.exclusions.resources") + if resources is not None: + resources.set_elements(AAZStrType, ".") + + tags = _builder.get(".properties.exclusions.tags") + if tags is not None: + _CreateHelper._build_schema_key_value_pair_create(tags.set_elements(AAZObjectType, ".")) + + types = _builder.get(".properties.exclusions.types") + if types is not None: + types.set_elements(AAZStrType, ".") + + filters = _builder.get(".properties.filters") + if filters is not None: + filters.set_prop("locations", AAZListType, ".locations") + filters.set_prop("physicalZones", AAZListType, ".physical_zones") + filters.set_prop("zones", AAZListType, ".zones") + + locations = _builder.get(".properties.filters.locations") + if locations is not None: + locations.set_elements(AAZStrType, ".") + + physical_zones = _builder.get(".properties.filters.physicalZones") + if physical_zones is not None: + physical_zones.set_elements(AAZStrType, ".") + + zones = _builder.get(".properties.filters.zones") + if zones is not None: + zones.set_elements(AAZStrType, ".") + + parameters = _builder.get(".properties.parameters") + if parameters is not None: + _CreateHelper._build_schema_key_value_pair_create(parameters.set_elements(AAZObjectType, ".")) + + return self.serialize_content(_content_value) + + def on_200_201(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200_201 + ) + + _schema_on_200_201 = None + + @classmethod + def _build_schema_on_200_201(cls): + if cls._schema_on_200_201 is not None: + return cls._schema_on_200_201 + + cls._schema_on_200_201 = AAZObjectType() + + _schema_on_200_201 = cls._schema_on_200_201 + _schema_on_200_201.id = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200_201.name = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200_201.properties = AAZObjectType() + _schema_on_200_201.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _schema_on_200_201.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200_201.properties + properties.exclusions = AAZObjectType() + properties.filters = AAZObjectType() + properties.parameters = AAZListType() + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.scenario_id = AAZStrType( + serialized_name="scenarioId", + flags={"required": True}, + ) + + exclusions = cls._schema_on_200_201.properties.exclusions + exclusions.resources = AAZListType() + exclusions.tags = AAZListType() + exclusions.types = AAZListType() + + resources = cls._schema_on_200_201.properties.exclusions.resources + resources.Element = AAZStrType() + + tags = cls._schema_on_200_201.properties.exclusions.tags + tags.Element = AAZObjectType() + _CreateHelper._build_schema_key_value_pair_read(tags.Element) + + types = cls._schema_on_200_201.properties.exclusions.types + types.Element = AAZStrType() + + filters = cls._schema_on_200_201.properties.filters + filters.locations = AAZListType() + filters.physical_zones = AAZListType( + serialized_name="physicalZones", + ) + filters.zones = AAZListType() + + locations = cls._schema_on_200_201.properties.filters.locations + locations.Element = AAZStrType() + + physical_zones = cls._schema_on_200_201.properties.filters.physical_zones + physical_zones.Element = AAZStrType() + + zones = cls._schema_on_200_201.properties.filters.zones + zones.Element = AAZStrType() + + parameters = cls._schema_on_200_201.properties.parameters + parameters.Element = AAZObjectType() + _CreateHelper._build_schema_key_value_pair_read(parameters.Element) + + system_data = cls._schema_on_200_201.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + return cls._schema_on_200_201 + + +class _CreateHelper: + """Helper class for Create""" + + @classmethod + def _build_schema_key_value_pair_create(cls, _builder): + if _builder is None: + return + _builder.set_prop("key", AAZStrType, ".key", typ_kwargs={"flags": {"required": True}}) + _builder.set_prop("value", AAZStrType, ".value", typ_kwargs={"flags": {"required": True}}) + + _schema_key_value_pair_read = None + + @classmethod + def _build_schema_key_value_pair_read(cls, _schema): + if cls._schema_key_value_pair_read is not None: + _schema.key = cls._schema_key_value_pair_read.key + _schema.value = cls._schema_key_value_pair_read.value + return + + cls._schema_key_value_pair_read = _schema_key_value_pair_read = AAZObjectType() + + key_value_pair_read = _schema_key_value_pair_read + key_value_pair_read.key = AAZStrType( + flags={"required": True}, + ) + key_value_pair_read.value = AAZStrType( + flags={"required": True}, + ) + + _schema.key = cls._schema_key_value_pair_read.key + _schema.value = cls._schema_key_value_pair_read.value + + +__all__ = ["Create"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_delete.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_delete.py new file mode 100644 index 00000000000..d3a580e69c9 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_delete.py @@ -0,0 +1,196 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos scenario config delete", + is_preview=True, + confirmation="Are you sure you want to perform this operation?", +) +class Delete(AAZCommand): + """Delete a scenario definition. + + :example: Delete a scenario configuration. + az chaos scenario config delete --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 --scenario-configuration-name my-config + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/scenarios/{}/configurations/{}", "2026-05-01-preview"], + ] + } + + AZ_SUPPORT_NO_WAIT = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_lro_poller(self._execute_operations, None) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.scenario_configuration_name = AAZStrArg( + options=["-n", "--name", "--scenario-configuration-name"], + help="Name of the scenario definition.", + required=True, + id_part="child_name_2", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.scenario_name = AAZStrArg( + options=["--scenario-name"], + help="Name of the scenario.", + required=True, + id_part="child_name_1", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + yield self.ScenarioConfigurationsDelete(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + class ScenarioConfigurationsDelete(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [202]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200_201, + self.on_error, + lro_options={"final-state-via": "location"}, + path_format_arguments=self.url_parameters, + ) + if session.http_response.status_code in [204]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_204, + self.on_error, + lro_options={"final-state-via": "location"}, + path_format_arguments=self.url_parameters, + ) + if session.http_response.status_code in [200, 201]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200_201, + self.on_error, + lro_options={"final-state-via": "location"}, + path_format_arguments=self.url_parameters, + ) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios/{scenarioName}/configurations/{scenarioConfigurationName}", + **self.url_parameters + ) + + @property + def method(self): + return "DELETE" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "scenarioConfigurationName", self.ctx.args.scenario_configuration_name, + required=True, + ), + **self.serialize_url_param( + "scenarioName", self.ctx.args.scenario_name, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + def on_204(self, session): + pass + + def on_200_201(self, session): + pass + + +class _DeleteHelper: + """Helper class for Delete""" + + +__all__ = ["Delete"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_execute.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_execute.py new file mode 100644 index 00000000000..d0ee93ebc50 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_execute.py @@ -0,0 +1,171 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos scenario config execute", + is_preview=True, +) +class Execute(AAZCommand): + """Execute the scenario execution with the given scenario configuration. + + :example: Execute the scenario execution with the given scenario configuration. + az chaos workspace scenario configuration execute --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name 12345678-1234-1234-1234-123456789012 --scenario-configuration-name config-5678-9012-3456-789012345678 + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/scenarios/{}/configurations/{}/execute", "2026-05-01-preview"], + ] + } + + AZ_SUPPORT_NO_WAIT = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_lro_poller(self._execute_operations, None) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.scenario_configuration_name = AAZStrArg( + options=["--scenario-configuration-name"], + help="Name of the scenario definition.", + required=True, + id_part="child_name_2", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.scenario_name = AAZStrArg( + options=["--scenario-name"], + help="Name of the scenario.", + required=True, + id_part="child_name_1", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + yield self.ScenarioConfigurationsExecute(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + class ScenarioConfigurationsExecute(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [202]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + None, + self.on_error, + lro_options={"final-state-via": "location"}, + path_format_arguments=self.url_parameters, + ) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios/{scenarioName}/configurations/{scenarioConfigurationName}/execute", + **self.url_parameters + ) + + @property + def method(self): + return "POST" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "scenarioConfigurationName", self.ctx.args.scenario_configuration_name, + required=True, + ), + **self.serialize_url_param( + "scenarioName", self.ctx.args.scenario_name, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + +class _ExecuteHelper: + """Helper class for Execute""" + + +__all__ = ["Execute"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_fix_permissions.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_fix_permissions.py new file mode 100644 index 00000000000..0cd8572bafe --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_fix_permissions.py @@ -0,0 +1,200 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos scenario config fix-permissions", + is_preview=True, +) +class FixPermissions(AAZCommand): + """Fixes resource permissions for the given scenario configuration. + + :example: Fix resource permissions required to run a scenario configuration. + az chaos scenario config fix-permissions --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 -n my-config + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/scenarios/{}/configurations/{}/fixresourcepermissions", "2026-05-01-preview"], + ] + } + + AZ_SUPPORT_NO_WAIT = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_lro_poller(self._execute_operations, None) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.scenario_configuration_name = AAZStrArg( + options=["-n", "--name", "--scenario-configuration-name"], + help="Name of the scenario definition.", + required=True, + id_part="child_name_2", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.scenario_name = AAZStrArg( + options=["--scenario-name"], + help="Name of the scenario.", + required=True, + id_part="child_name_1", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + + # define Arg Group "Body" + + _args_schema = cls._args_schema + _args_schema.what_if = AAZBoolArg( + options=["--what-if"], + arg_group="Body", + help="Optional value that indicates whether to run a \"dry run\" of fixing resource permissions.", + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + yield self.ScenarioConfigurationsFixResourcePermissions(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + class ScenarioConfigurationsFixResourcePermissions(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [202]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + None, + self.on_error, + lro_options={"final-state-via": "location"}, + path_format_arguments=self.url_parameters, + ) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios/{scenarioName}/configurations/{scenarioConfigurationName}/fixResourcePermissions", + **self.url_parameters + ) + + @property + def method(self): + return "POST" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "scenarioConfigurationName", self.ctx.args.scenario_configuration_name, + required=True, + ), + **self.serialize_url_param( + "scenarioName", self.ctx.args.scenario_name, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Content-Type", "application/json", + ), + } + return parameters + + @property + def content(self): + _content_value, _builder = self.new_content_builder( + self.ctx.args, + typ=AAZObjectType, + typ_kwargs={"flags": {"client_flatten": True}} + ) + _builder.set_prop("whatIf", AAZBoolType, ".what_if") + + return self.serialize_content(_content_value) + + +class _FixPermissionsHelper: + """Helper class for FixPermissions""" + + +__all__ = ["FixPermissions"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_list.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_list.py new file mode 100644 index 00000000000..437fed3f9d0 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_list.py @@ -0,0 +1,298 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos scenario config list", + is_preview=True, +) +class List(AAZCommand): + """List a list of scenario definitions. + + :example: List scenario configurations under a scenario. + az chaos scenario config list --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/scenarios/{}/configurations", "2026-05-01-preview"], + ] + } + + AZ_SUPPORT_PAGINATION = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_paging(self._execute_operations, self._output) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.scenario_name = AAZStrArg( + options=["--scenario-name"], + help="Name of the scenario.", + required=True, + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.ScenarioConfigurationsListAll(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance.value, client_flatten=True) + next_link = self.deserialize_output(self.ctx.vars.instance.next_link) + return result, next_link + + class ScenarioConfigurationsListAll(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios/{scenarioName}/configurations", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "scenarioName", self.ctx.args.scenario_name, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.next_link = AAZStrType( + serialized_name="nextLink", + ) + _schema_on_200.value = AAZListType( + flags={"required": True}, + ) + + value = cls._schema_on_200.value + value.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element + _element.id = AAZStrType( + flags={"read_only": True}, + ) + _element.name = AAZStrType( + flags={"read_only": True}, + ) + _element.properties = AAZObjectType() + _element.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _element.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.value.Element.properties + properties.exclusions = AAZObjectType() + properties.filters = AAZObjectType() + properties.parameters = AAZListType() + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.scenario_id = AAZStrType( + serialized_name="scenarioId", + flags={"required": True}, + ) + + exclusions = cls._schema_on_200.value.Element.properties.exclusions + exclusions.resources = AAZListType() + exclusions.tags = AAZListType() + exclusions.types = AAZListType() + + resources = cls._schema_on_200.value.Element.properties.exclusions.resources + resources.Element = AAZStrType() + + tags = cls._schema_on_200.value.Element.properties.exclusions.tags + tags.Element = AAZObjectType() + _ListHelper._build_schema_key_value_pair_read(tags.Element) + + types = cls._schema_on_200.value.Element.properties.exclusions.types + types.Element = AAZStrType() + + filters = cls._schema_on_200.value.Element.properties.filters + filters.locations = AAZListType() + filters.physical_zones = AAZListType( + serialized_name="physicalZones", + ) + filters.zones = AAZListType() + + locations = cls._schema_on_200.value.Element.properties.filters.locations + locations.Element = AAZStrType() + + physical_zones = cls._schema_on_200.value.Element.properties.filters.physical_zones + physical_zones.Element = AAZStrType() + + zones = cls._schema_on_200.value.Element.properties.filters.zones + zones.Element = AAZStrType() + + parameters = cls._schema_on_200.value.Element.properties.parameters + parameters.Element = AAZObjectType() + _ListHelper._build_schema_key_value_pair_read(parameters.Element) + + system_data = cls._schema_on_200.value.Element.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + return cls._schema_on_200 + + +class _ListHelper: + """Helper class for List""" + + _schema_key_value_pair_read = None + + @classmethod + def _build_schema_key_value_pair_read(cls, _schema): + if cls._schema_key_value_pair_read is not None: + _schema.key = cls._schema_key_value_pair_read.key + _schema.value = cls._schema_key_value_pair_read.value + return + + cls._schema_key_value_pair_read = _schema_key_value_pair_read = AAZObjectType() + + key_value_pair_read = _schema_key_value_pair_read + key_value_pair_read.key = AAZStrType( + flags={"required": True}, + ) + key_value_pair_read.value = AAZStrType( + flags={"required": True}, + ) + + _schema.key = cls._schema_key_value_pair_read.key + _schema.value = cls._schema_key_value_pair_read.value + + +__all__ = ["List"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_show.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_show.py new file mode 100644 index 00000000000..c8afeca73d0 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_show.py @@ -0,0 +1,301 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos scenario config show", + is_preview=True, +) +class Show(AAZCommand): + """Get a scenario definition. + + :example: Get a scenario configuration. + az chaos scenario config show --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 -n my-config + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/scenarios/{}/configurations/{}", "2026-05-01-preview"], + ] + } + + def _handler(self, command_args): + super()._handler(command_args) + self._execute_operations() + return self._output() + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.scenario_configuration_name = AAZStrArg( + options=["-n", "--name", "--scenario-configuration-name"], + help="Name of the scenario definition.", + required=True, + id_part="child_name_2", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.scenario_name = AAZStrArg( + options=["--scenario-name"], + help="Name of the scenario.", + required=True, + id_part="child_name_1", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.ScenarioConfigurationsGet(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) + return result + + class ScenarioConfigurationsGet(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios/{scenarioName}/configurations/{scenarioConfigurationName}", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "scenarioConfigurationName", self.ctx.args.scenario_configuration_name, + required=True, + ), + **self.serialize_url_param( + "scenarioName", self.ctx.args.scenario_name, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.id = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.name = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.properties = AAZObjectType() + _schema_on_200.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _schema_on_200.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.properties + properties.exclusions = AAZObjectType() + properties.filters = AAZObjectType() + properties.parameters = AAZListType() + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.scenario_id = AAZStrType( + serialized_name="scenarioId", + flags={"required": True}, + ) + + exclusions = cls._schema_on_200.properties.exclusions + exclusions.resources = AAZListType() + exclusions.tags = AAZListType() + exclusions.types = AAZListType() + + resources = cls._schema_on_200.properties.exclusions.resources + resources.Element = AAZStrType() + + tags = cls._schema_on_200.properties.exclusions.tags + tags.Element = AAZObjectType() + _ShowHelper._build_schema_key_value_pair_read(tags.Element) + + types = cls._schema_on_200.properties.exclusions.types + types.Element = AAZStrType() + + filters = cls._schema_on_200.properties.filters + filters.locations = AAZListType() + filters.physical_zones = AAZListType( + serialized_name="physicalZones", + ) + filters.zones = AAZListType() + + locations = cls._schema_on_200.properties.filters.locations + locations.Element = AAZStrType() + + physical_zones = cls._schema_on_200.properties.filters.physical_zones + physical_zones.Element = AAZStrType() + + zones = cls._schema_on_200.properties.filters.zones + zones.Element = AAZStrType() + + parameters = cls._schema_on_200.properties.parameters + parameters.Element = AAZObjectType() + _ShowHelper._build_schema_key_value_pair_read(parameters.Element) + + system_data = cls._schema_on_200.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + return cls._schema_on_200 + + +class _ShowHelper: + """Helper class for Show""" + + _schema_key_value_pair_read = None + + @classmethod + def _build_schema_key_value_pair_read(cls, _schema): + if cls._schema_key_value_pair_read is not None: + _schema.key = cls._schema_key_value_pair_read.key + _schema.value = cls._schema_key_value_pair_read.value + return + + cls._schema_key_value_pair_read = _schema_key_value_pair_read = AAZObjectType() + + key_value_pair_read = _schema_key_value_pair_read + key_value_pair_read.key = AAZStrType( + flags={"required": True}, + ) + key_value_pair_read.value = AAZStrType( + flags={"required": True}, + ) + + _schema.key = cls._schema_key_value_pair_read.key + _schema.value = cls._schema_key_value_pair_read.value + + +__all__ = ["Show"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_update.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_update.py new file mode 100644 index 00000000000..d4710a72a98 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_update.py @@ -0,0 +1,664 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos scenario config update", + is_preview=True, +) +class Update(AAZCommand): + """Update a scenario definition. + + :example: Update a scenario configuration. + az chaos scenario config update --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 --scenario-configuration-name my-config + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/scenarios/{}/configurations/{}", "2026-05-01-preview"], + ] + } + + AZ_SUPPORT_NO_WAIT = True + + AZ_SUPPORT_GENERIC_UPDATE = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_lro_poller(self._execute_operations, self._output) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.scenario_configuration_name = AAZStrArg( + options=["-n", "--name", "--scenario-configuration-name"], + help="Name of the scenario definition.", + required=True, + id_part="child_name_2", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.scenario_name = AAZStrArg( + options=["--scenario-name"], + help="Name of the scenario.", + required=True, + id_part="child_name_1", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + + # define Arg Group "Properties" + + _args_schema = cls._args_schema + _args_schema.exclusions = AAZObjectArg( + options=["--exclusions"], + arg_group="Properties", + help="Exclusion criteria for protecting resources from fault injection.", + nullable=True, + ) + _args_schema.filters = AAZObjectArg( + options=["--filters"], + arg_group="Properties", + help="Filter criteria used to constrain which discovered resources participate in fault injection.", + nullable=True, + ) + _args_schema.parameters = AAZListArg( + options=["--parameters"], + arg_group="Properties", + help="Runtime parameter values for the scenario. Keys must match parameter names defined in the scenario.", + nullable=True, + ) + _args_schema.scenario_id = AAZResourceIdArg( + options=["--scenario-id"], + arg_group="Properties", + help="Resource ID of the scenario this configuration applies to.", + ) + + exclusions = cls._args_schema.exclusions + exclusions.resources = AAZListArg( + options=["resources"], + help="Array of specific resource IDs to exclude from fault injection.", + nullable=True, + ) + exclusions.tags = AAZListArg( + options=["tags"], + help="Array of tag key-value pairs. Resources with matching tags are excluded.", + nullable=True, + ) + exclusions.types = AAZListArg( + options=["types"], + help="Array of resource types. All resources of these types are excluded.", + nullable=True, + ) + + resources = cls._args_schema.exclusions.resources + resources.Element = AAZResourceIdArg( + nullable=True, + ) + + tags = cls._args_schema.exclusions.tags + tags.Element = AAZObjectArg( + nullable=True, + ) + cls._build_args_key_value_pair_update(tags.Element) + + types = cls._args_schema.exclusions.types + types.Element = AAZStrArg( + nullable=True, + ) + + filters = cls._args_schema.filters + filters.locations = AAZListArg( + options=["locations"], + help="Array of Azure location strings. Only resources in these locations are included. Null or omitted means all locations (no filter). Empty array means include nothing.", + nullable=True, + ) + filters.physical_zones = AAZListArg( + options=["physical-zones"], + help="Array of physical availability zone identifiers in `{region}-az{N}` format (e.g., `\"westus2-az1\"`). Only resources in the corresponding logical zone for each subscription are included. At execution time, each physical zone is resolved to per-subscription logical zones via the Azure locations API. The resolved mapping is surfaced on the scenario run response (`zoneResolution`). Null or omitted means physical zone targeting is not used. Only one physical zone is supported in preview. Mutually exclusive with `zones` — set one or the other, not both.", + nullable=True, + ) + filters.zones = AAZListArg( + options=["zones"], + help="Array of availability zone identifiers (\"1\", \"2\", \"3\", \"zone-redundant\"). Only resources whose zones intersect this list are included. Null or omitted means all zones (including non-zonal). Empty array means include nothing. Mutually exclusive with `physicalZones` — set one or the other, not both.", + nullable=True, + ) + + locations = cls._args_schema.filters.locations + locations.Element = AAZStrArg( + nullable=True, + ) + + physical_zones = cls._args_schema.filters.physical_zones + physical_zones.Element = AAZStrArg( + nullable=True, + ) + + zones = cls._args_schema.filters.zones + zones.Element = AAZStrArg( + nullable=True, + ) + + parameters = cls._args_schema.parameters + parameters.Element = AAZObjectArg( + nullable=True, + ) + cls._build_args_key_value_pair_update(parameters.Element) + return cls._args_schema + + _args_key_value_pair_update = None + + @classmethod + def _build_args_key_value_pair_update(cls, _schema): + if cls._args_key_value_pair_update is not None: + _schema.key = cls._args_key_value_pair_update.key + _schema.value = cls._args_key_value_pair_update.value + return + + cls._args_key_value_pair_update = AAZObjectArg( + nullable=True, + ) + + key_value_pair_update = cls._args_key_value_pair_update + key_value_pair_update.key = AAZStrArg( + options=["key"], + help="The name of the setting for the action.", + fmt=AAZStrArgFormat( + min_length=1, + ), + ) + key_value_pair_update.value = AAZStrArg( + options=["value"], + help="The value of the setting for the action.", + fmt=AAZStrArgFormat( + min_length=1, + ), + ) + + _schema.key = cls._args_key_value_pair_update.key + _schema.value = cls._args_key_value_pair_update.value + + def _execute_operations(self): + self.pre_operations() + self.ScenarioConfigurationsGet(ctx=self.ctx)() + self.pre_instance_update(self.ctx.vars.instance) + self.InstanceUpdateByJson(ctx=self.ctx)() + self.InstanceUpdateByGeneric(ctx=self.ctx)() + self.post_instance_update(self.ctx.vars.instance) + yield self.ScenarioConfigurationsCreateOrUpdate(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + @register_callback + def pre_instance_update(self, instance): + pass + + @register_callback + def post_instance_update(self, instance): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) + return result + + class ScenarioConfigurationsGet(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios/{scenarioName}/configurations/{scenarioConfigurationName}", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "scenarioConfigurationName", self.ctx.args.scenario_configuration_name, + required=True, + ), + **self.serialize_url_param( + "scenarioName", self.ctx.args.scenario_name, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + _UpdateHelper._build_schema_scenario_configuration_read(cls._schema_on_200) + + return cls._schema_on_200 + + class ScenarioConfigurationsCreateOrUpdate(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [202]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200_201, + self.on_error, + lro_options={"final-state-via": "azure-async-operation"}, + path_format_arguments=self.url_parameters, + ) + if session.http_response.status_code in [200, 201]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200_201, + self.on_error, + lro_options={"final-state-via": "azure-async-operation"}, + path_format_arguments=self.url_parameters, + ) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios/{scenarioName}/configurations/{scenarioConfigurationName}", + **self.url_parameters + ) + + @property + def method(self): + return "PUT" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "scenarioConfigurationName", self.ctx.args.scenario_configuration_name, + required=True, + ), + **self.serialize_url_param( + "scenarioName", self.ctx.args.scenario_name, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Content-Type", "application/json", + ), + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + @property + def content(self): + _content_value, _builder = self.new_content_builder( + self.ctx.args, + value=self.ctx.vars.instance, + ) + + return self.serialize_content(_content_value) + + def on_200_201(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200_201 + ) + + _schema_on_200_201 = None + + @classmethod + def _build_schema_on_200_201(cls): + if cls._schema_on_200_201 is not None: + return cls._schema_on_200_201 + + cls._schema_on_200_201 = AAZObjectType() + _UpdateHelper._build_schema_scenario_configuration_read(cls._schema_on_200_201) + + return cls._schema_on_200_201 + + class InstanceUpdateByJson(AAZJsonInstanceUpdateOperation): + + def __call__(self, *args, **kwargs): + self._update_instance(self.ctx.vars.instance) + + def _update_instance(self, instance): + _instance_value, _builder = self.new_content_builder( + self.ctx.args, + value=instance, + typ=AAZObjectType + ) + _builder.set_prop("properties", AAZObjectType) + + properties = _builder.get(".properties") + if properties is not None: + properties.set_prop("exclusions", AAZObjectType, ".exclusions") + properties.set_prop("filters", AAZObjectType, ".filters") + properties.set_prop("parameters", AAZListType, ".parameters") + properties.set_prop("scenarioId", AAZStrType, ".scenario_id", typ_kwargs={"flags": {"required": True}}) + + exclusions = _builder.get(".properties.exclusions") + if exclusions is not None: + exclusions.set_prop("resources", AAZListType, ".resources") + exclusions.set_prop("tags", AAZListType, ".tags") + exclusions.set_prop("types", AAZListType, ".types") + + resources = _builder.get(".properties.exclusions.resources") + if resources is not None: + resources.set_elements(AAZStrType, ".") + + tags = _builder.get(".properties.exclusions.tags") + if tags is not None: + _UpdateHelper._build_schema_key_value_pair_update(tags.set_elements(AAZObjectType, ".")) + + types = _builder.get(".properties.exclusions.types") + if types is not None: + types.set_elements(AAZStrType, ".") + + filters = _builder.get(".properties.filters") + if filters is not None: + filters.set_prop("locations", AAZListType, ".locations") + filters.set_prop("physicalZones", AAZListType, ".physical_zones") + filters.set_prop("zones", AAZListType, ".zones") + + locations = _builder.get(".properties.filters.locations") + if locations is not None: + locations.set_elements(AAZStrType, ".") + + physical_zones = _builder.get(".properties.filters.physicalZones") + if physical_zones is not None: + physical_zones.set_elements(AAZStrType, ".") + + zones = _builder.get(".properties.filters.zones") + if zones is not None: + zones.set_elements(AAZStrType, ".") + + parameters = _builder.get(".properties.parameters") + if parameters is not None: + _UpdateHelper._build_schema_key_value_pair_update(parameters.set_elements(AAZObjectType, ".")) + + return _instance_value + + class InstanceUpdateByGeneric(AAZGenericInstanceUpdateOperation): + + def __call__(self, *args, **kwargs): + self._update_instance_by_generic( + self.ctx.vars.instance, + self.ctx.generic_update_args + ) + + +class _UpdateHelper: + """Helper class for Update""" + + @classmethod + def _build_schema_key_value_pair_update(cls, _builder): + if _builder is None: + return + _builder.set_prop("key", AAZStrType, ".key", typ_kwargs={"flags": {"required": True}}) + _builder.set_prop("value", AAZStrType, ".value", typ_kwargs={"flags": {"required": True}}) + + _schema_key_value_pair_read = None + + @classmethod + def _build_schema_key_value_pair_read(cls, _schema): + if cls._schema_key_value_pair_read is not None: + _schema.key = cls._schema_key_value_pair_read.key + _schema.value = cls._schema_key_value_pair_read.value + return + + cls._schema_key_value_pair_read = _schema_key_value_pair_read = AAZObjectType() + + key_value_pair_read = _schema_key_value_pair_read + key_value_pair_read.key = AAZStrType( + flags={"required": True}, + ) + key_value_pair_read.value = AAZStrType( + flags={"required": True}, + ) + + _schema.key = cls._schema_key_value_pair_read.key + _schema.value = cls._schema_key_value_pair_read.value + + _schema_scenario_configuration_read = None + + @classmethod + def _build_schema_scenario_configuration_read(cls, _schema): + if cls._schema_scenario_configuration_read is not None: + _schema.id = cls._schema_scenario_configuration_read.id + _schema.name = cls._schema_scenario_configuration_read.name + _schema.properties = cls._schema_scenario_configuration_read.properties + _schema.system_data = cls._schema_scenario_configuration_read.system_data + _schema.type = cls._schema_scenario_configuration_read.type + return + + cls._schema_scenario_configuration_read = _schema_scenario_configuration_read = AAZObjectType() + + scenario_configuration_read = _schema_scenario_configuration_read + scenario_configuration_read.id = AAZStrType( + flags={"read_only": True}, + ) + scenario_configuration_read.name = AAZStrType( + flags={"read_only": True}, + ) + scenario_configuration_read.properties = AAZObjectType() + scenario_configuration_read.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + scenario_configuration_read.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = _schema_scenario_configuration_read.properties + properties.exclusions = AAZObjectType() + properties.filters = AAZObjectType() + properties.parameters = AAZListType() + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.scenario_id = AAZStrType( + serialized_name="scenarioId", + flags={"required": True}, + ) + + exclusions = _schema_scenario_configuration_read.properties.exclusions + exclusions.resources = AAZListType() + exclusions.tags = AAZListType() + exclusions.types = AAZListType() + + resources = _schema_scenario_configuration_read.properties.exclusions.resources + resources.Element = AAZStrType() + + tags = _schema_scenario_configuration_read.properties.exclusions.tags + tags.Element = AAZObjectType() + cls._build_schema_key_value_pair_read(tags.Element) + + types = _schema_scenario_configuration_read.properties.exclusions.types + types.Element = AAZStrType() + + filters = _schema_scenario_configuration_read.properties.filters + filters.locations = AAZListType() + filters.physical_zones = AAZListType( + serialized_name="physicalZones", + ) + filters.zones = AAZListType() + + locations = _schema_scenario_configuration_read.properties.filters.locations + locations.Element = AAZStrType() + + physical_zones = _schema_scenario_configuration_read.properties.filters.physical_zones + physical_zones.Element = AAZStrType() + + zones = _schema_scenario_configuration_read.properties.filters.zones + zones.Element = AAZStrType() + + parameters = _schema_scenario_configuration_read.properties.parameters + parameters.Element = AAZObjectType() + cls._build_schema_key_value_pair_read(parameters.Element) + + system_data = _schema_scenario_configuration_read.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + _schema.id = cls._schema_scenario_configuration_read.id + _schema.name = cls._schema_scenario_configuration_read.name + _schema.properties = cls._schema_scenario_configuration_read.properties + _schema.system_data = cls._schema_scenario_configuration_read.system_data + _schema.type = cls._schema_scenario_configuration_read.type + + +__all__ = ["Update"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_validate.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_validate.py new file mode 100644 index 00000000000..2c44d5f16b5 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_validate.py @@ -0,0 +1,171 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos scenario config validate", + is_preview=True, +) +class Validate(AAZCommand): + """Validate the given scenario configuration. + + :example: Validate a scenario configuration. + az chaos scenario config validate --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 --scenario-configuration-name my-config + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/scenarios/{}/configurations/{}/validate", "2026-05-01-preview"], + ] + } + + AZ_SUPPORT_NO_WAIT = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_lro_poller(self._execute_operations, None) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.scenario_configuration_name = AAZStrArg( + options=["--scenario-configuration-name"], + help="Name of the scenario definition.", + required=True, + id_part="child_name_2", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.scenario_name = AAZStrArg( + options=["--scenario-name"], + help="Name of the scenario.", + required=True, + id_part="child_name_1", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + yield self.ScenarioConfigurationsValidate(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + class ScenarioConfigurationsValidate(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [202]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + None, + self.on_error, + lro_options={"final-state-via": "location"}, + path_format_arguments=self.url_parameters, + ) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios/{scenarioName}/configurations/{scenarioConfigurationName}/validate", + **self.url_parameters + ) + + @property + def method(self): + return "POST" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "scenarioConfigurationName", self.ctx.args.scenario_configuration_name, + required=True, + ), + **self.serialize_url_param( + "scenarioName", self.ctx.args.scenario_name, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + +class _ValidateHelper: + """Helper class for Validate""" + + +__all__ = ["Validate"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_wait.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_wait.py new file mode 100644 index 00000000000..03425d4ed14 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_wait.py @@ -0,0 +1,296 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos scenario config wait", +) +class Wait(AAZWaitCommand): + """Place the CLI in a waiting state until a condition is met. + """ + + _aaz_info = { + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/scenarios/{}/configurations/{}", "2026-05-01-preview"], + ] + } + + def _handler(self, command_args): + super()._handler(command_args) + self._execute_operations() + return self._output() + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.scenario_configuration_name = AAZStrArg( + options=["-n", "--name", "--scenario-configuration-name"], + help="Name of the scenario definition.", + required=True, + id_part="child_name_2", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.scenario_name = AAZStrArg( + options=["--scenario-name"], + help="Name of the scenario.", + required=True, + id_part="child_name_1", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.ScenarioConfigurationsGet(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=False) + return result + + class ScenarioConfigurationsGet(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios/{scenarioName}/configurations/{scenarioConfigurationName}", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "scenarioConfigurationName", self.ctx.args.scenario_configuration_name, + required=True, + ), + **self.serialize_url_param( + "scenarioName", self.ctx.args.scenario_name, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.id = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.name = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.properties = AAZObjectType() + _schema_on_200.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _schema_on_200.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.properties + properties.exclusions = AAZObjectType() + properties.filters = AAZObjectType() + properties.parameters = AAZListType() + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.scenario_id = AAZStrType( + serialized_name="scenarioId", + flags={"required": True}, + ) + + exclusions = cls._schema_on_200.properties.exclusions + exclusions.resources = AAZListType() + exclusions.tags = AAZListType() + exclusions.types = AAZListType() + + resources = cls._schema_on_200.properties.exclusions.resources + resources.Element = AAZStrType() + + tags = cls._schema_on_200.properties.exclusions.tags + tags.Element = AAZObjectType() + _WaitHelper._build_schema_key_value_pair_read(tags.Element) + + types = cls._schema_on_200.properties.exclusions.types + types.Element = AAZStrType() + + filters = cls._schema_on_200.properties.filters + filters.locations = AAZListType() + filters.physical_zones = AAZListType( + serialized_name="physicalZones", + ) + filters.zones = AAZListType() + + locations = cls._schema_on_200.properties.filters.locations + locations.Element = AAZStrType() + + physical_zones = cls._schema_on_200.properties.filters.physical_zones + physical_zones.Element = AAZStrType() + + zones = cls._schema_on_200.properties.filters.zones + zones.Element = AAZStrType() + + parameters = cls._schema_on_200.properties.parameters + parameters.Element = AAZObjectType() + _WaitHelper._build_schema_key_value_pair_read(parameters.Element) + + system_data = cls._schema_on_200.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + return cls._schema_on_200 + + +class _WaitHelper: + """Helper class for Wait""" + + _schema_key_value_pair_read = None + + @classmethod + def _build_schema_key_value_pair_read(cls, _schema): + if cls._schema_key_value_pair_read is not None: + _schema.key = cls._schema_key_value_pair_read.key + _schema.value = cls._schema_key_value_pair_read.value + return + + cls._schema_key_value_pair_read = _schema_key_value_pair_read = AAZObjectType() + + key_value_pair_read = _schema_key_value_pair_read + key_value_pair_read.key = AAZStrType( + flags={"required": True}, + ) + key_value_pair_read.value = AAZStrType( + flags={"required": True}, + ) + + _schema.key = cls._schema_key_value_pair_read.key + _schema.value = cls._schema_key_value_pair_read.value + + +__all__ = ["Wait"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/__cmd_group.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/__cmd_group.py new file mode 100644 index 00000000000..7e9dd6c9056 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/__cmd_group.py @@ -0,0 +1,24 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command_group( + "chaos scenario run", + is_preview=True, +) +class __CMDGroup(AAZCommandGroup): + """Manage Run + """ + pass + + +__all__ = ["__CMDGroup"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/__init__.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/__init__.py new file mode 100644 index 00000000000..289edf45a36 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/__init__.py @@ -0,0 +1,14 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from .__cmd_group import * +from ._cancel import * +from ._list import * +from ._show import * diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/_cancel.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/_cancel.py new file mode 100644 index 00000000000..4eacc2f739e --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/_cancel.py @@ -0,0 +1,170 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos scenario run cancel", + is_preview=True, +) +class Cancel(AAZCommand): + """Cancel the currently running scenario execution. + + :example: Cancel a running scenario run. + az chaos scenario run cancel --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 --run-id abcd1234-5678-9012-3456-789012345678 + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/scenarios/{}/runs/{}/cancel", "2026-05-01-preview"], + ] + } + + AZ_SUPPORT_NO_WAIT = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_lro_poller(self._execute_operations, None) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.run_id = AAZStrArg( + options=["--run-id"], + help="The name of the ScenarioRun", + required=True, + id_part="child_name_2", + fmt=AAZStrArgFormat( + pattern="^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + ), + ) + _args_schema.scenario_name = AAZStrArg( + options=["--scenario-name"], + help="Name of the scenario.", + required=True, + id_part="child_name_1", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + yield self.ScenarioRunsCancel(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + class ScenarioRunsCancel(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [202]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + None, + self.on_error, + lro_options={"final-state-via": "location"}, + path_format_arguments=self.url_parameters, + ) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios/{scenarioName}/runs/{runId}/cancel", + **self.url_parameters + ) + + @property + def method(self): + return "POST" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "runId", self.ctx.args.run_id, + required=True, + ), + **self.serialize_url_param( + "scenarioName", self.ctx.args.scenario_name, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + +class _CancelHelper: + """Helper class for Cancel""" + + +__all__ = ["Cancel"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/_list.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/_list.py new file mode 100644 index 00000000000..24730fcd31f --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/_list.py @@ -0,0 +1,460 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos scenario run list", + is_preview=True, +) +class List(AAZCommand): + """List a list of scenario runs. + + :example: List scenario runs under a scenario. + az chaos scenario run list --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/scenarios/{}/runs", "2026-05-01-preview"], + ] + } + + AZ_SUPPORT_PAGINATION = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_paging(self._execute_operations, self._output) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.scenario_name = AAZStrArg( + options=["--scenario-name"], + help="Name of the scenario.", + required=True, + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.ScenarioRunsListAll(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance.value, client_flatten=True) + next_link = self.deserialize_output(self.ctx.vars.instance.next_link) + return result, next_link + + class ScenarioRunsListAll(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios/{scenarioName}/runs", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "scenarioName", self.ctx.args.scenario_name, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.next_link = AAZStrType( + serialized_name="nextLink", + ) + _schema_on_200.value = AAZListType( + flags={"required": True}, + ) + + value = cls._schema_on_200.value + value.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element + _element.id = AAZStrType( + flags={"read_only": True}, + ) + _element.name = AAZStrType( + flags={"read_only": True}, + ) + _element.properties = AAZObjectType() + _element.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _element.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.value.Element.properties + properties.end_time = AAZStrType( + serialized_name="endTime", + flags={"read_only": True}, + ) + properties.errors = AAZListType( + flags={"read_only": True}, + ) + properties.execution_errors = AAZObjectType( + serialized_name="executionErrors", + flags={"read_only": True}, + ) + properties.managed_identity_principal_id = AAZStrType( + serialized_name="managedIdentityPrincipalId", + flags={"read_only": True}, + ) + properties.resources = AAZListType( + flags={"read_only": True}, + ) + properties.scenario_configuration_name = AAZStrType( + serialized_name="scenarioConfigurationName", + flags={"read_only": True}, + ) + properties.scenario_name = AAZStrType( + serialized_name="scenarioName", + flags={"read_only": True}, + ) + properties.scenario_run_json = AAZStrType( + serialized_name="scenarioRunJson", + flags={"read_only": True}, + ) + properties.scenario_run_summary = AAZListType( + serialized_name="scenarioRunSummary", + flags={"read_only": True}, + ) + properties.start_time = AAZStrType( + serialized_name="startTime", + flags={"read_only": True}, + ) + properties.status = AAZStrType( + flags={"read_only": True}, + ) + properties.workspace_name = AAZStrType( + serialized_name="workspaceName", + flags={"read_only": True}, + ) + properties.zone_resolution = AAZObjectType( + serialized_name="zoneResolution", + flags={"read_only": True}, + ) + + errors = cls._schema_on_200.value.Element.properties.errors + errors.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element.properties.errors.Element + _element.error_code = AAZStrType( + serialized_name="errorCode", + flags={"required": True}, + ) + _element.error_message = AAZStrType( + serialized_name="errorMessage", + flags={"required": True}, + ) + + execution_errors = cls._schema_on_200.value.Element.properties.execution_errors + execution_errors.error_code = AAZStrType( + serialized_name="errorCode", + ) + execution_errors.error_message = AAZStrType( + serialized_name="errorMessage", + ) + execution_errors.permission = AAZListType( + flags={"read_only": True}, + ) + execution_errors.resource = AAZListType( + flags={"read_only": True}, + ) + + permission = cls._schema_on_200.value.Element.properties.execution_errors.permission + permission.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element.properties.execution_errors.permission.Element + _element.identity = AAZObjectType( + flags={"read_only": True}, + ) + _element.missing_permissions = AAZListType( + serialized_name="missingPermissions", + flags={"read_only": True}, + ) + _element.recommended_roles = AAZListType( + serialized_name="recommendedRoles", + flags={"read_only": True}, + ) + _element.required_permissions = AAZListType( + serialized_name="requiredPermissions", + flags={"read_only": True}, + ) + _element.resource_id = AAZStrType( + serialized_name="resourceId", + flags={"read_only": True}, + ) + + identity = cls._schema_on_200.value.Element.properties.execution_errors.permission.Element.identity + identity.object_id = AAZStrType( + serialized_name="objectId", + flags={"read_only": True}, + ) + identity.tenant_id = AAZStrType( + serialized_name="tenantId", + flags={"read_only": True}, + ) + + missing_permissions = cls._schema_on_200.value.Element.properties.execution_errors.permission.Element.missing_permissions + missing_permissions.Element = AAZStrType() + + recommended_roles = cls._schema_on_200.value.Element.properties.execution_errors.permission.Element.recommended_roles + recommended_roles.Element = AAZStrType() + + required_permissions = cls._schema_on_200.value.Element.properties.execution_errors.permission.Element.required_permissions + required_permissions.Element = AAZStrType() + + resource = cls._schema_on_200.value.Element.properties.execution_errors.resource + resource.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element.properties.execution_errors.resource.Element + _element.error_code = AAZIntType( + serialized_name="errorCode", + flags={"read_only": True}, + ) + _element.error_message = AAZStrType( + serialized_name="errorMessage", + flags={"read_only": True}, + ) + _element.remediation_uri = AAZStrType( + serialized_name="remediationUri", + flags={"read_only": True}, + ) + _element.resource_id = AAZStrType( + serialized_name="resourceId", + flags={"read_only": True}, + ) + + resources = cls._schema_on_200.value.Element.properties.resources + resources.Element = AAZObjectType() + _ListHelper._build_schema_scenario_run_resource_read(resources.Element) + + scenario_run_summary = cls._schema_on_200.value.Element.properties.scenario_run_summary + scenario_run_summary.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element.properties.scenario_run_summary.Element + _element.action_urn = AAZStrType( + serialized_name="actionUrn", + flags={"read_only": True}, + ) + _element.completed_at = AAZStrType( + serialized_name="completedAt", + flags={"read_only": True}, + ) + _element.resources = AAZListType( + flags={"read_only": True}, + ) + _element.started_at = AAZStrType( + serialized_name="startedAt", + flags={"read_only": True}, + ) + _element.state = AAZStrType( + flags={"read_only": True}, + ) + + resources = cls._schema_on_200.value.Element.properties.scenario_run_summary.Element.resources + resources.Element = AAZObjectType() + _ListHelper._build_schema_scenario_run_resource_read(resources.Element) + + zone_resolution = cls._schema_on_200.value.Element.properties.zone_resolution + zone_resolution.mode = AAZStrType( + flags={"read_only": True}, + ) + zone_resolution.requested_physical_zones = AAZListType( + serialized_name="requestedPhysicalZones", + flags={"read_only": True}, + ) + zone_resolution.subscription_zone_mappings = AAZListType( + serialized_name="subscriptionZoneMappings", + flags={"read_only": True}, + ) + + requested_physical_zones = cls._schema_on_200.value.Element.properties.zone_resolution.requested_physical_zones + requested_physical_zones.Element = AAZStrType() + + subscription_zone_mappings = cls._schema_on_200.value.Element.properties.zone_resolution.subscription_zone_mappings + subscription_zone_mappings.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element.properties.zone_resolution.subscription_zone_mappings.Element + _element.subscription_id = AAZStrType( + serialized_name="subscriptionId", + flags={"read_only": True}, + ) + _element.zone_mappings = AAZListType( + serialized_name="zoneMappings", + flags={"read_only": True}, + ) + + zone_mappings = cls._schema_on_200.value.Element.properties.zone_resolution.subscription_zone_mappings.Element.zone_mappings + zone_mappings.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element.properties.zone_resolution.subscription_zone_mappings.Element.zone_mappings.Element + _element.logical_zone = AAZStrType( + serialized_name="logicalZone", + flags={"read_only": True}, + ) + _element.physical_zone = AAZStrType( + serialized_name="physicalZone", + flags={"read_only": True}, + ) + + system_data = cls._schema_on_200.value.Element.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + return cls._schema_on_200 + + +class _ListHelper: + """Helper class for List""" + + _schema_scenario_run_resource_read = None + + @classmethod + def _build_schema_scenario_run_resource_read(cls, _schema): + if cls._schema_scenario_run_resource_read is not None: + _schema.id = cls._schema_scenario_run_resource_read.id + return + + cls._schema_scenario_run_resource_read = _schema_scenario_run_resource_read = AAZObjectType() + + scenario_run_resource_read = _schema_scenario_run_resource_read + scenario_run_resource_read.id = AAZStrType( + flags={"read_only": True}, + ) + + _schema.id = cls._schema_scenario_run_resource_read.id + + +__all__ = ["List"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/_show.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/_show.py new file mode 100644 index 00000000000..f636ff14030 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/_show.py @@ -0,0 +1,511 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos scenario run show", + is_preview=True, +) +class Show(AAZCommand): + """Get a scenario run. + + This endpoint is also the polling target for ScenarioConfigurations.execute + and ScenarioRuns.cancel (final-state-via: location). While the run is in + progress the service returns 202 with a Location header pointing back to + this URL; clients must keep polling until they receive 200, which carries + the final ScenarioRun body. + + :example: Get a scenario run. + az chaos scenario run show --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 --run-id abcd1234-5678-9012-3456-789012345678 + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/scenarios/{}/runs/{}", "2026-05-01-preview"], + ] + } + + def _handler(self, command_args): + super()._handler(command_args) + self._execute_operations() + return self._output() + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.run_id = AAZStrArg( + options=["-n", "--name", "--run-id"], + help="The name of the ScenarioRun", + required=True, + id_part="child_name_2", + fmt=AAZStrArgFormat( + pattern="^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + ), + ) + _args_schema.scenario_name = AAZStrArg( + options=["--scenario-name"], + help="Name of the scenario.", + required=True, + id_part="child_name_1", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.ScenarioRunsGet(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) + return result + + class ScenarioRunsGet(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + if session.http_response.status_code in [202]: + return self.on_202(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios/{scenarioName}/runs/{runId}", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "runId", self.ctx.args.run_id, + required=True, + ), + **self.serialize_url_param( + "scenarioName", self.ctx.args.scenario_name, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + _ShowHelper._build_schema_scenario_run_read(cls._schema_on_200) + + return cls._schema_on_200 + + def on_202(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_202 + ) + + _schema_on_202 = None + + @classmethod + def _build_schema_on_202(cls): + if cls._schema_on_202 is not None: + return cls._schema_on_202 + + cls._schema_on_202 = AAZObjectType() + _ShowHelper._build_schema_scenario_run_read(cls._schema_on_202) + + return cls._schema_on_202 + + +class _ShowHelper: + """Helper class for Show""" + + _schema_scenario_run_resource_read = None + + @classmethod + def _build_schema_scenario_run_resource_read(cls, _schema): + if cls._schema_scenario_run_resource_read is not None: + _schema.id = cls._schema_scenario_run_resource_read.id + return + + cls._schema_scenario_run_resource_read = _schema_scenario_run_resource_read = AAZObjectType() + + scenario_run_resource_read = _schema_scenario_run_resource_read + scenario_run_resource_read.id = AAZStrType( + flags={"read_only": True}, + ) + + _schema.id = cls._schema_scenario_run_resource_read.id + + _schema_scenario_run_read = None + + @classmethod + def _build_schema_scenario_run_read(cls, _schema): + if cls._schema_scenario_run_read is not None: + _schema.id = cls._schema_scenario_run_read.id + _schema.name = cls._schema_scenario_run_read.name + _schema.properties = cls._schema_scenario_run_read.properties + _schema.system_data = cls._schema_scenario_run_read.system_data + _schema.type = cls._schema_scenario_run_read.type + return + + cls._schema_scenario_run_read = _schema_scenario_run_read = AAZObjectType() + + scenario_run_read = _schema_scenario_run_read + scenario_run_read.id = AAZStrType( + flags={"read_only": True}, + ) + scenario_run_read.name = AAZStrType( + flags={"read_only": True}, + ) + scenario_run_read.properties = AAZObjectType() + scenario_run_read.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + scenario_run_read.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = _schema_scenario_run_read.properties + properties.end_time = AAZStrType( + serialized_name="endTime", + flags={"read_only": True}, + ) + properties.errors = AAZListType( + flags={"read_only": True}, + ) + properties.execution_errors = AAZObjectType( + serialized_name="executionErrors", + flags={"read_only": True}, + ) + properties.managed_identity_principal_id = AAZStrType( + serialized_name="managedIdentityPrincipalId", + flags={"read_only": True}, + ) + properties.resources = AAZListType( + flags={"read_only": True}, + ) + properties.scenario_configuration_name = AAZStrType( + serialized_name="scenarioConfigurationName", + flags={"read_only": True}, + ) + properties.scenario_name = AAZStrType( + serialized_name="scenarioName", + flags={"read_only": True}, + ) + properties.scenario_run_json = AAZStrType( + serialized_name="scenarioRunJson", + flags={"read_only": True}, + ) + properties.scenario_run_summary = AAZListType( + serialized_name="scenarioRunSummary", + flags={"read_only": True}, + ) + properties.start_time = AAZStrType( + serialized_name="startTime", + flags={"read_only": True}, + ) + properties.status = AAZStrType( + flags={"read_only": True}, + ) + properties.workspace_name = AAZStrType( + serialized_name="workspaceName", + flags={"read_only": True}, + ) + properties.zone_resolution = AAZObjectType( + serialized_name="zoneResolution", + flags={"read_only": True}, + ) + + errors = _schema_scenario_run_read.properties.errors + errors.Element = AAZObjectType() + + _element = _schema_scenario_run_read.properties.errors.Element + _element.error_code = AAZStrType( + serialized_name="errorCode", + flags={"required": True}, + ) + _element.error_message = AAZStrType( + serialized_name="errorMessage", + flags={"required": True}, + ) + + execution_errors = _schema_scenario_run_read.properties.execution_errors + execution_errors.error_code = AAZStrType( + serialized_name="errorCode", + ) + execution_errors.error_message = AAZStrType( + serialized_name="errorMessage", + ) + execution_errors.permission = AAZListType( + flags={"read_only": True}, + ) + execution_errors.resource = AAZListType( + flags={"read_only": True}, + ) + + permission = _schema_scenario_run_read.properties.execution_errors.permission + permission.Element = AAZObjectType() + + _element = _schema_scenario_run_read.properties.execution_errors.permission.Element + _element.identity = AAZObjectType( + flags={"read_only": True}, + ) + _element.missing_permissions = AAZListType( + serialized_name="missingPermissions", + flags={"read_only": True}, + ) + _element.recommended_roles = AAZListType( + serialized_name="recommendedRoles", + flags={"read_only": True}, + ) + _element.required_permissions = AAZListType( + serialized_name="requiredPermissions", + flags={"read_only": True}, + ) + _element.resource_id = AAZStrType( + serialized_name="resourceId", + flags={"read_only": True}, + ) + + identity = _schema_scenario_run_read.properties.execution_errors.permission.Element.identity + identity.object_id = AAZStrType( + serialized_name="objectId", + flags={"read_only": True}, + ) + identity.tenant_id = AAZStrType( + serialized_name="tenantId", + flags={"read_only": True}, + ) + + missing_permissions = _schema_scenario_run_read.properties.execution_errors.permission.Element.missing_permissions + missing_permissions.Element = AAZStrType() + + recommended_roles = _schema_scenario_run_read.properties.execution_errors.permission.Element.recommended_roles + recommended_roles.Element = AAZStrType() + + required_permissions = _schema_scenario_run_read.properties.execution_errors.permission.Element.required_permissions + required_permissions.Element = AAZStrType() + + resource = _schema_scenario_run_read.properties.execution_errors.resource + resource.Element = AAZObjectType() + + _element = _schema_scenario_run_read.properties.execution_errors.resource.Element + _element.error_code = AAZIntType( + serialized_name="errorCode", + flags={"read_only": True}, + ) + _element.error_message = AAZStrType( + serialized_name="errorMessage", + flags={"read_only": True}, + ) + _element.remediation_uri = AAZStrType( + serialized_name="remediationUri", + flags={"read_only": True}, + ) + _element.resource_id = AAZStrType( + serialized_name="resourceId", + flags={"read_only": True}, + ) + + resources = _schema_scenario_run_read.properties.resources + resources.Element = AAZObjectType() + cls._build_schema_scenario_run_resource_read(resources.Element) + + scenario_run_summary = _schema_scenario_run_read.properties.scenario_run_summary + scenario_run_summary.Element = AAZObjectType() + + _element = _schema_scenario_run_read.properties.scenario_run_summary.Element + _element.action_urn = AAZStrType( + serialized_name="actionUrn", + flags={"read_only": True}, + ) + _element.completed_at = AAZStrType( + serialized_name="completedAt", + flags={"read_only": True}, + ) + _element.resources = AAZListType( + flags={"read_only": True}, + ) + _element.started_at = AAZStrType( + serialized_name="startedAt", + flags={"read_only": True}, + ) + _element.state = AAZStrType( + flags={"read_only": True}, + ) + + resources = _schema_scenario_run_read.properties.scenario_run_summary.Element.resources + resources.Element = AAZObjectType() + cls._build_schema_scenario_run_resource_read(resources.Element) + + zone_resolution = _schema_scenario_run_read.properties.zone_resolution + zone_resolution.mode = AAZStrType( + flags={"read_only": True}, + ) + zone_resolution.requested_physical_zones = AAZListType( + serialized_name="requestedPhysicalZones", + flags={"read_only": True}, + ) + zone_resolution.subscription_zone_mappings = AAZListType( + serialized_name="subscriptionZoneMappings", + flags={"read_only": True}, + ) + + requested_physical_zones = _schema_scenario_run_read.properties.zone_resolution.requested_physical_zones + requested_physical_zones.Element = AAZStrType() + + subscription_zone_mappings = _schema_scenario_run_read.properties.zone_resolution.subscription_zone_mappings + subscription_zone_mappings.Element = AAZObjectType() + + _element = _schema_scenario_run_read.properties.zone_resolution.subscription_zone_mappings.Element + _element.subscription_id = AAZStrType( + serialized_name="subscriptionId", + flags={"read_only": True}, + ) + _element.zone_mappings = AAZListType( + serialized_name="zoneMappings", + flags={"read_only": True}, + ) + + zone_mappings = _schema_scenario_run_read.properties.zone_resolution.subscription_zone_mappings.Element.zone_mappings + zone_mappings.Element = AAZObjectType() + + _element = _schema_scenario_run_read.properties.zone_resolution.subscription_zone_mappings.Element.zone_mappings.Element + _element.logical_zone = AAZStrType( + serialized_name="logicalZone", + flags={"read_only": True}, + ) + _element.physical_zone = AAZStrType( + serialized_name="physicalZone", + flags={"read_only": True}, + ) + + system_data = _schema_scenario_run_read.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + _schema.id = cls._schema_scenario_run_read.id + _schema.name = cls._schema_scenario_run_read.name + _schema.properties = cls._schema_scenario_run_read.properties + _schema.system_data = cls._schema_scenario_run_read.system_data + _schema.type = cls._schema_scenario_run_read.type + + +__all__ = ["Show"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/__cmd_group.py b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/__cmd_group.py new file mode 100644 index 00000000000..4136af55f2c --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/__cmd_group.py @@ -0,0 +1,24 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command_group( + "chaos workspace", + is_preview=True, +) +class __CMDGroup(AAZCommandGroup): + """Manage Workspace + """ + pass + + +__all__ = ["__CMDGroup"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/__init__.py b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/__init__.py new file mode 100644 index 00000000000..90c03b54a20 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/__init__.py @@ -0,0 +1,18 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from .__cmd_group import * +from ._create import * +from ._delete import * +from ._list import * +from ._refresh_recommendation import * +from ._show import * +from ._update import * +from ._wait import * diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_create.py b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_create.py new file mode 100644 index 00000000000..5b83df6e54a --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_create.py @@ -0,0 +1,369 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos workspace create", + is_preview=True, +) +class Create(AAZCommand): + """Create a Workspace resource. + + :example: Create or update a Workspace. + az chaos workspace create --resource-group exampleRG --workspace-name exampleWorkspace --location eastus --tags '{key1:value1,key2:value2}' + + :example: Create or update a Workspace with a user-assigned managed identity. + az chaos workspace create --resource-group exampleRG --workspace-name exampleWorkspace --location eastus --mi-user-assigned /subscriptions/6b052e15-03d3-4f17-b2e1-be7f07588291/resourceGroups/exampleRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/exampleIdentity --scopes /subscriptions/6b052e15-03d3-4f17-b2e1-be7f07588291/resourceGroups/exampleRG + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}", "2026-05-01-preview"], + ] + } + + AZ_SUPPORT_NO_WAIT = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_lro_poller(self._execute_operations, self._output) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.workspace_name = AAZStrArg( + options=["-n", "--name", "--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + + # define Arg Group "Identity" + + _args_schema = cls._args_schema + _args_schema.mi_system_assigned = AAZStrArg( + options=["--system-assigned", "--mi-system-assigned"], + arg_group="Identity", + help="Set the system managed identity.", + blank="True", + ) + _args_schema.mi_user_assigned = AAZListArg( + options=["--user-assigned", "--mi-user-assigned"], + arg_group="Identity", + help="Set the user managed identities.", + blank=[], + ) + + mi_user_assigned = cls._args_schema.mi_user_assigned + mi_user_assigned.Element = AAZStrArg() + + # define Arg Group "Properties" + + _args_schema = cls._args_schema + _args_schema.scopes = AAZListArg( + options=["--scopes"], + arg_group="Properties", + help="The intended workspace-level resource scope to be used by child scenarios.", + required=True, + ) + + scopes = cls._args_schema.scopes + scopes.Element = AAZResourceIdArg() + + # define Arg Group "Resource" + + _args_schema = cls._args_schema + _args_schema.location = AAZResourceLocationArg( + arg_group="Resource", + help="The geo-location where the resource lives", + required=True, + fmt=AAZResourceLocationArgFormat( + resource_group_arg="resource_group", + ), + ) + _args_schema.tags = AAZDictArg( + options=["--tags"], + arg_group="Resource", + help="Resource tags.", + ) + + tags = cls._args_schema.tags + tags.Element = AAZStrArg() + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + yield self.WorkspacesCreateOrUpdate(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) + return result + + class WorkspacesCreateOrUpdate(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [202]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200_201, + self.on_error, + lro_options={"final-state-via": "azure-async-operation"}, + path_format_arguments=self.url_parameters, + ) + if session.http_response.status_code in [200, 201]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200_201, + self.on_error, + lro_options={"final-state-via": "azure-async-operation"}, + path_format_arguments=self.url_parameters, + ) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}", + **self.url_parameters + ) + + @property + def method(self): + return "PUT" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Content-Type", "application/json", + ), + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + @property + def content(self): + _content_value, _builder = self.new_content_builder( + self.ctx.args, + typ=AAZObjectType, + typ_kwargs={"flags": {"required": True, "client_flatten": True}} + ) + _builder.set_prop("identity", AAZIdentityObjectType) + _builder.set_prop("location", AAZStrType, ".location", typ_kwargs={"flags": {"required": True}}) + _builder.set_prop("properties", AAZObjectType, ".", typ_kwargs={"flags": {"required": True, "client_flatten": True}}) + _builder.set_prop("tags", AAZDictType, ".tags") + + identity = _builder.get(".identity") + if identity is not None: + identity.set_prop("userAssigned", AAZListType, ".mi_user_assigned", typ_kwargs={"flags": {"action": "create"}}) + identity.set_prop("systemAssigned", AAZStrType, ".mi_system_assigned", typ_kwargs={"flags": {"action": "create"}}) + + user_assigned = _builder.get(".identity.userAssigned") + if user_assigned is not None: + user_assigned.set_elements(AAZStrType, ".") + + properties = _builder.get(".properties") + if properties is not None: + properties.set_prop("scopes", AAZListType, ".scopes", typ_kwargs={"flags": {"required": True}}) + + scopes = _builder.get(".properties.scopes") + if scopes is not None: + scopes.set_elements(AAZStrType, ".") + + tags = _builder.get(".tags") + if tags is not None: + tags.set_elements(AAZStrType, ".") + + return self.serialize_content(_content_value) + + def on_200_201(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200_201 + ) + + _schema_on_200_201 = None + + @classmethod + def _build_schema_on_200_201(cls): + if cls._schema_on_200_201 is not None: + return cls._schema_on_200_201 + + cls._schema_on_200_201 = AAZObjectType() + + _schema_on_200_201 = cls._schema_on_200_201 + _schema_on_200_201.id = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200_201.identity = AAZIdentityObjectType() + _schema_on_200_201.location = AAZStrType( + flags={"required": True}, + ) + _schema_on_200_201.name = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200_201.properties = AAZObjectType( + flags={"required": True, "client_flatten": True}, + ) + _schema_on_200_201.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _schema_on_200_201.tags = AAZDictType() + _schema_on_200_201.type = AAZStrType( + flags={"read_only": True}, + ) + + identity = cls._schema_on_200_201.identity + identity.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + identity.tenant_id = AAZStrType( + serialized_name="tenantId", + flags={"read_only": True}, + ) + identity.type = AAZStrType( + flags={"required": True}, + ) + identity.user_assigned_identities = AAZDictType( + serialized_name="userAssignedIdentities", + ) + + user_assigned_identities = cls._schema_on_200_201.identity.user_assigned_identities + user_assigned_identities.Element = AAZObjectType( + nullable=True, + ) + + _element = cls._schema_on_200_201.identity.user_assigned_identities.Element + _element.client_id = AAZStrType( + serialized_name="clientId", + flags={"read_only": True}, + ) + _element.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + + properties = cls._schema_on_200_201.properties + properties.communication_endpoint = AAZStrType( + serialized_name="communicationEndpoint", + flags={"read_only": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.scopes = AAZListType( + flags={"required": True}, + ) + + scopes = cls._schema_on_200_201.properties.scopes + scopes.Element = AAZStrType() + + system_data = cls._schema_on_200_201.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + tags = cls._schema_on_200_201.tags + tags.Element = AAZStrType() + + return cls._schema_on_200_201 + + +class _CreateHelper: + """Helper class for Create""" + + +__all__ = ["Create"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_delete.py b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_delete.py new file mode 100644 index 00000000000..500901b0788 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_delete.py @@ -0,0 +1,168 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos workspace delete", + is_preview=True, + confirmation="Are you sure you want to perform this operation?", +) +class Delete(AAZCommand): + """Delete a Workspace resource. + + :example: Delete a Workspace in a resource group. + az chaos workspace delete --resource-group exampleRG --workspace-name exampleWorkspace + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}", "2026-05-01-preview"], + ] + } + + AZ_SUPPORT_NO_WAIT = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_lro_poller(self._execute_operations, None) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.workspace_name = AAZStrArg( + options=["-n", "--name", "--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + yield self.WorkspacesDelete(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + class WorkspacesDelete(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [202]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200_201, + self.on_error, + lro_options={"final-state-via": "location"}, + path_format_arguments=self.url_parameters, + ) + if session.http_response.status_code in [204]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_204, + self.on_error, + lro_options={"final-state-via": "location"}, + path_format_arguments=self.url_parameters, + ) + if session.http_response.status_code in [200, 201]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200_201, + self.on_error, + lro_options={"final-state-via": "location"}, + path_format_arguments=self.url_parameters, + ) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}", + **self.url_parameters + ) + + @property + def method(self): + return "DELETE" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + def on_204(self, session): + pass + + def on_200_201(self, session): + pass + + +class _DeleteHelper: + """Helper class for Delete""" + + +__all__ = ["Delete"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_list.py b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_list.py new file mode 100644 index 00000000000..742d895cd80 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_list.py @@ -0,0 +1,454 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos workspace list", + is_preview=True, +) +class List(AAZCommand): + """List a list of all Workspace resources in a subscription. + + :example: List Workspaces in a resource group. + az chaos workspace list --resource-group exampleRG + + :example: List all Workspaces in a subscription. + az chaos workspace list + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/providers/microsoft.chaos/workspaces", "2026-05-01-preview"], + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces", "2026-05-01-preview"], + ] + } + + AZ_SUPPORT_PAGINATION = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_paging(self._execute_operations, self._output) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg() + _args_schema.continuation_token = AAZStrArg( + options=["--continuation-token"], + help="String that sets the continuation token.", + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + condition_0 = has_value(self.ctx.subscription_id) and has_value(self.ctx.args.resource_group) is not True + condition_1 = has_value(self.ctx.args.resource_group) and has_value(self.ctx.subscription_id) + if condition_0: + self.WorkspacesListAll(ctx=self.ctx)() + if condition_1: + self.WorkspacesList(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance.value, client_flatten=True) + next_link = self.deserialize_output(self.ctx.vars.instance.next_link) + return result, next_link + + class WorkspacesListAll(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/providers/Microsoft.Chaos/workspaces", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "continuationToken", self.ctx.args.continuation_token, + ), + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.next_link = AAZStrType( + serialized_name="nextLink", + ) + _schema_on_200.value = AAZListType( + flags={"required": True}, + ) + + value = cls._schema_on_200.value + value.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element + _element.id = AAZStrType( + flags={"read_only": True}, + ) + _element.identity = AAZIdentityObjectType() + _element.location = AAZStrType( + flags={"required": True}, + ) + _element.name = AAZStrType( + flags={"read_only": True}, + ) + _element.properties = AAZObjectType( + flags={"required": True, "client_flatten": True}, + ) + _element.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _element.tags = AAZDictType() + _element.type = AAZStrType( + flags={"read_only": True}, + ) + + identity = cls._schema_on_200.value.Element.identity + identity.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + identity.tenant_id = AAZStrType( + serialized_name="tenantId", + flags={"read_only": True}, + ) + identity.type = AAZStrType( + flags={"required": True}, + ) + identity.user_assigned_identities = AAZDictType( + serialized_name="userAssignedIdentities", + ) + + user_assigned_identities = cls._schema_on_200.value.Element.identity.user_assigned_identities + user_assigned_identities.Element = AAZObjectType( + nullable=True, + ) + + _element = cls._schema_on_200.value.Element.identity.user_assigned_identities.Element + _element.client_id = AAZStrType( + serialized_name="clientId", + flags={"read_only": True}, + ) + _element.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.value.Element.properties + properties.communication_endpoint = AAZStrType( + serialized_name="communicationEndpoint", + flags={"read_only": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.scopes = AAZListType( + flags={"required": True}, + ) + + scopes = cls._schema_on_200.value.Element.properties.scopes + scopes.Element = AAZStrType() + + system_data = cls._schema_on_200.value.Element.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + tags = cls._schema_on_200.value.Element.tags + tags.Element = AAZStrType() + + return cls._schema_on_200 + + class WorkspacesList(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "continuationToken", self.ctx.args.continuation_token, + ), + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.next_link = AAZStrType( + serialized_name="nextLink", + ) + _schema_on_200.value = AAZListType( + flags={"required": True}, + ) + + value = cls._schema_on_200.value + value.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element + _element.id = AAZStrType( + flags={"read_only": True}, + ) + _element.identity = AAZIdentityObjectType() + _element.location = AAZStrType( + flags={"required": True}, + ) + _element.name = AAZStrType( + flags={"read_only": True}, + ) + _element.properties = AAZObjectType( + flags={"required": True, "client_flatten": True}, + ) + _element.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _element.tags = AAZDictType() + _element.type = AAZStrType( + flags={"read_only": True}, + ) + + identity = cls._schema_on_200.value.Element.identity + identity.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + identity.tenant_id = AAZStrType( + serialized_name="tenantId", + flags={"read_only": True}, + ) + identity.type = AAZStrType( + flags={"required": True}, + ) + identity.user_assigned_identities = AAZDictType( + serialized_name="userAssignedIdentities", + ) + + user_assigned_identities = cls._schema_on_200.value.Element.identity.user_assigned_identities + user_assigned_identities.Element = AAZObjectType( + nullable=True, + ) + + _element = cls._schema_on_200.value.Element.identity.user_assigned_identities.Element + _element.client_id = AAZStrType( + serialized_name="clientId", + flags={"read_only": True}, + ) + _element.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.value.Element.properties + properties.communication_endpoint = AAZStrType( + serialized_name="communicationEndpoint", + flags={"read_only": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.scopes = AAZListType( + flags={"required": True}, + ) + + scopes = cls._schema_on_200.value.Element.properties.scopes + scopes.Element = AAZStrType() + + system_data = cls._schema_on_200.value.Element.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + tags = cls._schema_on_200.value.Element.tags + tags.Element = AAZStrType() + + return cls._schema_on_200 + + +class _ListHelper: + """Helper class for List""" + + +__all__ = ["List"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_refresh_recommendation.py b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_refresh_recommendation.py new file mode 100644 index 00000000000..fcb14936c1e --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_refresh_recommendation.py @@ -0,0 +1,143 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos workspace refresh-recommendation", + is_preview=True, +) +class RefreshRecommendation(AAZCommand): + """Refreshes recommendation status for all scenarios in a given workspace. + + :example: Refresh recommendations for all scenarios in a workspace. + az chaos workspace refresh-recommendation --resource-group exampleRG --workspace-name exampleWorkspace + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/refreshrecommendations", "2026-05-01-preview"], + ] + } + + AZ_SUPPORT_NO_WAIT = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_lro_poller(self._execute_operations, None) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + yield self.WorkspacesRefreshRecommendations(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + class WorkspacesRefreshRecommendations(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [202]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + None, + self.on_error, + lro_options={"final-state-via": "location"}, + path_format_arguments=self.url_parameters, + ) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/refreshRecommendations", + **self.url_parameters + ) + + @property + def method(self): + return "POST" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + +class _RefreshRecommendationHelper: + """Helper class for RefreshRecommendation""" + + +__all__ = ["RefreshRecommendation"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_show.py b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_show.py new file mode 100644 index 00000000000..fddfc221971 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_show.py @@ -0,0 +1,260 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos workspace show", + is_preview=True, +) +class Show(AAZCommand): + """Get a Workspace resource. + + :example: Get a Workspace in a resource group. + az chaos workspace show --resource-group exampleRG --workspace-name exampleWorkspace + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}", "2026-05-01-preview"], + ] + } + + def _handler(self, command_args): + super()._handler(command_args) + self._execute_operations() + return self._output() + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.workspace_name = AAZStrArg( + options=["-n", "--name", "--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.WorkspacesGet(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) + return result + + class WorkspacesGet(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.id = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.identity = AAZIdentityObjectType() + _schema_on_200.location = AAZStrType( + flags={"required": True}, + ) + _schema_on_200.name = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.properties = AAZObjectType( + flags={"required": True, "client_flatten": True}, + ) + _schema_on_200.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _schema_on_200.tags = AAZDictType() + _schema_on_200.type = AAZStrType( + flags={"read_only": True}, + ) + + identity = cls._schema_on_200.identity + identity.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + identity.tenant_id = AAZStrType( + serialized_name="tenantId", + flags={"read_only": True}, + ) + identity.type = AAZStrType( + flags={"required": True}, + ) + identity.user_assigned_identities = AAZDictType( + serialized_name="userAssignedIdentities", + ) + + user_assigned_identities = cls._schema_on_200.identity.user_assigned_identities + user_assigned_identities.Element = AAZObjectType( + nullable=True, + ) + + _element = cls._schema_on_200.identity.user_assigned_identities.Element + _element.client_id = AAZStrType( + serialized_name="clientId", + flags={"read_only": True}, + ) + _element.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.properties + properties.communication_endpoint = AAZStrType( + serialized_name="communicationEndpoint", + flags={"read_only": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.scopes = AAZListType( + flags={"required": True}, + ) + + scopes = cls._schema_on_200.properties.scopes + scopes.Element = AAZStrType() + + system_data = cls._schema_on_200.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + tags = cls._schema_on_200.tags + tags.Element = AAZStrType() + + return cls._schema_on_200 + + +class _ShowHelper: + """Helper class for Show""" + + +__all__ = ["Show"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_update.py b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_update.py new file mode 100644 index 00000000000..3a1fe527b0b --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_update.py @@ -0,0 +1,482 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos workspace update", + is_preview=True, +) +class Update(AAZCommand): + """Update a Workspace resource. + + :example: Create/update a workspace in a resource group. + az chaos workspace update --resource-group exampleRG --workspace-name exampleWorkspace + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}", "2026-05-01-preview"], + ] + } + + AZ_SUPPORT_NO_WAIT = True + + AZ_SUPPORT_GENERIC_UPDATE = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_lro_poller(self._execute_operations, self._output) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.workspace_name = AAZStrArg( + options=["-n", "--name", "--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + + # define Arg Group "Identity" + + # define Arg Group "Properties" + + _args_schema = cls._args_schema + _args_schema.scopes = AAZListArg( + options=["--scopes"], + arg_group="Properties", + help="The intended workspace-level resource scope to be used by child scenarios.", + ) + + scopes = cls._args_schema.scopes + scopes.Element = AAZResourceIdArg( + nullable=True, + ) + + # define Arg Group "Resource" + + _args_schema = cls._args_schema + _args_schema.tags = AAZDictArg( + options=["--tags"], + arg_group="Resource", + help="Resource tags.", + nullable=True, + ) + + tags = cls._args_schema.tags + tags.Element = AAZStrArg( + nullable=True, + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.WorkspacesGet(ctx=self.ctx)() + self.pre_instance_update(self.ctx.vars.instance) + self.InstanceUpdateByJson(ctx=self.ctx)() + self.InstanceUpdateByGeneric(ctx=self.ctx)() + self.post_instance_update(self.ctx.vars.instance) + yield self.WorkspacesCreateOrUpdate(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + @register_callback + def pre_instance_update(self, instance): + pass + + @register_callback + def post_instance_update(self, instance): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) + return result + + class WorkspacesGet(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + _UpdateHelper._build_schema_workspace_read(cls._schema_on_200) + + return cls._schema_on_200 + + class WorkspacesCreateOrUpdate(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [202]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200_201, + self.on_error, + lro_options={"final-state-via": "azure-async-operation"}, + path_format_arguments=self.url_parameters, + ) + if session.http_response.status_code in [200, 201]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200_201, + self.on_error, + lro_options={"final-state-via": "azure-async-operation"}, + path_format_arguments=self.url_parameters, + ) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}", + **self.url_parameters + ) + + @property + def method(self): + return "PUT" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Content-Type", "application/json", + ), + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + @property + def content(self): + _content_value, _builder = self.new_content_builder( + self.ctx.args, + value=self.ctx.vars.instance, + ) + + return self.serialize_content(_content_value) + + def on_200_201(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200_201 + ) + + _schema_on_200_201 = None + + @classmethod + def _build_schema_on_200_201(cls): + if cls._schema_on_200_201 is not None: + return cls._schema_on_200_201 + + cls._schema_on_200_201 = AAZObjectType() + _UpdateHelper._build_schema_workspace_read(cls._schema_on_200_201) + + return cls._schema_on_200_201 + + class InstanceUpdateByJson(AAZJsonInstanceUpdateOperation): + + def __call__(self, *args, **kwargs): + self._update_instance(self.ctx.vars.instance) + + def _update_instance(self, instance): + _instance_value, _builder = self.new_content_builder( + self.ctx.args, + value=instance, + typ=AAZObjectType + ) + _builder.set_prop("identity", AAZIdentityObjectType) + _builder.set_prop("properties", AAZObjectType, ".", typ_kwargs={"flags": {"required": True, "client_flatten": True}}) + _builder.set_prop("tags", AAZDictType, ".tags") + + properties = _builder.get(".properties") + if properties is not None: + properties.set_prop("scopes", AAZListType, ".scopes", typ_kwargs={"flags": {"required": True}}) + + scopes = _builder.get(".properties.scopes") + if scopes is not None: + scopes.set_elements(AAZStrType, ".") + + tags = _builder.get(".tags") + if tags is not None: + tags.set_elements(AAZStrType, ".") + + return _instance_value + + class InstanceUpdateByGeneric(AAZGenericInstanceUpdateOperation): + + def __call__(self, *args, **kwargs): + self._update_instance_by_generic( + self.ctx.vars.instance, + self.ctx.generic_update_args + ) + + +class _UpdateHelper: + """Helper class for Update""" + + _schema_workspace_read = None + + @classmethod + def _build_schema_workspace_read(cls, _schema): + if cls._schema_workspace_read is not None: + _schema.id = cls._schema_workspace_read.id + _schema.identity = cls._schema_workspace_read.identity + _schema.location = cls._schema_workspace_read.location + _schema.name = cls._schema_workspace_read.name + _schema.properties = cls._schema_workspace_read.properties + _schema.system_data = cls._schema_workspace_read.system_data + _schema.tags = cls._schema_workspace_read.tags + _schema.type = cls._schema_workspace_read.type + return + + cls._schema_workspace_read = _schema_workspace_read = AAZObjectType() + + workspace_read = _schema_workspace_read + workspace_read.id = AAZStrType( + flags={"read_only": True}, + ) + workspace_read.identity = AAZIdentityObjectType() + workspace_read.location = AAZStrType( + flags={"required": True}, + ) + workspace_read.name = AAZStrType( + flags={"read_only": True}, + ) + workspace_read.properties = AAZObjectType( + flags={"required": True, "client_flatten": True}, + ) + workspace_read.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + workspace_read.tags = AAZDictType() + workspace_read.type = AAZStrType( + flags={"read_only": True}, + ) + + identity = _schema_workspace_read.identity + identity.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + identity.tenant_id = AAZStrType( + serialized_name="tenantId", + flags={"read_only": True}, + ) + identity.type = AAZStrType( + flags={"required": True}, + ) + identity.user_assigned_identities = AAZDictType( + serialized_name="userAssignedIdentities", + ) + + user_assigned_identities = _schema_workspace_read.identity.user_assigned_identities + user_assigned_identities.Element = AAZObjectType( + nullable=True, + ) + + _element = _schema_workspace_read.identity.user_assigned_identities.Element + _element.client_id = AAZStrType( + serialized_name="clientId", + flags={"read_only": True}, + ) + _element.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + + properties = _schema_workspace_read.properties + properties.communication_endpoint = AAZStrType( + serialized_name="communicationEndpoint", + flags={"read_only": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.scopes = AAZListType( + flags={"required": True}, + ) + + scopes = _schema_workspace_read.properties.scopes + scopes.Element = AAZStrType() + + system_data = _schema_workspace_read.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + tags = _schema_workspace_read.tags + tags.Element = AAZStrType() + + _schema.id = cls._schema_workspace_read.id + _schema.identity = cls._schema_workspace_read.identity + _schema.location = cls._schema_workspace_read.location + _schema.name = cls._schema_workspace_read.name + _schema.properties = cls._schema_workspace_read.properties + _schema.system_data = cls._schema_workspace_read.system_data + _schema.tags = cls._schema_workspace_read.tags + _schema.type = cls._schema_workspace_read.type + + +__all__ = ["Update"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_wait.py b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_wait.py new file mode 100644 index 00000000000..354d820c664 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_wait.py @@ -0,0 +1,255 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos workspace wait", +) +class Wait(AAZWaitCommand): + """Place the CLI in a waiting state until a condition is met. + """ + + _aaz_info = { + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}", "2026-05-01-preview"], + ] + } + + def _handler(self, command_args): + super()._handler(command_args) + self._execute_operations() + return self._output() + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.workspace_name = AAZStrArg( + options=["-n", "--name", "--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.WorkspacesGet(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=False) + return result + + class WorkspacesGet(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.id = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.identity = AAZIdentityObjectType() + _schema_on_200.location = AAZStrType( + flags={"required": True}, + ) + _schema_on_200.name = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.properties = AAZObjectType( + flags={"required": True, "client_flatten": True}, + ) + _schema_on_200.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _schema_on_200.tags = AAZDictType() + _schema_on_200.type = AAZStrType( + flags={"read_only": True}, + ) + + identity = cls._schema_on_200.identity + identity.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + identity.tenant_id = AAZStrType( + serialized_name="tenantId", + flags={"read_only": True}, + ) + identity.type = AAZStrType( + flags={"required": True}, + ) + identity.user_assigned_identities = AAZDictType( + serialized_name="userAssignedIdentities", + ) + + user_assigned_identities = cls._schema_on_200.identity.user_assigned_identities + user_assigned_identities.Element = AAZObjectType( + nullable=True, + ) + + _element = cls._schema_on_200.identity.user_assigned_identities.Element + _element.client_id = AAZStrType( + serialized_name="clientId", + flags={"read_only": True}, + ) + _element.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.properties + properties.communication_endpoint = AAZStrType( + serialized_name="communicationEndpoint", + flags={"read_only": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.scopes = AAZListType( + flags={"required": True}, + ) + + scopes = cls._schema_on_200.properties.scopes + scopes.Element = AAZStrType() + + system_data = cls._schema_on_200.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + tags = cls._schema_on_200.tags + tags.Element = AAZStrType() + + return cls._schema_on_200 + + +class _WaitHelper: + """Helper class for Wait""" + + +__all__ = ["Wait"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/__cmd_group.py b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/__cmd_group.py new file mode 100644 index 00000000000..ba6f7cb6ae9 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/__cmd_group.py @@ -0,0 +1,24 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command_group( + "chaos workspace identity", + is_preview=True, +) +class __CMDGroup(AAZCommandGroup): + """Manage Identity + """ + pass + + +__all__ = ["__CMDGroup"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/__init__.py b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/__init__.py new file mode 100644 index 00000000000..3a074471e35 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/__init__.py @@ -0,0 +1,15 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from .__cmd_group import * +from ._assign import * +from ._remove import * +from ._show import * +from ._wait import * diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/_assign.py b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/_assign.py new file mode 100644 index 00000000000..0279f12fad4 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/_assign.py @@ -0,0 +1,461 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos workspace identity assign", + is_preview=True, +) +class Assign(AAZCommand): + """Assign the user or system managed identities. + + :example: Create/update a workspace in a resource group. + az chaos workspace identity assign --resource-group exampleRG --workspace-name exampleWorkspace + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}", "2026-05-01-preview", "identity"], + ] + } + + AZ_SUPPORT_NO_WAIT = True + + def _handler(self, command_args): + super()._handler(command_args) + self.SubresourceSelector(ctx=self.ctx, name="subresource") + return self.build_lro_poller(self._execute_operations, self._output) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.workspace_name = AAZStrArg( + options=["-n", "--name", "--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + + # define Arg Group "Resource.identity" + + _args_schema = cls._args_schema + _args_schema.mi_system_assigned = AAZStrArg( + options=["--system-assigned", "--mi-system-assigned"], + arg_group="Resource.identity", + help="Set the system managed identity.", + blank="True", + ) + _args_schema.mi_user_assigned = AAZListArg( + options=["--user-assigned", "--mi-user-assigned"], + arg_group="Resource.identity", + help="Set the user managed identities.", + blank=[], + ) + + mi_user_assigned = cls._args_schema.mi_user_assigned + mi_user_assigned.Element = AAZStrArg() + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.WorkspacesGet(ctx=self.ctx)() + self.pre_instance_update(self.ctx.selectors.subresource.get()) + self.InstanceUpdateByJson(ctx=self.ctx)() + self.post_instance_update(self.ctx.selectors.subresource.get()) + yield self.WorkspacesCreateOrUpdate(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + @register_callback + def pre_instance_update(self, instance): + pass + + @register_callback + def post_instance_update(self, instance): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.selectors.subresource.get(), client_flatten=True) + return result + + class SubresourceSelector(AAZJsonSelector): + + def _get(self): + result = self.ctx.vars.instance + return result.identity + + def _set(self, value): + result = self.ctx.vars.instance + result.identity = value + return + + class WorkspacesGet(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + _AssignHelper._build_schema_workspace_read(cls._schema_on_200) + + return cls._schema_on_200 + + class WorkspacesCreateOrUpdate(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [202]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200_201, + self.on_error, + lro_options={"final-state-via": "azure-async-operation"}, + path_format_arguments=self.url_parameters, + ) + if session.http_response.status_code in [200, 201]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200_201, + self.on_error, + lro_options={"final-state-via": "azure-async-operation"}, + path_format_arguments=self.url_parameters, + ) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}", + **self.url_parameters + ) + + @property + def method(self): + return "PUT" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Content-Type", "application/json", + ), + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + @property + def content(self): + _content_value, _builder = self.new_content_builder( + self.ctx.args, + value=self.ctx.vars.instance, + ) + + return self.serialize_content(_content_value) + + def on_200_201(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200_201 + ) + + _schema_on_200_201 = None + + @classmethod + def _build_schema_on_200_201(cls): + if cls._schema_on_200_201 is not None: + return cls._schema_on_200_201 + + cls._schema_on_200_201 = AAZObjectType() + _AssignHelper._build_schema_workspace_read(cls._schema_on_200_201) + + return cls._schema_on_200_201 + + class InstanceUpdateByJson(AAZJsonInstanceUpdateOperation): + + def __call__(self, *args, **kwargs): + self._update_instance(self.ctx.selectors.subresource.get()) + + def _update_instance(self, instance): + _instance_value, _builder = self.new_content_builder( + self.ctx.args, + value=instance, + typ=AAZIdentityObjectType + ) + _builder.set_prop("userAssigned", AAZListType, ".mi_user_assigned", typ_kwargs={"flags": {"action": "assign"}}) + _builder.set_prop("systemAssigned", AAZStrType, ".mi_system_assigned", typ_kwargs={"flags": {"action": "assign"}}) + + user_assigned = _builder.get(".userAssigned") + if user_assigned is not None: + user_assigned.set_elements(AAZStrType, ".") + + return _instance_value + + +class _AssignHelper: + """Helper class for Assign""" + + _schema_workspace_read = None + + @classmethod + def _build_schema_workspace_read(cls, _schema): + if cls._schema_workspace_read is not None: + _schema.id = cls._schema_workspace_read.id + _schema.identity = cls._schema_workspace_read.identity + _schema.location = cls._schema_workspace_read.location + _schema.name = cls._schema_workspace_read.name + _schema.properties = cls._schema_workspace_read.properties + _schema.system_data = cls._schema_workspace_read.system_data + _schema.tags = cls._schema_workspace_read.tags + _schema.type = cls._schema_workspace_read.type + return + + cls._schema_workspace_read = _schema_workspace_read = AAZObjectType() + + workspace_read = _schema_workspace_read + workspace_read.id = AAZStrType( + flags={"read_only": True}, + ) + workspace_read.identity = AAZIdentityObjectType() + workspace_read.location = AAZStrType( + flags={"required": True}, + ) + workspace_read.name = AAZStrType( + flags={"read_only": True}, + ) + workspace_read.properties = AAZObjectType( + flags={"required": True, "client_flatten": True}, + ) + workspace_read.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + workspace_read.tags = AAZDictType() + workspace_read.type = AAZStrType( + flags={"read_only": True}, + ) + + identity = _schema_workspace_read.identity + identity.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + identity.tenant_id = AAZStrType( + serialized_name="tenantId", + flags={"read_only": True}, + ) + identity.type = AAZStrType( + flags={"required": True}, + ) + identity.user_assigned_identities = AAZDictType( + serialized_name="userAssignedIdentities", + ) + + user_assigned_identities = _schema_workspace_read.identity.user_assigned_identities + user_assigned_identities.Element = AAZObjectType( + nullable=True, + ) + + _element = _schema_workspace_read.identity.user_assigned_identities.Element + _element.client_id = AAZStrType( + serialized_name="clientId", + flags={"read_only": True}, + ) + _element.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + + properties = _schema_workspace_read.properties + properties.communication_endpoint = AAZStrType( + serialized_name="communicationEndpoint", + flags={"read_only": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.scopes = AAZListType( + flags={"required": True}, + ) + + scopes = _schema_workspace_read.properties.scopes + scopes.Element = AAZStrType() + + system_data = _schema_workspace_read.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + tags = _schema_workspace_read.tags + tags.Element = AAZStrType() + + _schema.id = cls._schema_workspace_read.id + _schema.identity = cls._schema_workspace_read.identity + _schema.location = cls._schema_workspace_read.location + _schema.name = cls._schema_workspace_read.name + _schema.properties = cls._schema_workspace_read.properties + _schema.system_data = cls._schema_workspace_read.system_data + _schema.tags = cls._schema_workspace_read.tags + _schema.type = cls._schema_workspace_read.type + + +__all__ = ["Assign"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/_remove.py b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/_remove.py new file mode 100644 index 00000000000..235a034ba49 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/_remove.py @@ -0,0 +1,461 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos workspace identity remove", + is_preview=True, +) +class Remove(AAZCommand): + """Remove the user or system managed identities. + + :example: Create/update a workspace in a resource group. + az chaos workspace identity remove --resource-group exampleRG --workspace-name exampleWorkspace + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}", "2026-05-01-preview", "identity"], + ] + } + + AZ_SUPPORT_NO_WAIT = True + + def _handler(self, command_args): + super()._handler(command_args) + self.SubresourceSelector(ctx=self.ctx, name="subresource") + return self.build_lro_poller(self._execute_operations, self._output) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.workspace_name = AAZStrArg( + options=["-n", "--name", "--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + + # define Arg Group "Resource.identity" + + _args_schema = cls._args_schema + _args_schema.mi_system_assigned = AAZStrArg( + options=["--system-assigned", "--mi-system-assigned"], + arg_group="Resource.identity", + help="Set the system managed identity.", + blank="True", + ) + _args_schema.mi_user_assigned = AAZListArg( + options=["--user-assigned", "--mi-user-assigned"], + arg_group="Resource.identity", + help="Set the user managed identities.", + blank=[], + ) + + mi_user_assigned = cls._args_schema.mi_user_assigned + mi_user_assigned.Element = AAZStrArg() + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.WorkspacesGet(ctx=self.ctx)() + self.pre_instance_update(self.ctx.selectors.subresource.get()) + self.InstanceUpdateByJson(ctx=self.ctx)() + self.post_instance_update(self.ctx.selectors.subresource.get()) + yield self.WorkspacesCreateOrUpdate(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + @register_callback + def pre_instance_update(self, instance): + pass + + @register_callback + def post_instance_update(self, instance): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.selectors.subresource.get(), client_flatten=True) + return result + + class SubresourceSelector(AAZJsonSelector): + + def _get(self): + result = self.ctx.vars.instance + return result.identity + + def _set(self, value): + result = self.ctx.vars.instance + result.identity = value + return + + class WorkspacesGet(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + _RemoveHelper._build_schema_workspace_read(cls._schema_on_200) + + return cls._schema_on_200 + + class WorkspacesCreateOrUpdate(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [202]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200_201, + self.on_error, + lro_options={"final-state-via": "azure-async-operation"}, + path_format_arguments=self.url_parameters, + ) + if session.http_response.status_code in [200, 201]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200_201, + self.on_error, + lro_options={"final-state-via": "azure-async-operation"}, + path_format_arguments=self.url_parameters, + ) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}", + **self.url_parameters + ) + + @property + def method(self): + return "PUT" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Content-Type", "application/json", + ), + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + @property + def content(self): + _content_value, _builder = self.new_content_builder( + self.ctx.args, + value=self.ctx.vars.instance, + ) + + return self.serialize_content(_content_value) + + def on_200_201(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200_201 + ) + + _schema_on_200_201 = None + + @classmethod + def _build_schema_on_200_201(cls): + if cls._schema_on_200_201 is not None: + return cls._schema_on_200_201 + + cls._schema_on_200_201 = AAZObjectType() + _RemoveHelper._build_schema_workspace_read(cls._schema_on_200_201) + + return cls._schema_on_200_201 + + class InstanceUpdateByJson(AAZJsonInstanceUpdateOperation): + + def __call__(self, *args, **kwargs): + self._update_instance(self.ctx.selectors.subresource.get()) + + def _update_instance(self, instance): + _instance_value, _builder = self.new_content_builder( + self.ctx.args, + value=instance, + typ=AAZIdentityObjectType + ) + _builder.set_prop("userAssigned", AAZListType, ".mi_user_assigned", typ_kwargs={"flags": {"action": "remove"}}) + _builder.set_prop("systemAssigned", AAZStrType, ".mi_system_assigned", typ_kwargs={"flags": {"action": "remove"}}) + + user_assigned = _builder.get(".userAssigned") + if user_assigned is not None: + user_assigned.set_elements(AAZStrType, ".") + + return _instance_value + + +class _RemoveHelper: + """Helper class for Remove""" + + _schema_workspace_read = None + + @classmethod + def _build_schema_workspace_read(cls, _schema): + if cls._schema_workspace_read is not None: + _schema.id = cls._schema_workspace_read.id + _schema.identity = cls._schema_workspace_read.identity + _schema.location = cls._schema_workspace_read.location + _schema.name = cls._schema_workspace_read.name + _schema.properties = cls._schema_workspace_read.properties + _schema.system_data = cls._schema_workspace_read.system_data + _schema.tags = cls._schema_workspace_read.tags + _schema.type = cls._schema_workspace_read.type + return + + cls._schema_workspace_read = _schema_workspace_read = AAZObjectType() + + workspace_read = _schema_workspace_read + workspace_read.id = AAZStrType( + flags={"read_only": True}, + ) + workspace_read.identity = AAZIdentityObjectType() + workspace_read.location = AAZStrType( + flags={"required": True}, + ) + workspace_read.name = AAZStrType( + flags={"read_only": True}, + ) + workspace_read.properties = AAZObjectType( + flags={"required": True, "client_flatten": True}, + ) + workspace_read.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + workspace_read.tags = AAZDictType() + workspace_read.type = AAZStrType( + flags={"read_only": True}, + ) + + identity = _schema_workspace_read.identity + identity.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + identity.tenant_id = AAZStrType( + serialized_name="tenantId", + flags={"read_only": True}, + ) + identity.type = AAZStrType( + flags={"required": True}, + ) + identity.user_assigned_identities = AAZDictType( + serialized_name="userAssignedIdentities", + ) + + user_assigned_identities = _schema_workspace_read.identity.user_assigned_identities + user_assigned_identities.Element = AAZObjectType( + nullable=True, + ) + + _element = _schema_workspace_read.identity.user_assigned_identities.Element + _element.client_id = AAZStrType( + serialized_name="clientId", + flags={"read_only": True}, + ) + _element.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + + properties = _schema_workspace_read.properties + properties.communication_endpoint = AAZStrType( + serialized_name="communicationEndpoint", + flags={"read_only": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.scopes = AAZListType( + flags={"required": True}, + ) + + scopes = _schema_workspace_read.properties.scopes + scopes.Element = AAZStrType() + + system_data = _schema_workspace_read.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + tags = _schema_workspace_read.tags + tags.Element = AAZStrType() + + _schema.id = cls._schema_workspace_read.id + _schema.identity = cls._schema_workspace_read.identity + _schema.location = cls._schema_workspace_read.location + _schema.name = cls._schema_workspace_read.name + _schema.properties = cls._schema_workspace_read.properties + _schema.system_data = cls._schema_workspace_read.system_data + _schema.tags = cls._schema_workspace_read.tags + _schema.type = cls._schema_workspace_read.type + + +__all__ = ["Remove"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/_show.py b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/_show.py new file mode 100644 index 00000000000..6267b2056a7 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/_show.py @@ -0,0 +1,298 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos workspace identity show", + is_preview=True, +) +class Show(AAZCommand): + """Show the details of managed identities. + + :example: Get a Workspace in a resource group. + az chaos workspace identity show --resource-group exampleRG --workspace-name exampleWorkspace + """ + + _aaz_info = { + "version": "2026-05-01-preview", + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}", "2026-05-01-preview", "identity"], + ] + } + + def _handler(self, command_args): + super()._handler(command_args) + self.SubresourceSelector(ctx=self.ctx, name="subresource") + self._execute_operations() + return self._output() + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.workspace_name = AAZStrArg( + options=["-n", "--name", "--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.WorkspacesGet(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.selectors.subresource.required(), client_flatten=True) + return result + + class SubresourceSelector(AAZJsonSelector): + + def _get(self): + result = self.ctx.vars.instance + return result.identity + + def _set(self, value): + result = self.ctx.vars.instance + result.identity = value + return + + class WorkspacesGet(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + _ShowHelper._build_schema_workspace_read(cls._schema_on_200) + + return cls._schema_on_200 + + +class _ShowHelper: + """Helper class for Show""" + + _schema_workspace_read = None + + @classmethod + def _build_schema_workspace_read(cls, _schema): + if cls._schema_workspace_read is not None: + _schema.id = cls._schema_workspace_read.id + _schema.identity = cls._schema_workspace_read.identity + _schema.location = cls._schema_workspace_read.location + _schema.name = cls._schema_workspace_read.name + _schema.properties = cls._schema_workspace_read.properties + _schema.system_data = cls._schema_workspace_read.system_data + _schema.tags = cls._schema_workspace_read.tags + _schema.type = cls._schema_workspace_read.type + return + + cls._schema_workspace_read = _schema_workspace_read = AAZObjectType() + + workspace_read = _schema_workspace_read + workspace_read.id = AAZStrType( + flags={"read_only": True}, + ) + workspace_read.identity = AAZIdentityObjectType() + workspace_read.location = AAZStrType( + flags={"required": True}, + ) + workspace_read.name = AAZStrType( + flags={"read_only": True}, + ) + workspace_read.properties = AAZObjectType( + flags={"required": True, "client_flatten": True}, + ) + workspace_read.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + workspace_read.tags = AAZDictType() + workspace_read.type = AAZStrType( + flags={"read_only": True}, + ) + + identity = _schema_workspace_read.identity + identity.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + identity.tenant_id = AAZStrType( + serialized_name="tenantId", + flags={"read_only": True}, + ) + identity.type = AAZStrType( + flags={"required": True}, + ) + identity.user_assigned_identities = AAZDictType( + serialized_name="userAssignedIdentities", + ) + + user_assigned_identities = _schema_workspace_read.identity.user_assigned_identities + user_assigned_identities.Element = AAZObjectType( + nullable=True, + ) + + _element = _schema_workspace_read.identity.user_assigned_identities.Element + _element.client_id = AAZStrType( + serialized_name="clientId", + flags={"read_only": True}, + ) + _element.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + + properties = _schema_workspace_read.properties + properties.communication_endpoint = AAZStrType( + serialized_name="communicationEndpoint", + flags={"read_only": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.scopes = AAZListType( + flags={"required": True}, + ) + + scopes = _schema_workspace_read.properties.scopes + scopes.Element = AAZStrType() + + system_data = _schema_workspace_read.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + tags = _schema_workspace_read.tags + tags.Element = AAZStrType() + + _schema.id = cls._schema_workspace_read.id + _schema.identity = cls._schema_workspace_read.identity + _schema.location = cls._schema_workspace_read.location + _schema.name = cls._schema_workspace_read.name + _schema.properties = cls._schema_workspace_read.properties + _schema.system_data = cls._schema_workspace_read.system_data + _schema.tags = cls._schema_workspace_read.tags + _schema.type = cls._schema_workspace_read.type + + +__all__ = ["Show"] diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/_wait.py b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/_wait.py new file mode 100644 index 00000000000..ab23eeb6b3a --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/identity/_wait.py @@ -0,0 +1,282 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos workspace identity wait", +) +class Wait(AAZWaitCommand): + """Place the CLI in a waiting state until a condition is met. + """ + + _aaz_info = { + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}", "2026-05-01-preview", "identity"], + ] + } + + def _handler(self, command_args): + super()._handler(command_args) + self._execute_operations() + return self._output() + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.workspace_name = AAZStrArg( + options=["-n", "--name", "--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.WorkspacesGet(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=False) + return result + + class WorkspacesGet(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + _WaitHelper._build_schema_workspace_read(cls._schema_on_200) + + return cls._schema_on_200 + + +class _WaitHelper: + """Helper class for Wait""" + + _schema_workspace_read = None + + @classmethod + def _build_schema_workspace_read(cls, _schema): + if cls._schema_workspace_read is not None: + _schema.id = cls._schema_workspace_read.id + _schema.identity = cls._schema_workspace_read.identity + _schema.location = cls._schema_workspace_read.location + _schema.name = cls._schema_workspace_read.name + _schema.properties = cls._schema_workspace_read.properties + _schema.system_data = cls._schema_workspace_read.system_data + _schema.tags = cls._schema_workspace_read.tags + _schema.type = cls._schema_workspace_read.type + return + + cls._schema_workspace_read = _schema_workspace_read = AAZObjectType() + + workspace_read = _schema_workspace_read + workspace_read.id = AAZStrType( + flags={"read_only": True}, + ) + workspace_read.identity = AAZIdentityObjectType() + workspace_read.location = AAZStrType( + flags={"required": True}, + ) + workspace_read.name = AAZStrType( + flags={"read_only": True}, + ) + workspace_read.properties = AAZObjectType( + flags={"required": True, "client_flatten": True}, + ) + workspace_read.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + workspace_read.tags = AAZDictType() + workspace_read.type = AAZStrType( + flags={"read_only": True}, + ) + + identity = _schema_workspace_read.identity + identity.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + identity.tenant_id = AAZStrType( + serialized_name="tenantId", + flags={"read_only": True}, + ) + identity.type = AAZStrType( + flags={"required": True}, + ) + identity.user_assigned_identities = AAZDictType( + serialized_name="userAssignedIdentities", + ) + + user_assigned_identities = _schema_workspace_read.identity.user_assigned_identities + user_assigned_identities.Element = AAZObjectType( + nullable=True, + ) + + _element = _schema_workspace_read.identity.user_assigned_identities.Element + _element.client_id = AAZStrType( + serialized_name="clientId", + flags={"read_only": True}, + ) + _element.principal_id = AAZStrType( + serialized_name="principalId", + flags={"read_only": True}, + ) + + properties = _schema_workspace_read.properties + properties.communication_endpoint = AAZStrType( + serialized_name="communicationEndpoint", + flags={"read_only": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + properties.scopes = AAZListType( + flags={"required": True}, + ) + + scopes = _schema_workspace_read.properties.scopes + scopes.Element = AAZStrType() + + system_data = _schema_workspace_read.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + tags = _schema_workspace_read.tags + tags.Element = AAZStrType() + + _schema.id = cls._schema_workspace_read.id + _schema.identity = cls._schema_workspace_read.identity + _schema.location = cls._schema_workspace_read.location + _schema.name = cls._schema_workspace_read.name + _schema.properties = cls._schema_workspace_read.properties + _schema.system_data = cls._schema_workspace_read.system_data + _schema.tags = cls._schema_workspace_read.tags + _schema.type = cls._schema_workspace_read.type + + +__all__ = ["Wait"] diff --git a/src/chaos/azext_chaos/azext_metadata.json b/src/chaos/azext_chaos/azext_metadata.json new file mode 100644 index 00000000000..71889bb136b --- /dev/null +++ b/src/chaos/azext_chaos/azext_metadata.json @@ -0,0 +1,4 @@ +{ + "azext.isPreview": true, + "azext.minCliCoreVersion": "2.75.0" +} \ No newline at end of file diff --git a/src/chaos/azext_chaos/commands.py b/src/chaos/azext_chaos/commands.py new file mode 100644 index 00000000000..117a86b59c4 --- /dev/null +++ b/src/chaos/azext_chaos/commands.py @@ -0,0 +1,135 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azext_chaos._table_format import ( + discovered_resource_list_table_format, + discovered_resource_show_table_format, + permission_fix_show_table_format, + scenario_config_list_table_format, + scenario_config_show_table_format, + scenario_list_table_format, + scenario_run_list_table_format, + scenario_run_show_table_format, + scenario_show_table_format, + validation_show_table_format, + workspace_discovery_show_table_format, + workspace_evaluation_show_table_format, + workspace_list_table_format, + workspace_show_table_format, +) + + +def load_command_table(self, _): + # ── Table transformers for aaz-generated commands ──────────────── + # These commands are already registered by load_aaz_command_table() + # before this function is called. We set table_transformer on each + # result-bearing command so --output table renders useful columns. + _aaz_transformers = { + 'chaos workspace show': workspace_show_table_format, + 'chaos workspace list': workspace_list_table_format, + 'chaos workspace show-discovery': workspace_discovery_show_table_format, + 'chaos workspace show-evaluation': workspace_evaluation_show_table_format, + 'chaos scenario show': scenario_show_table_format, + 'chaos scenario list': scenario_list_table_format, + 'chaos scenario config show': scenario_config_show_table_format, + 'chaos scenario config list': scenario_config_list_table_format, + 'chaos scenario config show-validation': validation_show_table_format, + 'chaos scenario config show-permission-fix': permission_fix_show_table_format, + 'chaos scenario run show': scenario_run_show_table_format, + 'chaos scenario run list': scenario_run_list_table_format, + 'chaos discovered-resource show': discovered_resource_show_table_format, + 'chaos discovered-resource list': discovered_resource_list_table_format, + } + for cmd_name, transformer in _aaz_transformers.items(): + if cmd_name in self.command_table: + self.command_table[cmd_name].table_transformer = transformer + + # ── Custom command overrides────────────────────────────────────── + with self.command_group( + 'chaos workspace', + operations_tmpl='azext_chaos.custom#{}', + ) as g: + g.custom_command( + 'refresh-recommendations', + 'workspace_refresh_recommendations', + supports_no_wait=True, + ) + g.custom_command( + 'evaluate-scenarios', + 'workspace_refresh_recommendations', + supports_no_wait=True, + ) + g.custom_show_command( + 'show-discovery', + 'workspace_show_discovery', + table_transformer=workspace_discovery_show_table_format, + ) + g.custom_show_command( + 'show-evaluation', + 'workspace_show_evaluation', + table_transformer=workspace_evaluation_show_table_format, + ) + + with self.command_group( + 'chaos scenario config', + operations_tmpl='azext_chaos.custom#{}', + ) as g: + g.custom_command( + 'validate', + 'scenario_config_validate', + supports_no_wait=True, + table_transformer=validation_show_table_format, + ) + g.custom_command( + 'fix-permissions', + 'scenario_config_fix_permissions', + supports_no_wait=True, + table_transformer=permission_fix_show_table_format, + ) + g.custom_show_command( + 'show-validation', + 'scenario_config_show_validation', + table_transformer=validation_show_table_format, + ) + g.custom_show_command( + 'show-permission-fix', + 'scenario_config_show_permission_fix', + table_transformer=permission_fix_show_table_format, + ) + + with self.command_group( + 'chaos scenario run', + operations_tmpl='azext_chaos.custom#{}', + ) as g: + g.custom_command( + 'start', + 'scenario_run_start', + supports_no_wait=True, + table_transformer=scenario_run_show_table_format, + ) + g.custom_command( + 'cancel', + 'scenario_run_cancel', + supports_no_wait=True, + ) + + _register_aaz_subclass_overrides(self) + + +def _register_aaz_subclass_overrides(loader): + """Register AAZCommand subclass overrides via direct command_table writes. + + These supersede the AAZ-generated commands of the same name. Pattern + reference: Azure/azure-cli-extensions:src/connectedmachine/azext_connectedmachine + /commands.py — subclass + manual ``command_table[name] = cls(loader=self)``. + + Because instantiating an ``AAZCommand`` requires a real ``AzCommandsLoader`` + (Knack rejects non-CLI ``cli_ctx``), unit tests that drive ``load_command_table`` + with a ``MagicMock`` loader should ``@patch`` this helper out. + """ + from azext_chaos.custom import ScenarioConfigCreate + from azext_chaos.custom_wait import ScenarioRunWait + loader.command_table['chaos scenario config create'] = ScenarioConfigCreate(loader=loader) + loader.command_table['chaos scenario run wait'] = ScenarioRunWait(loader=loader) diff --git a/src/chaos/azext_chaos/custom.py b/src/chaos/azext_chaos/custom.py new file mode 100644 index 00000000000..0878620e2a6 --- /dev/null +++ b/src/chaos/azext_chaos/custom.py @@ -0,0 +1,648 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import json +import time + +from azure.cli.core.aaz import has_value +from azure.cli.core.util import send_raw_request +from knack.log import get_logger +from knack.util import CLIError + +from .aaz.latest.chaos.scenario.config._create import Create as _ScenarioConfigCreate + +logger = get_logger(__name__) + +# Substring pattern emitted by BE's evaluation-state gate +# (StartScenarioValidationCommand / StartScenarioExecutionCommand). +_EVALUATION_NOT_READY_PATTERN = "not evaluated yet" + + +_LRO_TERMINAL_STATES = {"Succeeded", "Failed", "Cancelled", "Canceled"} +_LRO_POLL_INTERVAL_SECONDS = 5 +_LRO_TIMEOUT_SECONDS = 600 # 10 min default + + +def _poll_or_return(cmd, response): + """Dispatch a raw HTTP response: return body for 200, poll for 201/202, raise on error.""" + if response.status_code == 200: + return response.json() if response.text else None + if response.status_code in (201, 202): + return _poll_lro(cmd, response) + try: + error_body = response.json() + message = error_body.get("error", {}).get("message", response.text) + except Exception: # pylint: disable=broad-except + message = response.text or f"HTTP {response.status_code}" + raise CLIError(f"Request failed ({response.status_code}): {message}") + + +def _poll_lro(cmd, initial_response): + """Choose polling strategy based on LRO headers.""" + headers = initial_response.headers + async_url = headers.get("Azure-AsyncOperation") + location_url = headers.get("Location") + try: + retry_after = int(headers.get("Retry-After", _LRO_POLL_INTERVAL_SECONDS)) + except (ValueError, TypeError): + retry_after = _LRO_POLL_INTERVAL_SECONDS + if async_url: + return _poll_async_operation(cmd, async_url, location_url, retry_after) + if location_url: + return _poll_location(cmd, location_url, retry_after) + return initial_response.json() if initial_response.text else None + + +def _poll_async_operation(cmd, poll_url, location_url, retry_after): + """Poll Azure-AsyncOperation URL until terminal status.""" + elapsed = 0 + while elapsed < _LRO_TIMEOUT_SECONDS: + time.sleep(retry_after) + elapsed += retry_after + poll_resp = send_raw_request(cmd.cli_ctx, "GET", poll_url) + body = poll_resp.json() if poll_resp.text else {} + status = body.get("status", "") + try: + retry_after = int(poll_resp.headers.get("Retry-After", retry_after)) + except (ValueError, TypeError): + pass # keep current retry_after + if status in _LRO_TERMINAL_STATES: + if status != "Succeeded": + err = body.get("error", {}) + msg = err.get("message", f"LRO terminated with status: {status}") + raise CLIError(msg) + if location_url: + final = send_raw_request(cmd.cli_ctx, "GET", location_url) + return final.json() if final.text else body + return body + raise CLIError(f"Long-running operation timed out after {_LRO_TIMEOUT_SECONDS}s.") + + +def _poll_location(cmd, poll_url, retry_after): + """Poll Location URL until non-202 response.""" + elapsed = 0 + while elapsed < _LRO_TIMEOUT_SECONDS: + time.sleep(retry_after) + elapsed += retry_after + poll_resp = send_raw_request(cmd.cli_ctx, "GET", poll_url) + if poll_resp.status_code == 200: + return poll_resp.json() if poll_resp.text else None + if poll_resp.status_code != 202: + try: + error_body = poll_resp.json() + msg = error_body.get("error", {}).get("message", poll_resp.text) + except Exception: # pylint: disable=broad-except + msg = poll_resp.text or f"HTTP {poll_resp.status_code}" + raise CLIError(f"LRO poll failed ({poll_resp.status_code}): {msg}") + try: + retry_after = int(poll_resp.headers.get("Retry-After", retry_after)) + except (ValueError, TypeError): + pass # keep current retry_after + raise CLIError(f"Long-running operation timed out after {_LRO_TIMEOUT_SECONDS}s.") + + +def _build_arm_url(cmd, resource_group, workspace, path_suffix): + """Build a fully qualified ARM URL for a workspace-scoped sub-resource.""" + from azure.cli.core.commands.client_factory import get_subscription_id + sub = get_subscription_id(cmd.cli_ctx) + return ( + f"/subscriptions/{sub}/resourceGroups/{resource_group}" + f"/providers/Microsoft.Chaos/workspaces/{workspace}" + f"{path_suffix}" + f"?api-version=2026-05-01-preview" + ) + + +def _is_evaluation_error(error_text): + """Check if an error message indicates the workspace evaluation gate.""" + if not error_text: + return False + return _EVALUATION_NOT_READY_PATTERN in error_text.lower() + + +def _make_evaluation_hint(workspace, resource_group, context_suffix=""): + """Build the friendly hint for evaluation-state errors.""" + hint = ( + f"Workspace has not been evaluated. " + f"Run `az chaos workspace refresh-recommendations " + f"--name {workspace} --resource-group {resource_group}` " + f"(or its alias `az chaos workspace evaluate-scenarios`) " + f"to (re)trigger evaluation, or " + f"`az chaos workspace show-evaluation " + f"--name {workspace} --resource-group {resource_group}` " + f"to inspect the current evaluation state without triggering a new one." + ) + if context_suffix: + hint += f" {context_suffix}" + return hint + + +# ── workspace refresh-recommendations──────────────────────────────────── + +# Inner LROs that complete via the outer refreshRecommendations LRO. After the +# outer call reports terminal, the BE has written results to these singletons. +# A common silent failure is Azure Resource Graph propagation lag: discovery +# 403s but the outer LRO still completes. We surface those inner failures so +# the user gets a non-zero exit, not a misleading green message. +_INNER_LRO_FAILURE_HINT = ( + "This commonly happens immediately after a Reader role assignment is " + "added to the workspace's managed identity: Azure Resource Graph can " + "lag 1-3 minutes before honoring the new role. Wait ~60-180s and re-run " + "'refresh-recommendations'. If it persists, run " + "'az chaos workspace show-discovery' / 'show-evaluation' for full error " + "detail and verify the workspace UAMI has Reader on all in-scope scopes." +) + + +def _check_inner_lro(cmd, resource_group_name, workspace_name, path_suffix, + operation_label): + """Fetch a /latest singleton and raise if its inner status is Failed. + + Returns silently for any other state (Succeeded, in-progress, no result + yet, or endpoint not reachable). We only flip to non-zero exit when the + inner result is unambiguously Failed. + """ + url = _build_arm_url(cmd, resource_group_name, workspace_name, path_suffix) + try: + response = send_raw_request(cmd.cli_ctx, "GET", url) + except Exception: # pylint: disable=broad-except + return # /latest may legitimately 404 on a fresh workspace + if response.status_code != 200 or not response.text: + return + try: + body = response.json() + except Exception: # pylint: disable=broad-except + return + props = body.get("properties") or {} + status = props.get("status", "") + if status != "Failed": + return + # Surface the inner failure + errors = props.get("errors") or [] + if errors: + first = errors[0] + code = first.get("errorCode") or first.get("code") or "Unknown" + message = first.get("errorMessage") or first.get("message") or "" + detail = f"{code}: {message}".strip(": ") + else: + detail = "no error detail returned by the service" + raise CLIError( + f"refresh-recommendations completed but the inner " + f"{operation_label} operation Failed ({detail}). " + f"{_INNER_LRO_FAILURE_HINT}" + ) + + +def workspace_refresh_recommendations(cmd, resource_group_name, workspace_name, + no_wait=False): + """POST + poll LRO for workspace refresh-recommendations.""" + url = _build_arm_url( + cmd, resource_group_name, workspace_name, "/refreshRecommendations" + ) + response = send_raw_request(cmd.cli_ctx, "POST", url) + + if no_wait: + return response.json() if response.text else None + + # Poll to completion + _poll_or_return(cmd, response) + + # Even when the outer LRO reports Succeeded, the inner discovery and + # evaluation LROs may have failed (typically Azure Resource Graph 403 + # during propagation lag after a fresh role assignment). Check both + # before declaring success. + _check_inner_lro( + cmd, resource_group_name, workspace_name, + "/discoveries/latest", "resource discovery", + ) + _check_inner_lro( + cmd, resource_group_name, workspace_name, + "/evaluations/latest", "scenario evaluation", + ) + + logger.warning( + "Successfully refreshed recommendations for workspace '%s' " + "in resource group '%s'. Workspace evaluation has been refreshed; " + "subsequent 'scenario config validate' / 'scenario run start' calls " + "(for non-custom scenarios) now have a satisfied evaluation gate.\n" + "Run 'az chaos scenario list -w %s -g %s' to see updated " + "recommendation statuses.", + workspace_name, resource_group_name, + workspace_name, resource_group_name, + ) + return None + + +# ── scenario config validate ───────────────────────────────────────────── + +def scenario_config_validate(cmd, resource_group_name, workspace_name, # pylint: disable=too-many-positional-arguments + scenario_name, scenario_configuration_name, + no_wait=False): + """POST validate + poll LRO + auto-GET validations/latest.""" + validate_url = _build_arm_url( + cmd, resource_group_name, workspace_name, + f"/scenarios/{scenario_name}" + f"/configurations/{scenario_configuration_name}/validate" + ) + response = send_raw_request(cmd.cli_ctx, "POST", validate_url) + + if no_wait: + logger.warning( + "Validation submitted. Use 'az chaos scenario config show-validation " + "--workspace-name %s --resource-group %s --scenario-name %s " + "--name %s' to retrieve the result once the operation completes.", + workspace_name, resource_group_name, scenario_name, + scenario_configuration_name, + ) + return response.json() if response.text else None + + # Poll LRO to completion + _poll_or_return(cmd, response) + + # Auto-GET validations/latest + latest_url = _build_arm_url( + cmd, resource_group_name, workspace_name, + f"/scenarios/{scenario_name}" + f"/configurations/{scenario_configuration_name}" + f"/validations/latest" + ) + latest_response = send_raw_request(cmd.cli_ctx, "GET", latest_url) + result = latest_response.json() + + # Check for non-success status (aligned with scenario_run_start behavior) + status = (result.get("properties") or {}).get("status", "") + if status != "Succeeded": + _check_evaluation_error_in_validation(result, workspace_name, + resource_group_name) + + return result + + +def _check_evaluation_error_in_validation(result, workspace, resource_group): + """Check validation result for evaluation-state errors and raise with hint.""" + props = result.get("properties") or {} + # Check system errors + for err in (props.get("errors") or []): + msg = err.get("message", "") + if _is_evaluation_error(msg): + raise CLIError( + _make_evaluation_hint(workspace, resource_group) + ) + # Check validation errors + val_errors = (props.get("validationErrors") or {}).get("errors") or [] + for err in val_errors: + msg = err.get("message", "") + if _is_evaluation_error(msg): + raise CLIError( + _make_evaluation_hint(workspace, resource_group) + ) + + +# ── scenario run start ─────────────────────────────────────────────────── + +def scenario_run_start(cmd, resource_group_name, workspace_name, # pylint: disable=too-many-positional-arguments + scenario_name, scenario_configuration_name, + skip_validation=False, no_wait=False): + """Execute a scenario configuration with optional pre-flight validation.""" + from azext_chaos._table_format import validation_show_table_format + + # Pre-flight validation (unless --skip-validation) + if not skip_validation: + validate_url = _build_arm_url( + cmd, resource_group_name, workspace_name, + f"/scenarios/{scenario_name}" + f"/configurations/{scenario_configuration_name}/validate" + ) + val_response = send_raw_request(cmd.cli_ctx, "POST", validate_url) + + # Always poll validation to completion, even with --no-wait + _poll_or_return(cmd, val_response) + + # GET validations/latest + latest_url = _build_arm_url( + cmd, resource_group_name, workspace_name, + f"/scenarios/{scenario_name}" + f"/configurations/{scenario_configuration_name}" + f"/validations/latest" + ) + latest_response = send_raw_request(cmd.cli_ctx, "GET", latest_url) + val_result = latest_response.json() + + val_status = (val_result.get("properties") or {}).get("status", "") + if val_status != "Succeeded": + # Check for evaluation-state error first + _check_evaluation_error_in_validation( + val_result, workspace_name, resource_group_name + ) + # Render validation errors and exit non-zero + table = validation_show_table_format(val_result) + logger.error("Pre-flight validation failed:") + logger.error(" Status: %s", table.get("Status", "Unknown")) + if table.get("Errors"): + logger.error(" Errors: %s", table["Errors"]) + raise CLIError( + f"Validation failed for configuration " + f"'{scenario_configuration_name}'. Fix the reported errors " + f"before running the scenario." + ) + + # Execute + execute_url = _build_arm_url( + cmd, resource_group_name, workspace_name, + f"/scenarios/{scenario_name}" + f"/configurations/{scenario_configuration_name}/execute" + ) + exec_response = send_raw_request(cmd.cli_ctx, "POST", execute_url) + + if no_wait: + # Parse run ID from Location header. Two paths observed in production: + # - Resource URL pointing at .../scenarioRuns/{runId} → parse it. + # - Operation-status URL → do a single GET on Azure-AsyncOperation + # and read the runId from the body. + location = exec_response.headers.get("Location", "") + async_op = exec_response.headers.get("Azure-AsyncOperation", "") + run_id = _extract_run_id_from_location(location) + if not run_id: + run_id = _fetch_run_id_from_async_op(cmd, async_op or location) + + if run_id: + logger.warning( + "Scenario run started (run ID: %s). " + "Run 'az chaos scenario run show --workspace-name %s " + "--resource-group %s --scenario-name %s --run-id %s' " + "to check status.", + run_id, workspace_name, resource_group_name, + scenario_name, run_id, + ) + else: + logger.warning( + "Scenario run started, but the run ID could not be parsed " + "from the Location/Azure-AsyncOperation header. " + "Run 'az chaos scenario run list --workspace-name %s " + "--resource-group %s --scenario-name %s' to recover it " + "(the most recent entry is yours).", + workspace_name, resource_group_name, scenario_name, + ) + # Always return a parseable shape so `-o json` is never empty. + return { + "runId": run_id, + "operationStatusUrl": async_op or location or None, + } + + # Poll execute LRO to completion + run_result = _poll_or_return(cmd, exec_response) + + # Extract run ID from the completed ScenarioRun resource + if isinstance(run_result, dict): + run_id = run_result.get("name", "") + else: + run_id = "" + + if run_id: + logger.warning( + "Scenario run started successfully (run ID: %s). " + "Run 'az chaos scenario run show --workspace-name %s " + "--resource-group %s --scenario-name %s --run-id %s' " + "to check status.", + run_id, workspace_name, resource_group_name, + scenario_name, run_id, + ) + + return run_result + + +def _extract_run_id_from_location(location_url): + """Parse the run ID from a Location header URL. + + Handles two common shapes observed for ScenarioRuns_Execute: + + 1. Resource URL: .../scenarios/{s}/scenarioRuns/{runId}?api-version=... + .../scenarios/{s}/runs/{runId}?api-version=... + 2. Operation URL: .../locations/{region}/operationResults/{opId}?... + + For (1) the run id is the segment after `scenarioRuns` (or legacy `runs`). + For (2) we cannot derive the run id from the URL alone — return None and + let the caller surface the operation URL as a fallback. + """ + if not location_url: + return None + try: + clean_url = location_url.split("?", 1)[0] + segments = [s for s in clean_url.strip("/").split("/") if s] + # Prefer the more specific marker; fall back to legacy `runs` segment. + for marker in ("scenarioRuns", "runs"): + if marker in segments: + idx = segments.index(marker) + if idx + 1 < len(segments): + return segments[idx + 1] + return None + except (ValueError, AttributeError): + return None + + +def _fetch_run_id_from_async_op(cmd, async_op_url): + """One-shot GET of an Azure-AsyncOperation URL to extract a runId. + + Used as a fallback for --no-wait when the Location header is an + operation-status URL rather than a resource URL. We do a SINGLE GET (no + polling loop) — the body may report InProgress, but it commonly already + contains the resulting runId in either properties.runId / properties.name + or the operation name itself. + """ + if not async_op_url: + return None + try: + resp = send_raw_request(cmd.cli_ctx, "GET", async_op_url) + if resp.status_code != 200 or not resp.text: + return None + body = resp.json() or {} + except Exception: # pylint: disable=broad-except + return None + props = body.get("properties") or {} + return ( + props.get("runId") + or props.get("scenarioRunId") + or props.get("name") + or body.get("name") + ) + + +# ── singleton "latest result" GETs ─────────────────────────────────────── +# These four endpoints are intentionally NOT in the public swagger by ARM policy: +# `/latest` paths are polling endpoints, not real GETs, and including them in +# the OpenAPI spec confuses customers and SDK generation. They live in the GW +# resource models (preserved for future api-versions by ADO PR #15743714) and +# are served by the BE controllers below. URLs are taken from those controller +# routes (services/BE/src/Chaos.Workspaces.Api/Controllers/): +# - ScenarioConfigurationsController.cs +# [HttpGet("{scenarioConfigurationId}/validations/latest")] +# [HttpGet("{scenarioConfigurationId}/fixResourcePermissions/latest")] +# - ResourceDiscoveryOperationsController.cs +# [HttpGet("latest")] (under /workspaces/{id}/discoveries) +# - ScenarioEvaluationOperationsController.cs +# [HttpGet("latest")] (under /workspaces/{id}/evaluations) +# These custom wrappers are the permanent CLI surface for these reads — they +# will not be replaced by aaz-generated commands in a future api-version. + + +def workspace_show_discovery(cmd, resource_group_name, workspace_name): + """GET the latest workspace-scope resource-discovery operation result.""" + url = _build_arm_url( + cmd, resource_group_name, workspace_name, "/discoveries/latest" + ) + response = send_raw_request(cmd.cli_ctx, "GET", url) + return response.json() if response.text else None + + +def workspace_show_evaluation(cmd, resource_group_name, workspace_name): + """GET the latest workspace scenario-evaluation operation result.""" + url = _build_arm_url( + cmd, resource_group_name, workspace_name, "/evaluations/latest" + ) + response = send_raw_request(cmd.cli_ctx, "GET", url) + return response.json() if response.text else None + + +def scenario_config_show_validation(cmd, resource_group_name, workspace_name, + scenario_name, scenario_configuration_name): + """GET the latest validation result for a scenario configuration.""" + url = _build_arm_url( + cmd, resource_group_name, workspace_name, + f"/scenarios/{scenario_name}" + f"/configurations/{scenario_configuration_name}" + f"/validations/latest" + ) + response = send_raw_request(cmd.cli_ctx, "GET", url) + return response.json() if response.text else None + + +def scenario_config_show_permission_fix(cmd, resource_group_name, workspace_name, + scenario_name, scenario_configuration_name): + """GET the latest permission-fix result for a scenario configuration. + + The response body carries `properties.whatIfMode` indicating whether the + latest fix was a what-if dry run or an actual fix — there is no separate + "show the last what-if" query; the singleton-latest GET returns whichever + fix was most recently submitted. To preview without applying, use + `az chaos scenario config fix-permissions --what-if` (POST side). + """ + url = _build_arm_url( + cmd, resource_group_name, workspace_name, + f"/scenarios/{scenario_name}" + f"/configurations/{scenario_configuration_name}" + f"/fixResourcePermissions/latest" + ) + response = send_raw_request(cmd.cli_ctx, "GET", url) + return response.json() if response.text else None + + +# ── scenario config fix-permissions ────────────────────────────────────── +# Overrides the aaz-generated command. The generated version uses +# `final-state-via: location` against the SAS-signed /fixResourcePermissions +# /latest Location header; the AAZ runtime mangles that URL on polling and +# returns a misleading 404 even when the role assignment succeeded server- +# side. Mirroring the validate/refresh-recommendations pattern (POST + own +# _poll_or_return + GET /latest) avoids the AAZ poller entirely. + +def scenario_config_fix_permissions(cmd, resource_group_name, workspace_name, # pylint: disable=too-many-positional-arguments + scenario_name, scenario_configuration_name, + what_if=False, no_wait=False): + """POST fixResourcePermissions + poll LRO + auto-GET fixResourcePermissions/latest.""" + logger.info( + "fix-permissions requires that the workspace, scenario, and " + "configuration all exist. If you receive a 404 NotFound error, " + "verify: (1) the workspace '%s' exists in resource group '%s'; " + "(2) the scenario '%s' exists under that workspace; " + "(3) the configuration '%s' exists under that scenario. " + "Run 'az chaos scenario config show' to confirm the " + "configuration exists before calling fix-permissions.", + workspace_name, resource_group_name, scenario_name, + scenario_configuration_name, + ) + fix_url = _build_arm_url( + cmd, resource_group_name, workspace_name, + f"/scenarios/{scenario_name}" + f"/configurations/{scenario_configuration_name}/fixResourcePermissions" + ) + body = json.dumps({"whatIf": bool(what_if)}) + response = send_raw_request( + cmd.cli_ctx, "POST", fix_url, + body=body, + headers=["Content-Type=application/json"], + ) + + if no_wait: + logger.warning( + "Permission fix submitted. Use 'az chaos scenario config " + "show-permission-fix --workspace-name %s --resource-group %s " + "--scenario-name %s --name %s' to retrieve the result once the " + "operation completes.", + workspace_name, resource_group_name, scenario_name, + scenario_configuration_name, + ) + return response.json() if response.text else None + + # Poll LRO to completion via Azure-AsyncOperation header (avoids the + # AAZ-poller behavior of GET-ing the SAS-signed Location URL). + _poll_or_return(cmd, response) + + # Auto-GET fixResourcePermissions/latest under our own ARM URL (we + # control the api-version query param; no SAS-stripping). + return scenario_config_show_permission_fix( + cmd, resource_group_name, workspace_name, + scenario_name, scenario_configuration_name, + ) + + +# ── scenario run cancel ────────────────────────────────────────────────── + +def scenario_run_cancel(cmd, resource_group_name, workspace_name, # pylint: disable=too-many-positional-arguments + scenario_name, run_id, no_wait=False): + """POST cancel + poll LRO for a scenario run.""" + url = _build_arm_url( + cmd, resource_group_name, workspace_name, + f"/scenarios/{scenario_name}/runs/{run_id}/cancel" + ) + response = send_raw_request(cmd.cli_ctx, "POST", url) + + if no_wait: + return response.json() if response.text else None + + _poll_or_return(cmd, response) + + logger.warning( + "Successfully cancelled scenario run '%s' for scenario '%s' " + "in workspace '%s' (resource group '%s').", + run_id, scenario_name, workspace_name, resource_group_name, + ) + return None + + +# ── AAZCommand subclass overrides ──────────────────────────────────────── +# Subclassing keeps the aaz-generated module pristine (no hand-edits under +# azext_chaos/aaz/) while letting us inject pre/post-operation behavior. +# Pattern reference: Azure/azure-cli-extensions:src/connectedmachine +# /azext_connectedmachine/custom.py — subclass + register in commands.py. + +class ScenarioConfigCreate(_ScenarioConfigCreate): + """Override `chaos scenario config create` to auto-derive ``--scenario-id``. + + The ARM resource ID for a scenario is deterministic from the other + args (subscription, resource group, workspace, scenario name), so + requiring users to type the full ID is poor UX. When the caller did + not supply ``--scenario-id`` we synthesize it before the request + builder reads ``args.scenario_id``. + """ + + def pre_operations(self): + args = self.ctx.args + if not has_value(args.scenario_id): + args.scenario_id = ( + f"/subscriptions/{self.ctx.subscription_id}" + f"/resourceGroups/{args.resource_group}" + f"/providers/Microsoft.Chaos" + f"/workspaces/{args.workspace_name}" + f"/scenarios/{args.scenario_name}" + ) diff --git a/src/chaos/azext_chaos/custom_wait.py b/src/chaos/azext_chaos/custom_wait.py new file mode 100644 index 00000000000..fb2e907e209 --- /dev/null +++ b/src/chaos/azext_chaos/custom_wait.py @@ -0,0 +1,213 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Hand-written ``chaos scenario run wait`` command. + +``aaz-dev`` only auto-generates ``_wait.py`` for resources whose spec advertises an +LRO; the public ``Microsoft.Chaos`` spec does not (yet) declare ``ScenarioRuns`` +as an LRO-bearing resource, so the aaz tool emits no wait sibling. The CLI +linter (``require_wait_command_if_no_wait``) needs one anyway because +``chaos scenario run cancel`` declares ``supports_no_wait=True``. + +Living next to ``custom.py`` (instead of under ``azext_chaos/aaz/``) keeps the +aaz-generated tree byte-identical with a fresh ``aaz-dev`` export. The command +is registered explicitly in :mod:`azext_chaos.commands` via +``self.command_table['chaos scenario run wait'] = ScenarioRunWait(loader=self)``. +""" + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command( + "chaos scenario run wait", +) +class ScenarioRunWait(AAZWaitCommand): + """Place the CLI in a waiting state until a condition is met. + """ + + _aaz_info = { + "resources": [ + ["mgmt-plane", "/subscriptions/{}/resourcegroups/{}/providers/microsoft.chaos/workspaces/{}/scenarios/{}/runs/{}", "2026-05-01-preview"], + ] + } + + def _handler(self, command_args): + super()._handler(command_args) + self._execute_operations() + return self._output() + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + _args_schema.run_id = AAZStrArg( + options=["-n", "--name", "--run-id"], + help="The name of the ScenarioRun", + required=True, + id_part="child_name_2", + fmt=AAZStrArgFormat( + pattern="^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + ), + ) + _args_schema.scenario_name = AAZStrArg( + options=["--scenario-name"], + help="Name of the scenario.", + required=True, + id_part="child_name_1", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + _args_schema.workspace_name = AAZStrArg( + options=["--workspace-name"], + help="String that represents a Workspace resource name.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[^<>%&:?#/\\\\]+$", + min_length=1, + ), + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.ScenarioRunsGet(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=False) + return result + + class ScenarioRunsGet(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Chaos/workspaces/{workspaceName}/scenarios/{scenarioName}/runs/{runId}", + **self.url_parameters + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "runId", self.ctx.args.run_id, + required=True, + ), + **self.serialize_url_param( + "scenarioName", self.ctx.args.scenario_name, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", self.ctx.subscription_id, + required=True, + ), + **self.serialize_url_param( + "workspaceName", self.ctx.args.workspace_name, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", "2026-05-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var( + "instance", + data, + schema_builder=self._build_schema_on_200 + ) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.id = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.name = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.properties = AAZObjectType() + _schema_on_200.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _schema_on_200.type = AAZStrType( + flags={"read_only": True}, + ) + + return cls._schema_on_200 + + +__all__ = ["ScenarioRunWait"] diff --git a/src/chaos/azext_chaos/tests/__init__.py b/src/chaos/azext_chaos/tests/__init__.py new file mode 100644 index 00000000000..34913fb394d --- /dev/null +++ b/src/chaos/azext_chaos/tests/__init__.py @@ -0,0 +1,4 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/src/chaos/azext_chaos/tests/latest/README.md b/src/chaos/azext_chaos/tests/latest/README.md new file mode 100644 index 00000000000..4d72640f53d --- /dev/null +++ b/src/chaos/azext_chaos/tests/latest/README.md @@ -0,0 +1,52 @@ +# Chaos CLI Extension — Integration Tests + +Tests in this directory use the Azure CLI `ScenarioTest` framework +(`azure.cli.testsdk`) and are runnable in both **live** and **playback** modes. + +## Running Tests + +```bash +# Playback mode (from recordings — no Azure subscription needed) +azdev test chaos + +# Live mode (requires Azure subscription + ARM Gateway deployed) +azdev test chaos --live + +# Single test class +azdev test chaos -t test_chaos_workspace +``` + +## Command Coverage + +Run `azdev cmdcov chaos` to verify 100 % command-path coverage. +The report should enumerate **27 command paths** (26 unique commands + the +`workspace evaluate-scenarios` alias) with all of them covered. The count +includes `fix-permissions`, `show-permission-fix`, `show-discovery`, and +`show-evaluation` which were added during plan revisions. + +## Test Files + +| File | Epic Task | Commands Covered | +|------|-----------|-----------------| +| `test_chaos_workspace.py` | E5-T1 | `workspace create`, `show`, `list`, `update`, `refresh-recommendations`, `evaluate-scenarios`, `show-discovery`, `show-evaluation`, `delete` | +| `test_chaos_scenario.py` | E5-T2 | `scenario create`, `show`, `list`, `delete` | +| `test_chaos_scenario_config.py` | E5-T3 | `scenario config create`, `show`, `list`, `validate`, `show-validation`, `fix-permissions`, `show-permission-fix`, `delete` | +| `test_chaos_scenario_run.py` | E5-T3a/b/T4 | `scenario run start` (5 modes), `list`, `show`, `cancel` | +| `test_chaos_discovered_resource.py` | E5-T5 | `discovered-resource list`, `show` | +| `test_custom_commands.py` | E3-T6 | Unit tests for `custom.py` (mocked — no recording) | +| `test_command_registration.py` | E2 | Unit tests for command & help registration | +| `test_table_format.py` | E4 | Unit tests for table formatters | +| `test_validators.py` | E2 | Unit tests for argument validators | + +## When to Re-record + +Re-record (`azdev test chaos --live`) when **either** condition is true: + +1. **Playback failures** — a test that used to pass in playback mode now + fails (typically because the request/response shape has changed). +2. **Spec change** — a change merges to `azure-rest-api-specs[-pr]` under + `Microsoft.Chaos` that touches a covered operation. The agentic + spec-change cross-links emitted from `az-chaos-codegen` (companion + skill codification plan E1-T10) propagate this trigger automatically. + +After re-recording, commit the updated `recordings/*.yaml` files. diff --git a/src/chaos/azext_chaos/tests/latest/__init__.py b/src/chaos/azext_chaos/tests/latest/__init__.py new file mode 100644 index 00000000000..34913fb394d --- /dev/null +++ b/src/chaos/azext_chaos/tests/latest/__init__.py @@ -0,0 +1,4 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/src/chaos/azext_chaos/tests/latest/test_aaz_pristine.py b/src/chaos/azext_chaos/tests/latest/test_aaz_pristine.py new file mode 100644 index 00000000000..5d87d987091 --- /dev/null +++ b/src/chaos/azext_chaos/tests/latest/test_aaz_pristine.py @@ -0,0 +1,50 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Guard that ``azext_chaos/aaz/`` contains no hand-edited ``pre_operations`` / +``post_operations`` method bodies. + +Hand-editing the aaz/ tree is forbidden — aliases, renames, and help text +belong in the aaz-dev workspace editor; behavioral hooks belong in +:mod:`azext_chaos.custom` subclasses registered in +:mod:`azext_chaos.commands`. + +See ``automation/cli-extension/src/chaos/README.md`` §"Modifying the +AAZ-generated code". +""" + +import pathlib +import re +import unittest + + +class TestAazPristine(unittest.TestCase): + def test_no_pre_post_operations_bodies_in_aaz(self): + aaz_root = pathlib.Path(__file__).resolve().parents[2] / 'aaz' / 'latest' / 'chaos' + self.assertTrue(aaz_root.is_dir(), f'aaz root not found at {aaz_root}') + + non_pass_re = re.compile( + r'@register_callback\s*\n\s*def (?:pre|post)_operations\(self\):\s*\n' + r'((?:[ \t]+.*\n)+)', + re.MULTILINE, + ) + bad = [] + for py in aaz_root.rglob('*.py'): + text = py.read_text(encoding='utf-8') + for body in non_pass_re.findall(text): + stripped = body.strip() + if stripped and stripped != 'pass': + bad.append(str(py.relative_to(aaz_root))) + break + self.assertFalse( + bad, + 'aaz/ files MUST NOT contain hand-written pre/post_operations bodies. ' + 'Move logic to a custom.py subclass and register it in commands.py. ' + f'Offenders: {bad}' + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/chaos/azext_chaos/tests/latest/test_chaos_discovered_resource.py b/src/chaos/azext_chaos/tests/latest/test_chaos_discovered_resource.py new file mode 100644 index 00000000000..f3769b459d2 --- /dev/null +++ b/src/chaos/azext_chaos/tests/latest/test_chaos_discovered_resource.py @@ -0,0 +1,63 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Integration tests for the ``az chaos discovered-resource`` command group (E5-T5). + +Covers discovered-resource browsing: + list discovered resources → show discovered resource + +Re-record when: playback fails OR a spec change merges to +``azure-rest-api-specs[-pr]`` under ``Microsoft.Chaos`` that touches a +covered discovered-resource operation. +""" + +from azure.cli.testsdk import ScenarioTest, ResourceGroupPreparer + + +class TestChaosDiscoveredResourceBrowsing(ScenarioTest): + """E5-T5: discovered-resource list → show.""" + + @ResourceGroupPreparer(name_prefix='cli_test_chaos_dr', location='eastus') + def test_discovered_resource_list_and_show(self, resource_group): + self.kwargs.update({ + 'ws': self.create_random_name('ws', 15), + 'loc': 'eastus', + }) + + # ── prerequisite: create workspace + refresh-recommendations ──── + # Scoping the workspace to the test resource group so that + # discovery will find at least the resource group itself. + self.cmd( + 'chaos workspace create -n {ws} -g {rg} -l {loc} ' + '--identity-type SystemAssigned ' + '--scopes /subscriptions/{sub}/resourceGroups/{rg}', + ) + self.cmd( + 'chaos workspace refresh-recommendations -n {ws} -g {rg}', + ) + + # ── discovered-resource list ──────────────────────────────────── + list_result = self.cmd( + 'chaos discovered-resource list -w {ws} -g {rg}', + checks=[ + self.greater_than('length(@)', 0), + ], + ).get_output_in_json() + + # Pick the first discovered resource for show + first_name = list_result[0]['name'] + self.kwargs['dr_name'] = first_name + + # ── discovered-resource show ──────────────────────────────────── + self.cmd( + 'chaos discovered-resource show -w {ws} -g {rg} -n {dr_name}', + checks=[ + self.check('name', first_name), + self.exists('properties'), + ], + ) + + # ── cleanup ───────────────────────────────────────────────────── + self.cmd('chaos workspace delete -n {ws} -g {rg} --yes') diff --git a/src/chaos/azext_chaos/tests/latest/test_chaos_scenario.py b/src/chaos/azext_chaos/tests/latest/test_chaos_scenario.py new file mode 100644 index 00000000000..ab1b2b0eac2 --- /dev/null +++ b/src/chaos/azext_chaos/tests/latest/test_chaos_scenario.py @@ -0,0 +1,80 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Integration tests for the ``az chaos scenario`` command group (E5-T2). + +Covers the scenario lifecycle: + create scenario → list scenarios → show scenario → delete scenario + +Re-record when: playback fails OR a spec change merges to +``azure-rest-api-specs[-pr]`` under ``Microsoft.Chaos`` that touches a +covered scenario operation. +""" + +from azure.cli.testsdk import ScenarioTest, ResourceGroupPreparer + + +class TestChaosScenarioLifecycle(ScenarioTest): + """E5-T2: scenario lifecycle — create → list → show → delete.""" + + @ResourceGroupPreparer(name_prefix='cli_test_chaos_sc', location='eastus') + def test_scenario_lifecycle(self, resource_group): + self.kwargs.update({ + 'ws': self.create_random_name('ws', 15), + 'scenario': self.create_random_name('sc', 15), + 'loc': 'eastus', + }) + + # ── prerequisite: create workspace ────────────────────────────── + self.cmd( + 'chaos workspace create -n {ws} -g {rg} -l {loc} ' + '--identity-type SystemAssigned ' + '--scopes /subscriptions/{sub}/resourceGroups/{rg}', + ) + + # ── scenario create ───────────────────────────────────────────── + self.cmd( + 'chaos scenario create -w {ws} -g {rg} -n {scenario}', + checks=[ + self.check('name', '{scenario}'), + ], + ) + + # ── scenario list ─────────────────────────────────────────────── + list_result = self.cmd( + 'chaos scenario list -w {ws} -g {rg}', + checks=[ + self.greater_than('length(@)', 0), + ], + ).get_output_in_json() + self.assertTrue( + any(s['name'] == self.kwargs['scenario'] for s in list_result), + 'Created scenario not found in list output', + ) + + # ── scenario show ─────────────────────────────────────────────── + self.cmd( + 'chaos scenario show -w {ws} -g {rg} -n {scenario}', + checks=[ + self.check('name', '{scenario}'), + ], + ) + + # ── scenario delete ───────────────────────────────────────────── + self.cmd( + 'chaos scenario delete -w {ws} -g {rg} -n {scenario} --yes', + ) + + # Verify deletion + list_after = self.cmd( + 'chaos scenario list -w {ws} -g {rg}', + ).get_output_in_json() + self.assertFalse( + any(s['name'] == self.kwargs['scenario'] for s in list_after), + 'Scenario should not appear after deletion', + ) + + # ── cleanup: delete workspace ─────────────────────────────────── + self.cmd('chaos workspace delete -n {ws} -g {rg} --yes') diff --git a/src/chaos/azext_chaos/tests/latest/test_chaos_scenario_config.py b/src/chaos/azext_chaos/tests/latest/test_chaos_scenario_config.py new file mode 100644 index 00000000000..1b75d24d1ad --- /dev/null +++ b/src/chaos/azext_chaos/tests/latest/test_chaos_scenario_config.py @@ -0,0 +1,130 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Integration tests for the ``az chaos scenario config`` command group (E5-T3). + +Covers the scenario configuration lifecycle: + create → show → list → validate → show-validation → + fix-permissions → show-permission-fix → delete + +Re-record when: playback fails OR a spec change merges to +``azure-rest-api-specs[-pr]`` under ``Microsoft.Chaos`` that touches a +covered scenario-configuration operation. +""" + +from azure.cli.testsdk import ScenarioTest, ResourceGroupPreparer + + +class TestChaosScenarioConfigLifecycle(ScenarioTest): + """E5-T3: scenario config lifecycle — create → show → list → validate → + show-validation → fix-permissions → show-permission-fix → delete.""" + + @ResourceGroupPreparer(name_prefix='cli_test_chaos_cfg', location='eastus') + def test_scenario_config_lifecycle(self, resource_group): + self.kwargs.update({ + 'ws': self.create_random_name('ws', 15), + 'scenario': self.create_random_name('sc', 15), + 'config': self.create_random_name('cfg', 15), + 'loc': 'eastus', + }) + + # ── prerequisite: workspace + scenario + refresh-recommendations + self.cmd( + 'chaos workspace create -n {ws} -g {rg} -l {loc} ' + '--identity-type SystemAssigned ' + '--scopes /subscriptions/{sub}/resourceGroups/{rg}', + ) + self.cmd( + 'chaos scenario create -w {ws} -g {rg} -n {scenario}', + ) + # Ensure workspace evaluation is complete (required for validate) + self.cmd( + 'chaos workspace refresh-recommendations -n {ws} -g {rg}', + ) + + # ── scenario config create ────────────────────────────────────── + self.cmd( + "chaos scenario config create -w {ws} -g {rg} " + "--scenario-name {scenario} -n {config} " + "--parameters '{{}}'", + checks=[ + self.check('name', '{config}'), + ], + ) + + # ── scenario config show ──────────────────────────────────────── + self.cmd( + 'chaos scenario config show -w {ws} -g {rg} ' + '--scenario-name {scenario} -n {config}', + checks=[ + self.check('name', '{config}'), + ], + ) + + # ── scenario config list ──────────────────────────────────────── + list_result = self.cmd( + 'chaos scenario config list -w {ws} -g {rg} ' + '--scenario-name {scenario}', + checks=[ + self.greater_than('length(@)', 0), + ], + ).get_output_in_json() + self.assertTrue( + any(c['name'] == self.kwargs['config'] for c in list_result), + 'Created config not found in list output', + ) + + # ── scenario config validate (default — polls to completion) ──── + self.cmd( + 'chaos scenario config validate -w {ws} -g {rg} ' + '--scenario-name {scenario} -n {config}', + checks=[ + self.exists('properties.status'), + ], + ) + + # ── scenario config show-validation ───────────────────────────── + self.cmd( + 'chaos scenario config show-validation -w {ws} -g {rg} ' + '--scenario-name {scenario} -n {config}', + checks=[ + self.exists('properties.status'), + ], + ) + + # ── scenario config fix-permissions ───────────────────────────── + self.cmd( + 'chaos scenario config fix-permissions -w {ws} -g {rg} ' + '--scenario-name {scenario} -n {config}', + ) + + # ── scenario config show-permission-fix ───────────────────────── + self.cmd( + 'chaos scenario config show-permission-fix -w {ws} -g {rg} ' + '--scenario-name {scenario} -n {config}', + checks=[ + self.exists('properties.state'), + ], + ) + + # ── scenario config delete ────────────────────────────────────── + self.cmd( + 'chaos scenario config delete -w {ws} -g {rg} ' + '--scenario-name {scenario} -n {config} --yes', + ) + + # Verify deletion + list_after = self.cmd( + 'chaos scenario config list -w {ws} -g {rg} ' + '--scenario-name {scenario}', + ).get_output_in_json() + self.assertFalse( + any(c['name'] == self.kwargs['config'] for c in list_after), + 'Config should not appear after deletion', + ) + + # ── cleanup ───────────────────────────────────────────────────── + self.cmd('chaos scenario delete -w {ws} -g {rg} -n {scenario} --yes') + self.cmd('chaos workspace delete -n {ws} -g {rg} --yes') diff --git a/src/chaos/azext_chaos/tests/latest/test_chaos_scenario_run.py b/src/chaos/azext_chaos/tests/latest/test_chaos_scenario_run.py new file mode 100644 index 00000000000..37ad2ab67e2 --- /dev/null +++ b/src/chaos/azext_chaos/tests/latest/test_chaos_scenario_run.py @@ -0,0 +1,337 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Integration tests for the ``az chaos scenario run`` command group. + +Test classes: + - TestScenarioRunStartDefault (E5-T3a-a): default mode validates then executes + - TestScenarioRunStartSkipValidation (E5-T3a-b): --skip-validation skips validate + - TestScenarioRunStartValidationFailure (E5-T3a-c): failed validation → non-zero exit + - TestScenarioRunStartNoWait (E5-T3a-d): --no-wait applies only to execute LRO + - TestScenarioRunStartFireAndForget (E5-T3a-e): --skip-validation --no-wait + - TestCatalogScenarioEvalGate (E5-T3b): catalog scenario first-run eval-gate recovery + - TestScenarioRunOperations (E5-T4): list → show → cancel + +Re-record when: playback fails OR a spec change merges to +``azure-rest-api-specs[-pr]`` under ``Microsoft.Chaos`` that touches a +covered scenario-run operation. +""" + +from azure.cli.testsdk import ScenarioTest, ResourceGroupPreparer + + +# ── Shared mixin for workspace + scenario + config setup ──────────────── + +class _ChaosRunTestBase(ScenarioTest): + """Base class that provisions a workspace, scenario, and config for run tests.""" + + def _setup_workspace_and_config(self, resource_group, *, + refresh_recommendations=True): + """Create workspace → scenario → config. Optionally run + refresh-recommendations (skipped for the eval-gate negative test).""" + self.kwargs.update({ + 'ws': self.create_random_name('ws', 15), + 'scenario': self.create_random_name('sc', 15), + 'config': self.create_random_name('cfg', 15), + 'loc': 'eastus', + }) + + self.cmd( + 'chaos workspace create -n {ws} -g {rg} -l {loc} ' + '--identity-type SystemAssigned ' + '--scopes /subscriptions/{sub}/resourceGroups/{rg}', + ) + self.cmd( + 'chaos scenario create -w {ws} -g {rg} -n {scenario}', + ) + if refresh_recommendations: + self.cmd( + 'chaos workspace refresh-recommendations -n {ws} -g {rg}', + ) + self.cmd( + "chaos scenario config create -w {ws} -g {rg} " + "--scenario-name {scenario} -n {config} " + "--parameters '{{}}'", + ) + + def _cleanup(self): + """Delete config → scenario → workspace.""" + self.cmd( + 'chaos scenario config delete -w {ws} -g {rg} ' + '--scenario-name {scenario} -n {config} --yes', + ) + self.cmd('chaos scenario delete -w {ws} -g {rg} -n {scenario} --yes') + self.cmd('chaos workspace delete -n {ws} -g {rg} --yes') + + +# ═══════════════════════════════════════════════════════════════════════ +# E5-T3a: scenario run start mode matrix +# ═══════════════════════════════════════════════════════════════════════ + + +class TestScenarioRunStartDefault(_ChaosRunTestBase): + """E5-T3a (a): ``run start`` (default) → validate issued → execute → + run ID returned.""" + + @ResourceGroupPreparer(name_prefix='cli_test_chaos_run_def', location='eastus') + def test_run_start_default(self, resource_group): + self._setup_workspace_and_config(resource_group) + + # Default mode: validate + execute + result = self.cmd( + 'chaos scenario run start -w {ws} -g {rg} ' + '--scenario-name {scenario} --config-name {config}', + checks=[ + self.exists('name'), + self.exists('properties.status'), + ], + ).get_output_in_json() + + # The returned resource is a ScenarioRun with a run ID + self.assertTrue( + result.get('name'), + 'run start should return a ScenarioRun with a name (run ID)', + ) + + self._cleanup() + + +class TestScenarioRunStartSkipValidation(_ChaosRunTestBase): + """E5-T3a (b): ``run start --skip-validation`` → no validate → + execute issued directly.""" + + @ResourceGroupPreparer(name_prefix='cli_test_chaos_run_skip', location='eastus') + def test_run_start_skip_validation(self, resource_group): + self._setup_workspace_and_config(resource_group) + + result = self.cmd( + 'chaos scenario run start -w {ws} -g {rg} ' + '--scenario-name {scenario} --config-name {config} ' + '--skip-validation', + checks=[ + self.exists('name'), + ], + ).get_output_in_json() + + self.assertTrue( + result.get('name'), + 'run start --skip-validation should return a run ID', + ) + + self._cleanup() + + +class TestScenarioRunStartValidationFailure(_ChaosRunTestBase): + """E5-T3a (c): negative test — ``run start`` against a config that + fails validation → non-zero exit and no execute issued. + + Uses an intentionally malformed parameters body (an invalid JSON structure + with unrecognised properties) so the server-side validation rejects the + config. The base-class ``_setup_workspace_and_config`` creates a *valid* + config; this test creates a second, invalid one for the negative path. + """ + + @ResourceGroupPreparer(name_prefix='cli_test_chaos_run_fail', location='eastus') + def test_run_start_validation_failure(self, resource_group): + self._setup_workspace_and_config(resource_group) + + # Create a deliberately invalid config with malformed parameters. + # The invalid parameters contain an unknown property that the service + # validation will reject, ensuring a non-Succeeded status. + self.kwargs['bad_config'] = self.create_random_name('bad', 15) + self.cmd( + "chaos scenario config create -w {ws} -g {rg} " + "--scenario-name {scenario} -n {bad_config} " + "--parameters '{{\"__invalid_property__\": true, " + "\"__force_validation_failure__\": 1}}'", + ) + + # run start should fail because validation fails + self.cmd( + 'chaos scenario run start -w {ws} -g {rg} ' + '--scenario-name {scenario} --config-name {bad_config}', + expect_failure=True, + ) + + # cleanup extra config + self.cmd( + 'chaos scenario config delete -w {ws} -g {rg} ' + '--scenario-name {scenario} -n {bad_config} --yes', + ) + self._cleanup() + + +class TestScenarioRunStartNoWait(_ChaosRunTestBase): + """E5-T3a (d): ``run start --no-wait`` → validate polled to completion → + execute kicked off → command returns with run ID before execute completes + (demonstrating --no-wait applies only to execute LRO).""" + + @ResourceGroupPreparer(name_prefix='cli_test_chaos_run_nw', location='eastus') + def test_run_start_no_wait(self, resource_group): + self._setup_workspace_and_config(resource_group) + + result = self.cmd( + 'chaos scenario run start -w {ws} -g {rg} ' + '--scenario-name {scenario} --config-name {config} ' + '--no-wait', + checks=[ + self.exists('runId'), + ], + ).get_output_in_json() + + # With --no-wait, the run ID is parsed from the Location header + run_id = result.get('runId') + self.assertTrue(run_id, '--no-wait should return a runId') + + # Verify the run exists via show (it may still be running) + self.kwargs['run_id'] = run_id + self.cmd( + 'chaos scenario run show -w {ws} -g {rg} ' + '--scenario-name {scenario} --run-id {run_id}', + checks=[ + self.check('name', run_id), + self.exists('properties.status'), + ], + ) + + self._cleanup() + + +class TestScenarioRunStartFireAndForget(_ChaosRunTestBase): + """E5-T3a (e): ``run start --skip-validation --no-wait`` → + no validate → execute kicked off → returns before execute completes.""" + + @ResourceGroupPreparer(name_prefix='cli_test_chaos_run_ff', location='eastus') + def test_run_start_fire_and_forget(self, resource_group): + self._setup_workspace_and_config(resource_group) + + result = self.cmd( + 'chaos scenario run start -w {ws} -g {rg} ' + '--scenario-name {scenario} --config-name {config} ' + '--skip-validation --no-wait', + checks=[ + self.exists('runId'), + ], + ).get_output_in_json() + + run_id = result.get('runId') + self.assertTrue(run_id, '--skip-validation --no-wait should return a runId') + + self._cleanup() + + +# ═══════════════════════════════════════════════════════════════════════ +# E5-T3b: catalog-scenario eval-gate recovery +# ═══════════════════════════════════════════════════════════════════════ + + +class TestCatalogScenarioEvalGate(_ChaosRunTestBase): + """E5-T3b: from a clean workspace (no prior refresh-recommendations), + ``run start`` fails with eval-gate error → refresh-recommendations → + retry succeeds. This is the objective check for G6.""" + + @ResourceGroupPreparer(name_prefix='cli_test_chaos_eval', location='eastus') + def test_catalog_scenario_eval_gate_recovery(self, resource_group): + # Setup WITHOUT refresh-recommendations + self._setup_workspace_and_config(resource_group, + refresh_recommendations=False) + + # Attempt run start → should fail with eval-gate error + result = self.cmd( + 'chaos scenario run start -w {ws} -g {rg} ' + '--scenario-name {scenario} --config-name {config}', + expect_failure=True, + ) + # Verify the error message contains the friendly guidance + stderr = result.output if hasattr(result, 'output') else str(result) + self.assertIn('refresh-recommendations', stderr, + 'eval-gate error should reference refresh-recommendations command') + + # Run refresh-recommendations to satisfy the eval gate + self.cmd( + 'chaos workspace refresh-recommendations -n {ws} -g {rg}', + ) + + # Retry run start → should now succeed + retry_result = self.cmd( + 'chaos scenario run start -w {ws} -g {rg} ' + '--scenario-name {scenario} --config-name {config}', + ).get_output_in_json() + + self.assertTrue( + retry_result.get('name'), + 'run start should succeed after refresh-recommendations', + ) + + self._cleanup() + + +# ═══════════════════════════════════════════════════════════════════════ +# E5-T4: scenario run list → show → cancel +# ═══════════════════════════════════════════════════════════════════════ + + +class TestScenarioRunOperations(_ChaosRunTestBase): + """E5-T4: run list → show → cancel lifecycle.""" + + @ResourceGroupPreparer(name_prefix='cli_test_chaos_run_ops', location='eastus') + def test_run_list_show_cancel(self, resource_group): + self._setup_workspace_and_config(resource_group) + + # Start a run (--skip-validation for speed) + start_result = self.cmd( + 'chaos scenario run start -w {ws} -g {rg} ' + '--scenario-name {scenario} --config-name {config} ' + '--skip-validation --no-wait', + checks=[ + self.exists('runId'), + ], + ).get_output_in_json() + + run_id = start_result.get('runId') + self.assertTrue(run_id, 'Expected a run ID from run start') + self.kwargs['run_id'] = run_id + + # ── run list ──────────────────────────────────────────────────── + list_result = self.cmd( + 'chaos scenario run list -w {ws} -g {rg} ' + '--scenario-name {scenario}', + checks=[ + self.greater_than('length(@)', 0), + ], + ).get_output_in_json() + self.assertTrue( + any(r['name'] == run_id for r in list_result), + 'Started run not found in list output', + ) + + # ── run show ──────────────────────────────────────────────────── + self.cmd( + 'chaos scenario run show -w {ws} -g {rg} ' + '--scenario-name {scenario} --run-id {run_id}', + checks=[ + self.check('name', run_id), + self.exists('properties.status'), + ], + ) + + # ── run cancel ────────────────────────────────────────────────── + self.cmd( + 'chaos scenario run cancel -w {ws} -g {rg} ' + '--scenario-name {scenario} --run-id {run_id}', + ) + + # Verify cancelled status + show_after = self.cmd( + 'chaos scenario run show -w {ws} -g {rg} ' + '--scenario-name {scenario} --run-id {run_id}', + ).get_output_in_json() + status = (show_after.get('properties') or {}).get('status', '') + self.assertIn( + status, ('Cancelled', 'Cancelling', 'Failed'), + f'Run status should reflect cancellation, got: {status}', + ) + + self._cleanup() diff --git a/src/chaos/azext_chaos/tests/latest/test_chaos_workspace.py b/src/chaos/azext_chaos/tests/latest/test_chaos_workspace.py new file mode 100644 index 00000000000..65794301398 --- /dev/null +++ b/src/chaos/azext_chaos/tests/latest/test_chaos_workspace.py @@ -0,0 +1,116 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Integration tests for the ``az chaos workspace`` command group (E5-T1). + +Covers the full workspace lifecycle: + create → show → list → update → refresh-recommendations → + evaluate-scenarios (alias) → show-discovery → show-evaluation → delete + +Re-record when: playback fails OR a spec change merges to +``azure-rest-api-specs[-pr]`` under ``Microsoft.Chaos`` that touches a +covered workspace operation. +""" + +from azure.cli.testsdk import ScenarioTest, ResourceGroupPreparer + + +class TestChaosWorkspaceLifecycle(ScenarioTest): + """E5-T1: workspace lifecycle — create → show → list → update → + refresh-recommendations → evaluate-scenarios → show-discovery → + show-evaluation → delete.""" + + @ResourceGroupPreparer(name_prefix='cli_test_chaos_ws', location='eastus') + def test_workspace_lifecycle(self, resource_group): + self.kwargs.update({ + 'ws': self.create_random_name('ws', 15), + 'loc': 'eastus', + }) + + # ── create ────────────────────────────────────────────────────── + self.cmd( + 'chaos workspace create -n {ws} -g {rg} -l {loc} ' + '--identity-type SystemAssigned ' + '--scopes /subscriptions/{sub}/resourceGroups/{rg}', + checks=[ + self.check('name', '{ws}'), + self.check('location', '{loc}'), + self.check('properties.provisioningState', 'Succeeded'), + self.exists('identity'), + self.check('identity.type', 'SystemAssigned'), + ], + ) + + # ── show ──────────────────────────────────────────────────────── + self.cmd( + 'chaos workspace show -n {ws} -g {rg}', + checks=[ + self.check('name', '{ws}'), + self.check('properties.provisioningState', 'Succeeded'), + ], + ) + + # ── list (by resource group) ──────────────────────────────────── + list_result = self.cmd( + 'chaos workspace list -g {rg}', + checks=[ + self.greater_than('length(@)', 0), + ], + ).get_output_in_json() + self.assertTrue( + any(w['name'] == self.kwargs['ws'] for w in list_result), + 'Created workspace not found in list output', + ) + + # ── update (add tags) ─────────────────────────────────────────── + self.cmd( + 'chaos workspace update -n {ws} -g {rg} ' + '--tags env=test purpose=e5', + checks=[ + self.check('name', '{ws}'), + self.check('tags.env', 'test'), + self.check('tags.purpose', 'e5'), + ], + ) + + # ── refresh-recommendations ───────────────────────────────────── + self.cmd( + 'chaos workspace refresh-recommendations -n {ws} -g {rg}', + ) + + # ── evaluate-scenarios (alias — same handler) ─────────────────── + self.cmd( + 'chaos workspace evaluate-scenarios -n {ws} -g {rg}', + ) + + # ── show-discovery ────────────────────────────────────────────── + self.cmd( + 'chaos workspace show-discovery -n {ws} -g {rg}', + checks=[ + self.exists('properties.status'), + ], + ) + + # ── show-evaluation ───────────────────────────────────────────── + self.cmd( + 'chaos workspace show-evaluation -n {ws} -g {rg}', + checks=[ + self.exists('properties.status'), + ], + ) + + # ── delete ────────────────────────────────────────────────────── + self.cmd( + 'chaos workspace delete -n {ws} -g {rg} --yes', + ) + + # Verify deletion + list_after = self.cmd( + 'chaos workspace list -g {rg}', + ).get_output_in_json() + self.assertFalse( + any(w['name'] == self.kwargs['ws'] for w in list_after), + 'Workspace should not appear after deletion', + ) diff --git a/src/chaos/azext_chaos/tests/latest/test_command_registration.py b/src/chaos/azext_chaos/tests/latest/test_command_registration.py new file mode 100644 index 00000000000..bf6cd39a8a5 --- /dev/null +++ b/src/chaos/azext_chaos/tests/latest/test_command_registration.py @@ -0,0 +1,251 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import unittest +from unittest.mock import MagicMock, patch + +from azext_chaos._help import helps + + +class TestHelpEntries(unittest.TestCase): + """Tests for help entries defined in _help.py.""" + + def test_chaos_group_help_exists(self): + self.assertIn('chaos', helps) + + def test_chaos_workspace_group_help_exists(self): + self.assertIn('chaos workspace', helps) + + def test_chaos_scenario_group_help_exists(self): + self.assertIn('chaos scenario', helps) + + def test_chaos_scenario_config_group_help_exists(self): + self.assertIn('chaos scenario config', helps) + + def test_chaos_scenario_run_group_help_exists(self): + self.assertIn('chaos scenario run', helps) + + def test_chaos_discovered_resource_group_help_exists(self): + self.assertIn('chaos discovered-resource', helps) + + def test_alias_help_exists(self): + self.assertIn('chaos workspace evaluate-scenarios', helps) + + def test_alias_help_mentions_canonical_name(self): + alias_help = helps['chaos workspace evaluate-scenarios'] + self.assertIn( + 'Alias of `az chaos workspace refresh-recommendations`', + alias_help + ) + + +class TestCommandRegistration(unittest.TestCase): + """Tests for command registration in commands.py.""" + + def test_load_command_table_callable(self): + from azext_chaos.commands import load_command_table + # Create a mock loader with an empty command_table dict + mock_loader = MagicMock() + mock_loader.command_table = {} + with patch('azext_chaos.commands._register_aaz_subclass_overrides'): + # Should not raise + load_command_table(mock_loader, None) + + def test_alias_registered_via_custom_command(self): + """Both refresh-recommendations and evaluate-scenarios are registered + as custom commands pointing to workspace_refresh_recommendations.""" + from azext_chaos.commands import load_command_table + mock_loader = MagicMock() + mock_loader.command_table = {} + with patch('azext_chaos.commands._register_aaz_subclass_overrides'): + load_command_table(mock_loader, None) + # Verify command_group was called for 'chaos workspace' + call_args = [str(c) for c in mock_loader.command_group.call_args_list] + self.assertTrue(any('chaos workspace' in a for a in call_args)) + # Verify custom_command was called for evaluate-scenarios + ctx = mock_loader.command_group.return_value.__enter__.return_value + custom_cmd_calls = ctx.custom_command.call_args_list + custom_cmd_names = [c.args[0] for c in custom_cmd_calls] + self.assertIn('evaluate-scenarios', custom_cmd_names) + self.assertIn('workspace_refresh_recommendations', + [c.args[1] for c in custom_cmd_calls + if c.args[0] == 'evaluate-scenarios']) + + +class TestParamsLoadable(unittest.TestCase): + """Tests for _params.py load_arguments function.""" + + def test_load_arguments_callable(self): + from azext_chaos._params import load_arguments + mock_loader = MagicMock() + # Should not raise + load_arguments(mock_loader, None) + + +class TestInitModule(unittest.TestCase): + """Tests for __init__.py ChaosCommandsLoader.""" + + def test_command_loader_cls_exists(self): + from azext_chaos import COMMAND_LOADER_CLS + self.assertIsNotNone(COMMAND_LOADER_CLS) + + def test_command_loader_cls_is_az_loader(self): + from azext_chaos import COMMAND_LOADER_CLS + from azure.cli.core import AzCommandsLoader + self.assertTrue(issubclass(COMMAND_LOADER_CLS, AzCommandsLoader)) + + +class TestHelpTextExamples(unittest.TestCase): + """Tests for EPIC-004: help-text examples use correct arg names and friendly placeholders.""" + + def test_workspace_create_example_uses_mi_user_assigned(self): + """E4-T1: workspace create example uses --mi-user-assigned, not --type/--user-assigned-identities.""" + from azext_chaos.aaz.latest.chaos.workspace._create import Create + docstring = Create.__doc__ + self.assertIn('--mi-user-assigned', docstring) + self.assertNotIn('--type UserAssigned', docstring) + self.assertNotIn('--user-assigned-identities', docstring) + + def test_workspace_list_example_no_continuation_token(self): + """E4-T2: workspace list example has no --continuation-token.""" + from azext_chaos.aaz.latest.chaos.workspace._list import List + docstring = List.__doc__ + self.assertNotIn('--continuation-token', docstring) + + def test_scenario_config_create_example_uses_friendly_names(self): + """E4-T3: scenario config create uses ZoneDown-1.0, not UUID.""" + from azext_chaos.aaz.latest.chaos.scenario.config._create import Create + docstring = Create.__doc__ + self.assertIn('ZoneDown-1.0', docstring) + self.assertNotIn('12345678-1234-1234-1234-123456789012', docstring) + + def test_scenario_config_create_example_no_scenario_id(self): + """E4-T3: scenario config create example does not include --scenario-id.""" + from azext_chaos.aaz.latest.chaos.scenario.config._create import Create + docstring = Create.__doc__ + self.assertNotIn('--scenario-id', docstring) + + def test_scenario_config_list_example_uses_friendly_name(self): + """E4-T5: scenario config list uses ZoneDown-1.0, not UUID.""" + from azext_chaos.aaz.latest.chaos.scenario.config._list import List + docstring = List.__doc__ + self.assertIn('ZoneDown-1.0', docstring) + self.assertNotIn('12345678-1234-1234-1234-123456789012', docstring) + + def test_scenario_config_show_example_uses_friendly_name(self): + """E4-T6: scenario config show uses ZoneDown-1.0, not UUID.""" + from azext_chaos.aaz.latest.chaos.scenario.config._show import Show + docstring = Show.__doc__ + self.assertIn('ZoneDown-1.0', docstring) + self.assertNotIn('12345678-1234-1234-1234-123456789012', docstring) + + def test_fix_permissions_example_uses_correct_command_name(self): + """E4-T7: fix-permissions example uses fix-permissions, not fix-resource-permission.""" + from azext_chaos.aaz.latest.chaos.scenario.config._fix_permissions import FixPermissions + docstring = FixPermissions.__doc__ + self.assertIn('fix-permissions', docstring) + self.assertNotIn('fix-resource-permission', docstring) + self.assertIn('ZoneDown-1.0', docstring) + + def test_scenario_run_show_example_uses_friendly_name(self): + """E4-T8: scenario run show uses ZoneDown-1.0, not UUID.""" + from azext_chaos.aaz.latest.chaos.scenario.run._show import Show + docstring = Show.__doc__ + self.assertIn('ZoneDown-1.0', docstring) + self.assertNotIn('12345678-1234-1234-1234-123456789012', docstring) + + def test_scenario_run_list_example_uses_friendly_name(self): + """E4-T9: scenario run list uses ZoneDown-1.0, not UUID.""" + from azext_chaos.aaz.latest.chaos.scenario.run._list import List + docstring = List.__doc__ + self.assertIn('ZoneDown-1.0', docstring) + self.assertNotIn('12345678-1234-1234-1234-123456789012', docstring) + + +class TestScenarioIdAutoDerivation(unittest.TestCase): + """Tests for E4-T4: --scenario-id auto-derivation via custom subclass. + + The auto-derivation runs in :class:`azext_chaos.custom.ScenarioConfigCreate` + (an :class:`AAZCommand` subclass registered in commands.py) so that the + generated ``aaz/latest/.../config/_create.py`` module stays pristine. + """ + + def _make_create_cmd(self, scenario_id, resource_group, workspace_name, scenario_name, subscription_id): + """Create a minimally-initialized ScenarioConfigCreate instance for testing pre_operations.""" + from azext_chaos.custom import ScenarioConfigCreate + from azure.cli.core.aaz._arg import AAZUndefined + cmd = ScenarioConfigCreate.__new__(ScenarioConfigCreate) + cmd.ctx = MagicMock() + cmd.ctx.subscription_id = subscription_id + args = MagicMock() + # AAZ uses AAZUndefined for unset args, not None + args.scenario_id = scenario_id if scenario_id is not None else AAZUndefined + args.resource_group = resource_group + args.workspace_name = workspace_name + args.scenario_name = scenario_name + cmd.ctx.args = args + return cmd, args + + def test_pre_operations_synthesizes_scenario_id_when_not_provided(self): + """pre_operations sets scenario_id from other args when not explicitly provided.""" + cmd, args = self._make_create_cmd( + scenario_id=None, # Will be set to AAZUndefined + resource_group='myRG', + workspace_name='myWorkspace', + scenario_name='ZoneDown-1.0', + subscription_id='11111111-2222-3333-4444-555555555555', + ) + + cmd.pre_operations() + + expected_id = ( + '/subscriptions/11111111-2222-3333-4444-555555555555' + '/resourceGroups/myRG/providers/Microsoft.Chaos' + '/workspaces/myWorkspace/scenarios/ZoneDown-1.0' + ) + self.assertEqual(args.scenario_id, expected_id) + + def test_pre_operations_preserves_explicit_scenario_id(self): + """pre_operations does not overwrite an explicitly provided scenario_id.""" + explicit_id = '/subscriptions/aaaa/resourceGroups/rg/providers/Microsoft.Chaos/workspaces/ws/scenarios/sc' + cmd, args = self._make_create_cmd( + scenario_id=explicit_id, + resource_group='myRG', + workspace_name='myWorkspace', + scenario_name='ZoneDown-1.0', + subscription_id='11111111-2222-3333-4444-555555555555', + ) + + cmd.pre_operations() + + self.assertEqual(args.scenario_id, explicit_id) + + def test_aaz_create_pre_operations_is_pristine(self): + """The AAZ-generated Create.pre_operations must be a no-op (pristine). + + Auto-derivation lives in the ScenarioConfigCreate subclass; the + generated module must contain no hand-edits. + """ + from azext_chaos.aaz.latest.chaos.scenario.config._create import Create + from azure.cli.core.aaz._arg import AAZUndefined + cmd = Create.__new__(Create) + cmd.callbacks = {} + cmd.ctx = MagicMock() + cmd.ctx.subscription_id = 'unused' + args = MagicMock() + args.scenario_id = AAZUndefined + args.resource_group = 'rg' + args.workspace_name = 'ws' + args.scenario_name = 'sn' + cmd.ctx.args = args + + cmd.pre_operations() + + # Pristine pre_operations must not mutate args.scenario_id. + self.assertEqual(args.scenario_id, AAZUndefined) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/chaos/azext_chaos/tests/latest/test_custom_commands.py b/src/chaos/azext_chaos/tests/latest/test_custom_commands.py new file mode 100644 index 00000000000..111ab793920 --- /dev/null +++ b/src/chaos/azext_chaos/tests/latest/test_custom_commands.py @@ -0,0 +1,1311 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import unittest +from collections import OrderedDict +from unittest.mock import MagicMock, patch, PropertyMock + +from knack.util import CLIError + +# Knack prefixes logger names with 'cli.' — this is the logger name used +# by custom.py via ``get_logger(__name__)``. +_LOGGER_NAME = 'cli.azext_chaos.custom' + + +# ── Helpers ────────────────────────────────────────────────────────────── + +def _make_cmd(): + """Create a mock cmd object with cli_ctx.""" + cmd = MagicMock() + cmd.cli_ctx = MagicMock() + return cmd + + +def _make_response(json_body=None, status_code=200, headers=None, text="{}"): + """Create a mock HTTP response.""" + resp = MagicMock() + resp.json.return_value = json_body or {} + resp.status_code = status_code + resp.headers = headers or {} + resp.text = text + return resp + + +_EVAL_ERROR_MSG = ( + "Cannot validate the scenario configuration cfg1 for scenario s1 " + "in workspace ws1 because the scenario is not evaluated yet. " + "Please wait for the evaluation process to complete." +) + + +def _successful_validation_result(): + return { + "name": "latest", + "properties": { + "status": "Succeeded", + "startTime": "2026-01-01T00:00:00Z", + "endTime": "2026-01-01T00:01:00Z", + "errors": [], + "validationErrors": {"errors": []}, + }, + } + + +def _failed_validation_result(): + return { + "name": "latest", + "properties": { + "status": "Failed", + "startTime": "2026-01-01T00:00:00Z", + "endTime": "2026-01-01T00:01:00Z", + "errors": [{"code": "PermissionError", "message": "Missing role"}], + "validationErrors": {"errors": []}, + }, + } + + +# ── workspace refresh-recommendations ──────────────────────────────────── + +class TestWorkspaceRefreshRecommendations(unittest.TestCase): + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_success_message(self, mock_send, mock_poll_or_return): + """E3-T1: verify success message content.""" + from azext_chaos.custom import workspace_refresh_recommendations + + mock_send.return_value = _make_response() + mock_poll_or_return.return_value = None + + cmd = _make_cmd() + with self.assertLogs(_LOGGER_NAME, level='WARNING') as cm: + result = workspace_refresh_recommendations(cmd, 'myRG', 'myWS') + + self.assertIsNone(result) + log_output = '\n'.join(cm.output) + self.assertIn('myWS', log_output) + self.assertIn('myRG', log_output) + self.assertIn('evaluation', log_output.lower()) + self.assertIn("az chaos scenario list", log_output) + + @patch('azext_chaos.custom.send_raw_request') + def test_no_wait_returns_immediately(self, mock_send): + """E3-T1: --no-wait returns without polling.""" + from azext_chaos.custom import workspace_refresh_recommendations + + mock_send.return_value = _make_response(json_body={"status": "accepted"}) + + cmd = _make_cmd() + result = workspace_refresh_recommendations( + cmd, 'myRG', 'myWS', no_wait=True + ) + self.assertEqual(result, {"status": "accepted"}) + + def test_evaluate_scenarios_alias_registered(self): + """E3-T1: evaluate-scenarios is registered to the same handler in commands.py.""" + from azext_chaos.commands import load_command_table + mock_loader = MagicMock() + mock_loader.command_table = {} + with patch('azext_chaos.commands._register_aaz_subclass_overrides'): + load_command_table(mock_loader, None) + # Verify command_group was called for 'chaos workspace' + call_args = [str(c) for c in mock_loader.command_group.call_args_list] + self.assertTrue(any('chaos workspace' in a for a in call_args)) + # Verify custom_command was called for evaluate-scenarios + ctx = mock_loader.command_group.return_value.__enter__.return_value + custom_cmd_calls = ctx.custom_command.call_args_list + custom_cmd_names = [c.args[0] for c in custom_cmd_calls] + self.assertIn('evaluate-scenarios', custom_cmd_names) + self.assertIn('workspace_refresh_recommendations', + [c.args[1] for c in custom_cmd_calls + if c.args[0] == 'evaluate-scenarios']) + + +# ── scenario config validate ───────────────────────────────────────────── + +class TestScenarioConfigValidate(unittest.TestCase): + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_auto_fetch_validation_result(self, mock_send, mock_poll_or_return): + """E3-T2: default mode fetches validations/latest.""" + from azext_chaos.custom import scenario_config_validate + + mock_poll_or_return.return_value = None + # First call = POST validate, second call = GET validations/latest + mock_send.side_effect = [ + _make_response(), # POST + _make_response(json_body=_successful_validation_result()), # GET + ] + + cmd = _make_cmd() + result = scenario_config_validate( + cmd, 'myRG', 'myWS', 'ZoneDown', 'zone1' + ) + + self.assertEqual(result["properties"]["status"], "Succeeded") + self.assertEqual(mock_send.call_count, 2) + + @patch('azext_chaos.custom.send_raw_request') + def test_no_wait_skips_auto_fetch(self, mock_send): + """E3-T2: --no-wait returns without fetching validation result.""" + from azext_chaos.custom import scenario_config_validate + + mock_send.return_value = _make_response() + cmd = _make_cmd() + + with self.assertLogs(_LOGGER_NAME, level='WARNING') as cm: + scenario_config_validate( + cmd, 'myRG', 'myWS', 'ZoneDown', 'zone1', no_wait=True + ) + + self.assertEqual(mock_send.call_count, 1) + log_output = '\n'.join(cm.output) + self.assertIn('show-validation', log_output) + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_evaluation_error_rewriting(self, mock_send, mock_poll_or_return): + """E3-T2: evaluation-state error is rewritten with actionable hint.""" + from azext_chaos.custom import scenario_config_validate + + mock_poll_or_return.return_value = None + validation_result = { + "name": "latest", + "properties": { + "status": "Failed", + "errors": [{ + "code": "ResourceStateNotReady", + "message": _EVAL_ERROR_MSG, + }], + "validationErrors": {"errors": []}, + }, + } + mock_send.side_effect = [ + _make_response(), # POST + _make_response(json_body=validation_result), # GET + ] + + cmd = _make_cmd() + with self.assertRaises(CLIError) as ctx: + scenario_config_validate(cmd, 'myRG', 'myWS', 'ZoneDown', 'zone1') + + error_msg = str(ctx.exception) + self.assertIn('refresh-recommendations', error_msg) + self.assertIn('evaluate-scenarios', error_msg) + self.assertIn('show-evaluation', error_msg) + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_evaluation_error_in_validation_errors(self, mock_send, mock_poll_or_return): + """E3-T2: evaluation error in validationErrors is also detected.""" + from azext_chaos.custom import scenario_config_validate + + mock_poll_or_return.return_value = None + validation_result = { + "name": "latest", + "properties": { + "status": "Failed", + "errors": [], + "validationErrors": { + "errors": [{ + "code": "ResourceStateNotReady", + "message": _EVAL_ERROR_MSG, + }], + }, + }, + } + mock_send.side_effect = [ + _make_response(), # POST + _make_response(json_body=validation_result), # GET + ] + + cmd = _make_cmd() + with self.assertRaises(CLIError) as ctx: + scenario_config_validate(cmd, 'myRG', 'myWS', 'ZoneDown', 'zone1') + + self.assertIn('refresh-recommendations', str(ctx.exception)) + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_non_succeeded_status_triggers_eval_check(self, mock_send, mock_poll_or_return): + """E3-T2: non-standard status (e.g., Canceled) is also treated as failure.""" + from azext_chaos.custom import scenario_config_validate + + mock_poll_or_return.return_value = None + validation_result = { + "name": "latest", + "properties": { + "status": "Canceled", + "errors": [], + "validationErrors": {"errors": []}, + }, + } + mock_send.side_effect = [ + _make_response(), # POST + _make_response(json_body=validation_result), # GET + ] + + cmd = _make_cmd() + # Should return the result (no eval error, but status != Succeeded + # triggers eval check; no eval error found so result is returned) + result = scenario_config_validate(cmd, 'myRG', 'myWS', 'ZoneDown', 'zone1') + self.assertEqual(result["properties"]["status"], "Canceled") + + +# ── scenario run start ─────────────────────────────────────────────────── + +class TestScenarioRunStart(unittest.TestCase): + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_default_validates_then_executes(self, mock_send, mock_poll_or_return): + """E3-T3: default mode calls validate, then execute.""" + from azext_chaos.custom import scenario_run_start + + run_result = { + "name": "run-id-123", + "properties": {"status": "Running"}, + } + mock_send.side_effect = [ + _make_response(), # POST validate + _make_response(json_body=_successful_validation_result()), # GET val + _make_response(headers={"Location": "/runs/run-id-123"}), # POST exec + ] + # _poll_or_return called twice: once for validate, once for execute + mock_poll_or_return.side_effect = [None, run_result] + + cmd = _make_cmd() + with self.assertLogs(_LOGGER_NAME, level='WARNING') as cm: + scenario_run_start( + cmd, 'myRG', 'myWS', 'ZoneDown', 'zone1' + ) + + # 3 calls: POST validate, GET validations/latest, POST execute + self.assertEqual(mock_send.call_count, 3) + log_output = '\n'.join(cm.output) + self.assertIn('run-id-123', log_output) + self.assertIn('scenario run show', log_output) + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_skip_validation_skips_validate(self, mock_send, mock_poll_or_return): + """E3-T3: --skip-validation goes directly to execute.""" + from azext_chaos.custom import scenario_run_start + + run_result = { + "name": "run-id-456", + "properties": {"status": "Running"}, + } + mock_send.side_effect = [ + _make_response(headers={"Location": "/runs/run-id-456"}), # POST exec + ] + mock_poll_or_return.return_value = run_result + + cmd = _make_cmd() + with self.assertLogs(_LOGGER_NAME, level='WARNING'): + scenario_run_start( + cmd, 'myRG', 'myWS', 'ZoneDown', 'zone1', + skip_validation=True + ) + + # Only 1 call: POST execute + self.assertEqual(mock_send.call_count, 1) + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_validation_failure_aborts(self, mock_send, mock_poll_or_return): + """E3-T3: validation failure exits non-zero, no execute.""" + from azext_chaos.custom import scenario_run_start + + mock_poll_or_return.return_value = None + mock_send.side_effect = [ + _make_response(), # POST validate + _make_response(json_body=_failed_validation_result()), # GET val + ] + + cmd = _make_cmd() + with self.assertRaises(CLIError) as ctx: + scenario_run_start(cmd, 'myRG', 'myWS', 'ZoneDown', 'zone1') + + self.assertIn('Validation failed', str(ctx.exception)) + # Only 2 calls: POST validate, GET validations/latest — no execute + self.assertEqual(mock_send.call_count, 2) + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_no_wait_returns_run_id_from_location(self, mock_send, mock_poll_or_return): + """E3-T3: --no-wait parses run ID from Location header.""" + from azext_chaos.custom import scenario_run_start + + location_url = ( + "/subscriptions/sub1/resourceGroups/myRG/" + "providers/Microsoft.Chaos/workspaces/myWS/" + "scenarios/ZoneDown/scenarioRuns/run-abc-123" + "?api-version=2026-05-01-preview" + ) + mock_poll_or_return.return_value = None + mock_send.side_effect = [ + _make_response(), # POST validate + _make_response(json_body=_successful_validation_result()), # GET val + _make_response(headers={"Location": location_url}), # POST exec + ] + + cmd = _make_cmd() + with self.assertLogs(_LOGGER_NAME, level='WARNING') as cm: + result = scenario_run_start( + cmd, 'myRG', 'myWS', 'ZoneDown', 'zone1', + no_wait=True + ) + + self.assertEqual(result["runId"], "run-abc-123") + log_output = '\n'.join(cm.output) + self.assertIn('run-abc-123', log_output) + + @patch('azext_chaos.custom.send_raw_request') + def test_skip_validation_no_wait(self, mock_send): + """E3-T3: --skip-validation --no-wait is fire-and-forget.""" + from azext_chaos.custom import scenario_run_start + + location_url = ( + "/subscriptions/sub1/resourceGroups/myRG/" + "providers/Microsoft.Chaos/workspaces/myWS/" + "scenarios/ZoneDown/scenarioRuns/run-fast-789" + ) + mock_send.side_effect = [ + _make_response(headers={"Location": location_url}), # POST exec + ] + + cmd = _make_cmd() + with self.assertLogs(_LOGGER_NAME, level='WARNING'): + result = scenario_run_start( + cmd, 'myRG', 'myWS', 'ZoneDown', 'zone1', + skip_validation=True, no_wait=True + ) + + # Only 1 call: POST execute + self.assertEqual(mock_send.call_count, 1) + self.assertEqual(result["runId"], "run-fast-789") + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_evaluation_error_in_preflight(self, mock_send, mock_poll_or_return): + """E3-T3: evaluation-state error during pre-flight.""" + from azext_chaos.custom import scenario_run_start + + mock_poll_or_return.return_value = None + validation_result = { + "name": "latest", + "properties": { + "status": "Failed", + "errors": [{ + "code": "ResourceStateNotReady", + "message": _EVAL_ERROR_MSG, + }], + "validationErrors": {"errors": []}, + }, + } + mock_send.side_effect = [ + _make_response(), # POST validate + _make_response(json_body=validation_result), # GET val + ] + + cmd = _make_cmd() + with self.assertRaises(CLIError) as ctx: + scenario_run_start(cmd, 'myRG', 'myWS', 'ZoneDown', 'zone1') + + error_msg = str(ctx.exception) + self.assertIn('refresh-recommendations', error_msg) + self.assertIn('evaluate-scenarios', error_msg) + # Should not proceed to execute + self.assertEqual(mock_send.call_count, 2) + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_run_id_surfacing_full_poll(self, mock_send, mock_poll_or_return): + """E3-T3: run ID is read from ScenarioRun body after full LRO poll.""" + from azext_chaos.custom import scenario_run_start + + run_result = { + "name": "polled-run-id", + "id": "/subscriptions/sub1/providers/.../scenarioRuns/polled-run-id", + "properties": {"status": "Running"}, + } + mock_send.side_effect = [ + _make_response(), # POST validate + _make_response(json_body=_successful_validation_result()), # GET val + _make_response(headers={"Location": "/runs/polled-run-id"}), # POST exec + ] + mock_poll_or_return.side_effect = [None, run_result] + + cmd = _make_cmd() + with self.assertLogs(_LOGGER_NAME, level='WARNING') as cm: + result = scenario_run_start( + cmd, 'myRG', 'myWS', 'ZoneDown', 'zone1' + ) + + self.assertEqual(result["name"], "polled-run-id") + log_output = '\n'.join(cm.output) + self.assertIn('polled-run-id', log_output) + self.assertIn('scenario run show', log_output) + + +# ── scenario run cancel ────────────────────────────────────────────────── + +class TestScenarioRunCancel(unittest.TestCase): + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_cancel_success_message(self, mock_send, mock_poll_or_return): + """E3-T4: verify cancellation confirmation message.""" + from azext_chaos.custom import scenario_run_cancel + + mock_send.return_value = _make_response() + mock_poll_or_return.return_value = None + + cmd = _make_cmd() + with self.assertLogs(_LOGGER_NAME, level='WARNING') as cm: + result = scenario_run_cancel( + cmd, 'myRG', 'myWS', 'ZoneDown', + '12345678-1234-1234-1234-123456789012' + ) + + self.assertIsNone(result) + log_output = '\n'.join(cm.output) + self.assertIn('12345678-1234-1234-1234-123456789012', log_output) + self.assertIn('cancelled', log_output.lower()) + + @patch('azext_chaos.custom.send_raw_request') + def test_cancel_no_wait(self, mock_send): + """E3-T4: --no-wait returns immediately.""" + from azext_chaos.custom import scenario_run_cancel + + mock_send.return_value = _make_response( + json_body={"status": "cancelling"}, text='{"status": "cancelling"}' + ) + + cmd = _make_cmd() + result = scenario_run_cancel( + cmd, 'myRG', 'myWS', 'ZoneDown', + '12345678-1234-1234-1234-123456789012', + no_wait=True + ) + self.assertEqual(result, {"status": "cancelling"}) + + +# ── Internal helpers ───────────────────────────────────────────────────── + +class TestInternalHelpers(unittest.TestCase): + + def test_is_evaluation_error_true(self): + from azext_chaos.custom import _is_evaluation_error + self.assertTrue(_is_evaluation_error(_EVAL_ERROR_MSG)) + + def test_is_evaluation_error_false(self): + from azext_chaos.custom import _is_evaluation_error + self.assertFalse(_is_evaluation_error("Permission denied")) + + def test_is_evaluation_error_none(self): + from azext_chaos.custom import _is_evaluation_error + self.assertFalse(_is_evaluation_error(None)) + + def test_extract_run_id_from_location(self): + from azext_chaos.custom import _extract_run_id_from_location + url = ( + "/subscriptions/sub1/resourceGroups/myRG/" + "providers/Microsoft.Chaos/workspaces/myWS/" + "scenarios/ZoneDown/scenarioRuns/my-run-id" + "?api-version=2026-05-01-preview" + ) + self.assertEqual(_extract_run_id_from_location(url), "my-run-id") + + def test_extract_run_id_empty(self): + from azext_chaos.custom import _extract_run_id_from_location + self.assertIsNone(_extract_run_id_from_location("")) + self.assertIsNone(_extract_run_id_from_location(None)) + + def test_make_evaluation_hint_content(self): + from azext_chaos.custom import _make_evaluation_hint + hint = _make_evaluation_hint("myWS", "myRG") + self.assertIn("refresh-recommendations", hint) + self.assertIn("evaluate-scenarios", hint) + self.assertIn("show-evaluation", hint) + self.assertIn("myWS", hint) + self.assertIn("myRG", hint) + + +class TestTableFormat(unittest.TestCase): + + def test_validation_show_table_format_no_mutation(self): + """validation_show_table_format must not inject keys into the input dict.""" + from azext_chaos._table_format import validation_show_table_format + result = { + "name": "latest", + "properties": { + "status": "Succeeded", + "startTime": "2026-01-01T00:00:00Z", + "endTime": "2026-01-01T00:01:00Z", + "errors": [], + "validationErrors": {"errors": []}, + }, + } + original_keys = set(result.keys()) + validation_show_table_format(result) + self.assertEqual(set(result.keys()), original_keys) + + +# ── Help entries ───────────────────────────────────────────────────────── + +class TestHelpEntries(unittest.TestCase): + + def test_refresh_recommendations_help_exists(self): + from azext_chaos._help import helps + self.assertIn('chaos workspace refresh-recommendations', helps) + + def test_validate_help_exists(self): + from azext_chaos._help import helps + self.assertIn('chaos scenario config validate', helps) + + def test_run_start_help_exists(self): + from azext_chaos._help import helps + self.assertIn('chaos scenario run start', helps) + help_text = helps['chaos scenario run start'] + self.assertIn('--skip-validation', help_text) + self.assertIn('--no-wait', help_text) + + def test_run_start_help_shows_all_four_pairings(self): + from azext_chaos._help import helps + help_text = helps['chaos scenario run start'] + # Verify all 4 example pairings exist + self.assertIn('--skip-validation --no-wait', help_text) + # Check that individual flags appear in examples + examples_with_skip = help_text.count('--skip-validation') + examples_with_no_wait = help_text.count('--no-wait') + self.assertGreaterEqual(examples_with_skip, 3) # 2 examples + long desc + self.assertGreaterEqual(examples_with_no_wait, 3) + + def test_run_cancel_help_exists(self): + from azext_chaos._help import helps + self.assertIn('chaos scenario run cancel', helps) + + def test_evaluate_scenarios_alias_help(self): + from azext_chaos._help import helps + self.assertIn('chaos workspace evaluate-scenarios', helps) + self.assertIn( + 'Alias of `az chaos workspace refresh-recommendations`', + helps['chaos workspace evaluate-scenarios'] + ) + + +# ── _poll_or_return unit tests ─────────────────────────────────────────── + +class TestPollOrReturn(unittest.TestCase): + + def test_200_returns_json_body(self): + """E2-T11: status 200 returns JSON body directly (no polling).""" + from azext_chaos.custom import _poll_or_return + cmd = _make_cmd() + resp = _make_response(json_body={"key": "value"}, status_code=200, + text='{"key": "value"}') + result = _poll_or_return(cmd, resp) + self.assertEqual(result, {"key": "value"}) + + def test_200_empty_text_returns_none(self): + """E2-T12: status 200 with empty text returns None.""" + from azext_chaos.custom import _poll_or_return + cmd = _make_cmd() + resp = _make_response(status_code=200, text="") + result = _poll_or_return(cmd, resp) + self.assertIsNone(result) + + @patch('azext_chaos.custom.time.sleep') + @patch('azext_chaos.custom.send_raw_request') + def test_202_async_operation_polls_until_succeeded(self, mock_send, mock_sleep): + """E2-T13: status 202 + Azure-AsyncOperation polls until Succeeded.""" + from azext_chaos.custom import _poll_or_return + cmd = _make_cmd() + initial = _make_response( + status_code=202, + headers={"Azure-AsyncOperation": "https://mgmt.azure.com/poll"}, + text="" + ) + # First poll: InProgress, second poll: Succeeded + mock_send.side_effect = [ + _make_response(json_body={"status": "InProgress"}, text='{"status": "InProgress"}'), + _make_response(json_body={"status": "Succeeded"}, text='{"status": "Succeeded"}'), + ] + result = _poll_or_return(cmd, initial) + self.assertEqual(result, {"status": "Succeeded"}) + self.assertEqual(mock_send.call_count, 2) + + @patch('azext_chaos.custom.time.sleep') + @patch('azext_chaos.custom.send_raw_request') + def test_202_location_polls_until_200(self, mock_send, mock_sleep): + """E2-T14: status 202 + Location polls until 200.""" + from azext_chaos.custom import _poll_or_return + cmd = _make_cmd() + initial = _make_response( + status_code=202, + headers={"Location": "https://mgmt.azure.com/location"}, + text="" + ) + # First poll: 202, second poll: 200 + mock_send.side_effect = [ + _make_response(status_code=202, headers={}, text=""), + _make_response(status_code=200, json_body={"done": True}, text='{"done": true}'), + ] + result = _poll_or_return(cmd, initial) + self.assertEqual(result, {"done": True}) + self.assertEqual(mock_send.call_count, 2) + + @patch('azext_chaos.custom.time.sleep') + @patch('azext_chaos.custom.send_raw_request') + def test_202_async_operation_failed_raises(self, mock_send, mock_sleep): + """E2-T15: status 202 + Azure-AsyncOperation, terminal Failed raises CLIError.""" + from azext_chaos.custom import _poll_or_return + cmd = _make_cmd() + initial = _make_response( + status_code=202, + headers={"Azure-AsyncOperation": "https://mgmt.azure.com/poll"}, + text="" + ) + mock_send.return_value = _make_response( + json_body={"status": "Failed", "error": {"message": "something broke"}}, + text='{"status": "Failed"}' + ) + with self.assertRaises(CLIError) as ctx: + _poll_or_return(cmd, initial) + self.assertIn("something broke", str(ctx.exception)) + + @patch('azext_chaos.custom.time.sleep') + @patch('azext_chaos.custom.send_raw_request') + def test_202_both_headers_async_then_location(self, mock_send, mock_sleep): + """E2-T16: both headers — uses Azure-AsyncOperation, then final GET to Location.""" + from azext_chaos.custom import _poll_or_return + cmd = _make_cmd() + initial = _make_response( + status_code=202, + headers={ + "Azure-AsyncOperation": "https://mgmt.azure.com/poll", + "Location": "https://mgmt.azure.com/resource", + }, + text="" + ) + # Async poll succeeds, then final GET to Location + mock_send.side_effect = [ + _make_response(json_body={"status": "Succeeded"}, text='{"status": "Succeeded"}'), + _make_response(json_body={"id": "resource-1"}, text='{"id": "resource-1"}'), + ] + result = _poll_or_return(cmd, initial) + self.assertEqual(result, {"id": "resource-1"}) + # Verify second call was to Location URL + self.assertEqual(mock_send.call_count, 2) + second_call_url = mock_send.call_args_list[1][0][2] + self.assertEqual(second_call_url, "https://mgmt.azure.com/resource") + + def test_202_no_polling_headers_returns_body(self): + """E2-T17: status 202 + no polling headers returns body (sync fallback).""" + from azext_chaos.custom import _poll_or_return + cmd = _make_cmd() + resp = _make_response( + status_code=202, json_body={"accepted": True}, + headers={}, text='{"accepted": true}' + ) + result = _poll_or_return(cmd, resp) + self.assertEqual(result, {"accepted": True}) + + def test_500_raises_cli_error(self): + """E2-T18: status 500 raises CLIError with error body.""" + from azext_chaos.custom import _poll_or_return + cmd = _make_cmd() + resp = _make_response( + status_code=500, + json_body={"error": {"message": "Internal server error"}}, + text='{"error": {"message": "Internal server error"}}' + ) + with self.assertRaises(CLIError) as ctx: + _poll_or_return(cmd, resp) + self.assertIn("Internal server error", str(ctx.exception)) + self.assertIn("500", str(ctx.exception)) + + @patch('azext_chaos.custom.time.sleep') + @patch('azext_chaos.custom.send_raw_request') + @patch('azext_chaos.custom._LRO_TIMEOUT_SECONDS', 10) + @patch('azext_chaos.custom._LRO_POLL_INTERVAL_SECONDS', 11) + def test_async_operation_timeout_raises(self, mock_send, mock_sleep): + """Timeout after _LRO_TIMEOUT_SECONDS raises CLIError.""" + from azext_chaos.custom import _poll_or_return + cmd = _make_cmd() + initial = _make_response( + status_code=202, + headers={"Azure-AsyncOperation": "https://mgmt.azure.com/poll"}, + text="" + ) + # Always return InProgress + mock_send.return_value = _make_response( + json_body={"status": "InProgress"}, text='{"status": "InProgress"}' + ) + with self.assertRaises(CLIError) as ctx: + _poll_or_return(cmd, initial) + self.assertIn("timed out", str(ctx.exception)) + + @patch('azext_chaos.custom.time.sleep') + @patch('azext_chaos.custom.send_raw_request') + def test_location_poll_bad_retry_after_ignored(self, mock_send, mock_sleep): + """Non-integer Retry-After header (e.g. HTTP-date) is silently ignored.""" + from azext_chaos.custom import _poll_or_return + cmd = _make_cmd() + initial = _make_response( + status_code=202, + headers={"Location": "https://mgmt.azure.com/location"}, + text="" + ) + # First poll: 202 with HTTP-date Retry-After, second poll: 200 + mock_send.side_effect = [ + _make_response( + status_code=202, + headers={"Retry-After": "Tue, 19 May 2026 00:00:00 GMT"}, + text="" + ), + _make_response( + status_code=200, + json_body={"done": True}, + text='{"done": true}' + ), + ] + result = _poll_or_return(cmd, initial) + self.assertEqual(result, {"done": True}) + + +# ── Integration-level regression tests ─────────────────────────────────── + +class TestLROIntegrationRegression(unittest.TestCase): + + @patch('azext_chaos.custom.send_raw_request') + def test_workspace_refresh_200_no_attribute_error(self, mock_send): + """E2-T19: workspace_refresh_recommendations with 200 completes + without the old AttributeError from LongRunningOperation.""" + from azext_chaos.custom import workspace_refresh_recommendations + + mock_send.return_value = _make_response(status_code=200, text="") + cmd = _make_cmd() + with self.assertLogs(_LOGGER_NAME, level='WARNING'): + result = workspace_refresh_recommendations(cmd, 'myRG', 'myWS') + self.assertIsNone(result) + + @patch('azext_chaos.custom.time.sleep') + @patch('azext_chaos.custom.send_raw_request') + def test_scenario_run_start_202_location_polls(self, mock_send, mock_sleep): + """E2-T20: scenario_run_start with 202 + Location header polls correctly.""" + from azext_chaos.custom import scenario_run_start + + run_result = {"name": "run-202", "properties": {"status": "Running"}} + mock_send.side_effect = [ + # POST validate → 200 (sync) + _make_response(status_code=200, text=""), + # GET validations/latest + _make_response(json_body=_successful_validation_result()), + # POST execute → 202 + Location + _make_response( + status_code=202, + headers={"Location": "https://mgmt.azure.com/runs/run-202"}, + text="" + ), + # Poll Location → 200 with result + _make_response( + status_code=200, json_body=run_result, + text='{"name": "run-202"}' + ), + ] + + cmd = _make_cmd() + with self.assertLogs(_LOGGER_NAME, level='WARNING') as cm: + result = scenario_run_start( + cmd, 'myRG', 'myWS', 'ZoneDown', 'zone1' + ) + + self.assertEqual(result["name"], "run-202") + log_output = '\n'.join(cm.output) + self.assertIn('run-202', log_output) + +# ── URL segment assertions (EPIC-001) ──────────────────────────────────── + + +class TestUrlSegments(unittest.TestCase): + """Assert every custom command builds ARM URLs with the correct path segments. + + The GW routes use /configurations/{configurationName}/ and /runs/{runName}/, + NOT /scenarioConfigurations/ or /scenarioRuns/. + """ + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_scenario_config_validate_url(self, mock_send, mock_poll_or_return): + """E1-T7: scenario_config_validate uses /configurations/, not /scenarioConfigurations/.""" + from azext_chaos.custom import scenario_config_validate + + mock_poll_or_return.return_value = None + mock_send.side_effect = [ + _make_response(), + _make_response(json_body=_successful_validation_result()), + ] + + scenario_config_validate(_make_cmd(), 'rg', 'ws', 'sc', 'cfg') + + urls = [call.args[2] for call in mock_send.call_args_list] + for url in urls: + self.assertIn('/configurations/', url) + self.assertNotIn('/scenarioConfigurations/', url) + + @patch('azext_chaos.custom.send_raw_request') + def test_scenario_config_show_validation_url(self, mock_send): + """E1-T7: scenario_config_show_validation uses /configurations/.""" + from azext_chaos.custom import scenario_config_show_validation + + mock_send.return_value = _make_response() + scenario_config_show_validation(_make_cmd(), 'rg', 'ws', 'sc', 'cfg') + + url = mock_send.call_args.args[2] + self.assertIn('/configurations/', url) + self.assertNotIn('/scenarioConfigurations/', url) + + @patch('azext_chaos.custom.send_raw_request') + def test_scenario_config_show_permission_fix_url(self, mock_send): + """E1-T7: scenario_config_show_permission_fix uses /configurations/.""" + from azext_chaos.custom import scenario_config_show_permission_fix + + mock_send.return_value = _make_response() + scenario_config_show_permission_fix(_make_cmd(), 'rg', 'ws', 'sc', 'cfg') + + url = mock_send.call_args.args[2] + self.assertIn('/configurations/', url) + self.assertNotIn('/scenarioConfigurations/', url) + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_scenario_run_start_url(self, mock_send, mock_poll_or_return): + """E1-T7: scenario_run_start uses /configurations/, not /scenarioConfigurations/.""" + from azext_chaos.custom import scenario_run_start + + mock_poll_or_return.side_effect = [None, {"name": "r1", "properties": {}}] + mock_send.side_effect = [ + _make_response(), + _make_response(json_body=_successful_validation_result()), + _make_response(headers={"Location": "/runs/r1"}), + ] + + with self.assertLogs(_LOGGER_NAME, level='WARNING'): + scenario_run_start(_make_cmd(), 'rg', 'ws', 'sc', 'cfg') + + urls = [call.args[2] for call in mock_send.call_args_list] + for url in urls: + self.assertIn('/configurations/', url) + self.assertNotIn('/scenarioConfigurations/', url) + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_scenario_run_cancel_url(self, mock_send, mock_poll_or_return): + """E1-T7: scenario_run_cancel uses /runs/, not /scenarioRuns/.""" + from azext_chaos.custom import scenario_run_cancel + + mock_send.return_value = _make_response() + mock_poll_or_return.return_value = None + + with self.assertLogs(_LOGGER_NAME, level='WARNING'): + scenario_run_cancel(_make_cmd(), 'rg', 'ws', 'sc', 'run1') + + url = mock_send.call_args.args[2] + self.assertIn('/runs/', url) + self.assertNotIn('/scenarioRuns/', url) + + +# ── EPIC-003: fix-permissions investigation tests ──────────────────────── + +class TestFixPermissionsPreOperations(unittest.TestCase): + """E3-T5: Tests for fix-permissions pre_operations hint and help text.""" + + def test_fix_permissions_pre_operations_logs_hint(self): + """E3-T5: the runtime command logs an info-level troubleshooting hint. + + The hint moved out of the (now pristine) AAZ ``pre_operations`` and + lives in the custom command ``scenario_config_fix_permissions`` that + supersedes the aaz registration. See README §"Modifying the + AAZ-generated code" and ``CODEGEN_SOURCE.md`` item 7. + """ + import inspect + from azext_chaos.custom import scenario_config_fix_permissions + + source = inspect.getsource(scenario_config_fix_permissions) + self.assertIn('logger.info', source, + "scenario_config_fix_permissions should log a hint about prerequisites") + self.assertIn('404', source, + "Hint should explain the 404 NotFound failure mode") + + def test_fix_permissions_aaz_pre_operations_is_pristine(self): + """The AAZ-generated FixPermissions.pre_operations must be a no-op. + + Behavioral hints belong in the custom command, not the aaz module. + """ + import inspect + from azext_chaos.aaz.latest.chaos.scenario.config._fix_permissions import FixPermissions + + class_source = inspect.getsource(FixPermissions) + self.assertNotIn('logger', class_source, + "FixPermissions class must not reference logger (aaz pristine)") + self.assertNotIn('get_logger', class_source, + "FixPermissions module must not import get_logger (aaz pristine)") + + def test_fix_permissions_help_mentions_no_prior_validation(self): + """E3-T5: help text documents that no prior validation is required.""" + from knack.help_files import helps + # Force module load to register help entries + import azext_chaos._help # noqa: F401 + + help_text = helps.get('chaos scenario config fix-permissions', '') + self.assertIn('NOT required', help_text, + "Help text should state that prior validate is NOT required") + + def test_fix_permissions_help_mentions_404_cause(self): + """E3-T5: help text documents 404 means missing resource.""" + from knack.help_files import helps + import azext_chaos._help # noqa: F401 + + help_text = helps.get('chaos scenario config fix-permissions', '') + self.assertIn('404', help_text, + "Help text should mention 404 NotFound cause") + + def test_fix_permissions_body_shape_is_top_level(self): + """E3-T3: body serializes whatIf at top level, not under properties.""" + from azext_chaos.aaz.latest.chaos.scenario.config._fix_permissions import FixPermissions + import inspect + source = inspect.getsource( + FixPermissions.ScenarioConfigurationsFixResourcePermissions.content.fget + ) + # Body uses client_flatten=True and sets "whatIf" directly + self.assertIn('client_flatten', source) + self.assertIn('"whatIf"', source) + # Should NOT wrap in "properties" + self.assertNotIn('"properties"', source) + + +# ── Regression tests for the test-report follow-ups ────────────────────── + + +class TestScenarioConfigFixPermissions(unittest.TestCase): + """H1 follow-up: custom fix-permissions handler that bypasses the + AAZ-generated LRO poller's mishandling of the SAS-signed Location URL.""" + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_default_polls_then_fetches_latest(self, mock_send, mock_poll): + from azext_chaos.custom import scenario_config_fix_permissions + + mock_poll.return_value = None + latest_body = { + "name": "latest", + "properties": { + "state": "Succeeded", + "summary": "Assigned 1 role", + "whatIfMode": False, + "roleAssignments": [{"roleDefinitionId": "abc"}], + }, + } + mock_send.side_effect = [ + _make_response(status_code=202), # POST fixResourcePermissions + _make_response(json_body=latest_body), # GET .../fixResourcePermissions/latest + ] + + cmd = _make_cmd() + result = scenario_config_fix_permissions( + cmd, 'myRG', 'myWS', 'ZoneDown-1.0', 'cfg-1' + ) + + self.assertEqual(result, latest_body) + # POST + GET = 2 send calls; poll invoked once on the 202. + self.assertEqual(mock_send.call_count, 2) + mock_poll.assert_called_once() + # POST URL hits the right path; body is {"whatIf": false} by default. + post_call = mock_send.call_args_list[0] + post_url = post_call.args[2] + self.assertIn('/fixResourcePermissions?', post_url) + post_body = post_call.kwargs.get('body') + self.assertEqual(post_body, '{"whatIf": false}') + # GET URL is the /latest singleton. + get_url = mock_send.call_args_list[1].args[2] + self.assertIn('/fixResourcePermissions/latest?', get_url) + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_what_if_propagates_to_body(self, mock_send, mock_poll): + from azext_chaos.custom import scenario_config_fix_permissions + + mock_poll.return_value = None + mock_send.side_effect = [ + _make_response(status_code=202), + _make_response(json_body={"properties": {"state": "Succeeded"}}), + ] + + scenario_config_fix_permissions( + _make_cmd(), 'myRG', 'myWS', 'ZoneDown-1.0', 'cfg-1', + what_if=True, + ) + + post_body = mock_send.call_args_list[0].kwargs.get('body') + self.assertEqual(post_body, '{"whatIf": true}') + + @patch('azext_chaos.custom.send_raw_request') + def test_no_wait_skips_poll_and_latest_get(self, mock_send): + from azext_chaos.custom import scenario_config_fix_permissions + + mock_send.return_value = _make_response( + status_code=202, json_body={"status": "InProgress"} + ) + + cmd = _make_cmd() + with self.assertLogs(_LOGGER_NAME, level='WARNING') as cm: + result = scenario_config_fix_permissions( + cmd, 'myRG', 'myWS', 'ZoneDown-1.0', 'cfg-1', + no_wait=True, + ) + + # Only the POST was issued. + self.assertEqual(mock_send.call_count, 1) + self.assertEqual(result, {"status": "InProgress"}) + self.assertIn('show-permission-fix', '\n'.join(cm.output)) + + def test_registered_as_custom_command(self): + """Verify commands.py overrides the AAZ fix-permissions registration.""" + from azext_chaos.commands import load_command_table + mock_loader = MagicMock() + mock_loader.command_table = {} + with patch('azext_chaos.commands._register_aaz_subclass_overrides'): + load_command_table(mock_loader, None) + ctx = mock_loader.command_group.return_value.__enter__.return_value + custom_cmds = [(c.args[0], c.args[1]) + for c in ctx.custom_command.call_args_list] + self.assertIn( + ('fix-permissions', 'scenario_config_fix_permissions'), + custom_cmds, + 'fix-permissions must be registered as a custom command to bypass ' + 'the AAZ-generated LRO poller (test-report finding H1).', + ) + + +class TestRefreshRecommendationsInnerLRO(unittest.TestCase): + """M1 follow-up: surface inner discovery/evaluation failures even when the + outer refreshRecommendations LRO reports Succeeded.""" + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_inner_discovery_failed_raises(self, mock_send, mock_poll): + from azext_chaos.custom import workspace_refresh_recommendations + + mock_poll.return_value = None + failed_discovery = { + "properties": { + "status": "Failed", + "errors": [{ + "errorCode": "ResourceDiscoveryPermissionError", + "errorMessage": "Status: 403 (Forbidden)", + }], + }, + } + mock_send.side_effect = [ + _make_response(status_code=202), # POST refreshRecommendations + _make_response(json_body=failed_discovery), # GET discoveries/latest + _make_response(json_body={"properties": {"status": "Succeeded"}}), # GET evaluations/latest + ] + + with self.assertRaises(CLIError) as ctx: + workspace_refresh_recommendations(_make_cmd(), 'myRG', 'myWS') + + err = str(ctx.exception) + self.assertIn('resource discovery', err) + self.assertIn('ResourceDiscoveryPermissionError', err) + # Friendly hint must point at ARG propagation + self.assertIn('Azure Resource Graph', err) + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_inner_evaluation_failed_raises(self, mock_send, mock_poll): + from azext_chaos.custom import workspace_refresh_recommendations + + mock_poll.return_value = None + mock_send.side_effect = [ + _make_response(status_code=202), # POST + _make_response(json_body={"properties": {"status": "Succeeded"}}), + _make_response(json_body={ + "properties": { + "status": "Failed", + "errors": [{"errorCode": "X", "errorMessage": "boom"}], + } + }), + ] + + with self.assertRaises(CLIError) as ctx: + workspace_refresh_recommendations(_make_cmd(), 'myRG', 'myWS') + + self.assertIn('scenario evaluation', str(ctx.exception)) + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_inner_404_does_not_raise(self, mock_send, mock_poll): + """A /latest 404 on a brand-new workspace must not flip the command + to non-zero — only an explicit Failed inner status does.""" + from azext_chaos.custom import workspace_refresh_recommendations + + mock_poll.return_value = None + # 404 on /latest: empty text → silently treated as "no result yet". + mock_send.side_effect = [ + _make_response(status_code=202), + _make_response(status_code=404, text="", json_body={}), + _make_response(status_code=404, text="", json_body={}), + ] + + # Should not raise. + with self.assertLogs(_LOGGER_NAME, level='WARNING'): + workspace_refresh_recommendations(_make_cmd(), 'myRG', 'myWS') + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_inner_check_skipped_for_no_wait(self, mock_send, mock_poll): + from azext_chaos.custom import workspace_refresh_recommendations + + mock_send.return_value = _make_response(json_body={"status": "accepted"}) + workspace_refresh_recommendations( + _make_cmd(), 'myRG', 'myWS', no_wait=True + ) + + # Only the POST is issued; no inner LRO GETs and no poll. + self.assertEqual(mock_send.call_count, 1) + mock_poll.assert_not_called() + + +class TestExtractRunIdRobust(unittest.TestCase): + """M2 follow-up: run-id parser handles operation-URL Locations and + --no-wait always returns a parseable shape.""" + + def test_extract_from_scenario_runs_segment(self): + from azext_chaos.custom import _extract_run_id_from_location + url = ( + "https://management.azure.com/subscriptions/sub1/resourceGroups/" + "myRG/providers/Microsoft.Chaos/workspaces/myWS/" + "scenarios/ZoneDown-1.0/scenarioRuns/abc-123?api-version=..." + ) + self.assertEqual(_extract_run_id_from_location(url), "abc-123") + + def test_extract_from_runs_segment_legacy(self): + """Backwards-compat: some Location headers use /runs/ not /scenarioRuns/.""" + from azext_chaos.custom import _extract_run_id_from_location + url = ( + "/subscriptions/sub1/resourceGroups/myRG/providers/Microsoft.Chaos/" + "workspaces/myWS/scenarios/ZoneDown-1.0/runs/legacy-run-77" + ) + self.assertEqual(_extract_run_id_from_location(url), "legacy-run-77") + + def test_extract_returns_none_for_operation_url(self): + """Operation-status Location URLs do not embed the run id.""" + from azext_chaos.custom import _extract_run_id_from_location + url = ( + "/subscriptions/sub1/providers/Microsoft.Chaos/locations/westus2/" + "operationResults/00000000-0000-0000-0000-000000000000" + ) + self.assertIsNone(_extract_run_id_from_location(url)) + + def test_extract_handles_no_input(self): + from azext_chaos.custom import _extract_run_id_from_location + self.assertIsNone(_extract_run_id_from_location(None)) + self.assertIsNone(_extract_run_id_from_location("")) + + @patch('azext_chaos.custom.send_raw_request') + def test_async_op_fallback_reads_run_id_from_body(self, mock_send): + from azext_chaos.custom import _fetch_run_id_from_async_op + mock_send.return_value = _make_response( + json_body={"properties": {"runId": "from-async-op"}} + ) + self.assertEqual( + _fetch_run_id_from_async_op(_make_cmd(), 'https://x/op'), + "from-async-op", + ) + + @patch('azext_chaos.custom.send_raw_request') + def test_async_op_fallback_handles_failures_silently(self, mock_send): + from azext_chaos.custom import _fetch_run_id_from_async_op + mock_send.side_effect = RuntimeError("network") + self.assertIsNone( + _fetch_run_id_from_async_op(_make_cmd(), 'https://x/op') + ) + + @patch('azext_chaos.custom._fetch_run_id_from_async_op') + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_run_start_no_wait_returns_parseable_shape_when_run_id_unknown( + self, mock_send, mock_poll, mock_async_fallback, + ): + """Even if neither Location nor Azure-AsyncOperation surface the run + id, --no-wait must return a parseable JSON shape (not None / empty).""" + from azext_chaos.custom import scenario_run_start + + mock_poll.return_value = None + mock_async_fallback.return_value = None # fallback also fails + op_url = ( + "/subscriptions/sub1/providers/Microsoft.Chaos/locations/westus2/" + "operationResults/op-xyz" + ) + mock_send.side_effect = [ + _make_response(), # POST validate + _make_response(json_body=_successful_validation_result()), # GET validation + _make_response(headers={ # POST execute + "Location": op_url, + "Azure-AsyncOperation": op_url, + }), + ] + + with self.assertLogs(_LOGGER_NAME, level='WARNING') as cm: + result = scenario_run_start( + _make_cmd(), 'myRG', 'myWS', 'ZoneDown', 'cfg-1', + no_wait=True, + ) + + self.assertIsInstance(result, dict) + self.assertIsNone(result['runId']) + self.assertEqual(result['operationStatusUrl'], op_url) + # Guidance message points the user at `run list` for recovery. + self.assertIn('scenario run list', '\n'.join(cm.output)) + + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_run_start_no_wait_falls_back_to_async_op_body( + self, mock_send, mock_poll, + ): + from azext_chaos.custom import scenario_run_start + + mock_poll.return_value = None + # Location is an operation URL (no /runs/ segment), so the parser + # fails. Then the async-op fallback GET retrieves the runId. + op_url = ( + "/subscriptions/sub1/providers/Microsoft.Chaos/locations/westus2/" + "operationResults/op-xyz" + ) + mock_send.side_effect = [ + _make_response(headers={ + "Location": op_url, + "Azure-AsyncOperation": op_url, + }), # POST execute + _make_response(json_body={ # GET async-op (fallback) + "properties": {"runId": "found-via-fallback"} + }), + ] + + result = scenario_run_start( + _make_cmd(), 'myRG', 'myWS', 'ZoneDown', 'cfg-1', + skip_validation=True, no_wait=True, + ) + + self.assertEqual(result['runId'], 'found-via-fallback') + + +if __name__ == '__main__': + unittest.main() diff --git a/src/chaos/azext_chaos/tests/latest/test_table_format.py b/src/chaos/azext_chaos/tests/latest/test_table_format.py new file mode 100644 index 00000000000..cc3e49b4e34 --- /dev/null +++ b/src/chaos/azext_chaos/tests/latest/test_table_format.py @@ -0,0 +1,556 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import unittest +from collections import OrderedDict + + +class TestProject(unittest.TestCase): + """Tests for the _project helper.""" + + def test_project_returns_ordered_dict(self): + from azext_chaos._table_format import _project + result = {'name': 'test', 'location': 'westus2'} + projected = _project(result, '{Name: name, Location: location}') + self.assertIsInstance(projected, OrderedDict) + self.assertEqual(projected['Name'], 'test') + self.assertEqual(projected['Location'], 'westus2') + + def test_project_missing_keys_return_none(self): + from azext_chaos._table_format import _project + result = {'name': 'test'} + projected = _project(result, '{Name: name, Missing: missing_key}') + self.assertEqual(projected['Name'], 'test') + self.assertIsNone(projected['Missing']) + + +class TestWorkspaceTableFormat(unittest.TestCase): + + def test_workspace_show_table_format(self): + from azext_chaos._table_format import workspace_show_table_format + result = { + 'id': '/subscriptions/00000000-0000-0000-0000-000000000000' + '/resourceGroups/MyRG/providers/Microsoft.Chaos' + '/workspaces/MyWorkspace', + 'name': 'MyWorkspace', + 'location': 'westus2', + 'properties': {'provisioningState': 'Succeeded'}, + 'identity': {'type': 'SystemAssigned'}, + } + table = workspace_show_table_format(result) + self.assertEqual(table['Name'], 'MyWorkspace') + self.assertEqual(table['ResourceGroup'], 'MyRG') + self.assertEqual(table['Location'], 'westus2') + self.assertEqual(table['ProvisioningState'], 'Succeeded') + self.assertEqual(table['IdentityType'], 'SystemAssigned') + + def test_workspace_show_table_format_no_id(self): + from azext_chaos._table_format import workspace_show_table_format + result = { + 'name': 'MyWorkspace', + 'location': 'westus2', + 'properties': {'provisioningState': 'Succeeded'}, + } + table = workspace_show_table_format(result) + self.assertEqual(table['ResourceGroup'], '') + + def test_workspace_list_table_format(self): + from azext_chaos._table_format import workspace_list_table_format + results = [ + { + 'id': '/subscriptions/sub1/resourceGroups/RG1' + '/providers/Microsoft.Chaos/workspaces/ws1', + 'name': 'ws1', + 'location': 'westus2', + 'properties': {'provisioningState': 'Succeeded'}, + 'identity': {'type': 'SystemAssigned'}, + }, + { + 'id': '/subscriptions/sub1/resourceGroups/RG2' + '/providers/Microsoft.Chaos/workspaces/ws2', + 'name': 'ws2', + 'location': 'eastus', + 'properties': {'provisioningState': 'Creating'}, + 'identity': {'type': 'UserAssigned'}, + }, + ] + tables = workspace_list_table_format(results) + self.assertEqual(len(tables), 2) + self.assertEqual(tables[0]['Name'], 'ws1') + self.assertEqual(tables[1]['ResourceGroup'], 'RG2') + + def test_workspace_show_table_format_no_mutation(self): + """workspace_show_table_format must not inject keys into the input dict.""" + from azext_chaos._table_format import workspace_show_table_format + result = { + 'id': '/subscriptions/00000000-0000-0000-0000-000000000000' + '/resourceGroups/MyRG/providers/Microsoft.Chaos' + '/workspaces/MyWorkspace', + 'name': 'MyWorkspace', + 'location': 'westus2', + 'properties': {'provisioningState': 'Succeeded'}, + 'identity': {'type': 'SystemAssigned'}, + } + original_keys = set(result.keys()) + workspace_show_table_format(result) + self.assertEqual(set(result.keys()), original_keys) + + +class TestScenarioTableFormat(unittest.TestCase): + + def test_scenario_show_table_format(self): + from azext_chaos._table_format import scenario_show_table_format + result = { + 'name': 'ZoneDown-1.0', + 'properties': { + 'version': '1.0', + 'description': 'Zone failure simulation', + 'recommendation': { + 'recommendationStatus': 'Recommended', + }, + }, + } + table = scenario_show_table_format(result) + self.assertEqual(table['Name'], 'ZoneDown-1.0') + self.assertEqual(table['Version'], '1.0') + self.assertEqual(table['Description'], 'Zone failure simulation') + self.assertEqual(table['Recommendation'], 'Recommended') + + def test_scenario_list_table_format(self): + from azext_chaos._table_format import scenario_list_table_format + results = [ + {'name': 's1', 'properties': { + 'version': '1.0', 'description': 'd1', + 'recommendation': {'recommendationStatus': 'R'}, + }}, + ] + tables = scenario_list_table_format(results) + self.assertEqual(len(tables), 1) + self.assertEqual(tables[0]['Name'], 's1') + + +class TestScenarioConfigTableFormat(unittest.TestCase): + + def test_scenario_config_show_extracts_scenario_name(self): + """Scenario config table must show short scenario name, not full ARM ID.""" + from azext_chaos._table_format import scenario_config_show_table_format + result = { + 'name': 'zone1', + 'properties': { + 'scenarioId': ( + '/subscriptions/00000000-0000-0000-0000-000000000000' + '/resourceGroups/MyRG/providers/Microsoft.Chaos' + '/workspaces/MyWorkspace/scenarios/ZoneDown-1.0' + ), + 'provisioningState': 'Succeeded', + }, + } + table = scenario_config_show_table_format(result) + self.assertEqual(table['Name'], 'zone1') + self.assertEqual(table['Scenario'], 'ZoneDown-1.0') + self.assertEqual(table['ProvisioningState'], 'Succeeded') + + def test_scenario_config_show_empty_scenario_id(self): + from azext_chaos._table_format import scenario_config_show_table_format + result = { + 'name': 'zone1', + 'properties': { + 'scenarioId': '', + 'provisioningState': 'Creating', + }, + } + table = scenario_config_show_table_format(result) + self.assertEqual(table['Scenario'], '') + + def test_scenario_config_show_missing_properties(self): + from azext_chaos._table_format import scenario_config_show_table_format + result = {'name': 'zone1'} + table = scenario_config_show_table_format(result) + self.assertEqual(table['Scenario'], '') + + def test_scenario_config_show_no_mutation(self): + """scenario_config_show_table_format must not inject keys into the input dict.""" + from azext_chaos._table_format import scenario_config_show_table_format + result = { + 'name': 'zone1', + 'properties': { + 'scenarioId': ( + '/subscriptions/00000000-0000-0000-0000-000000000000' + '/resourceGroups/MyRG/providers/Microsoft.Chaos' + '/workspaces/MyWorkspace/scenarios/ZoneDown-1.0' + ), + 'provisioningState': 'Succeeded', + }, + } + original_keys = set(result.keys()) + scenario_config_show_table_format(result) + self.assertEqual(set(result.keys()), original_keys) + + def test_scenario_config_list_table_format(self): + from azext_chaos._table_format import scenario_config_list_table_format + results = [ + { + 'name': 'cfg1', + 'properties': { + 'scenarioId': '/subscriptions/sub/resourceGroups/rg' + '/providers/Microsoft.Chaos' + '/workspaces/ws/scenarios/S1', + 'provisioningState': 'Succeeded', + }, + }, + { + 'name': 'cfg2', + 'properties': { + 'scenarioId': '/subscriptions/sub/resourceGroups/rg' + '/providers/Microsoft.Chaos' + '/workspaces/ws/scenarios/S2', + 'provisioningState': 'Failed', + }, + }, + ] + tables = scenario_config_list_table_format(results) + self.assertEqual(len(tables), 2) + self.assertEqual(tables[0]['Scenario'], 'S1') + self.assertEqual(tables[1]['Scenario'], 'S2') + + +class TestScenarioRunTableFormat(unittest.TestCase): + + def test_run_show_table_format(self): + from azext_chaos._table_format import scenario_run_show_table_format + result = { + 'name': '12345678-1234-1234-1234-123456789012', + 'properties': { + 'status': 'Running', + 'startTime': '2026-01-01T00:00:00Z', + 'endTime': None, + }, + } + table = scenario_run_show_table_format(result) + self.assertEqual(table['RunId'], + '12345678-1234-1234-1234-123456789012') + self.assertEqual(table['Status'], 'Running') + self.assertEqual(table['StartTime'], '2026-01-01T00:00:00Z') + self.assertIsNone(table['EndTime']) + + def test_run_list_table_format(self): + from azext_chaos._table_format import scenario_run_list_table_format + results = [ + {'name': 'run1', 'properties': { + 'status': 'Succeeded', + 'startTime': 't1', 'endTime': 't2', + }}, + ] + tables = scenario_run_list_table_format(results) + self.assertEqual(len(tables), 1) + self.assertEqual(tables[0]['RunId'], 'run1') + + +class TestValidationTableFormat(unittest.TestCase): + + def test_validation_show_table_format_succeeded(self): + from azext_chaos._table_format import validation_show_table_format + result = { + 'name': 'latest', + 'properties': { + 'status': 'Succeeded', + 'startTime': '2026-01-01T00:00:00Z', + 'endTime': '2026-01-01T00:01:00Z', + 'errors': [], + 'validationErrors': {'errors': []}, + }, + } + table = validation_show_table_format(result) + self.assertEqual(table['Status'], 'Succeeded') + self.assertEqual(table['Errors'], '') + + def test_validation_show_table_format_with_errors(self): + from azext_chaos._table_format import validation_show_table_format + result = { + 'name': 'latest', + 'properties': { + 'status': 'Failed', + 'startTime': '2026-01-01T00:00:00Z', + 'endTime': '2026-01-01T00:01:00Z', + 'errors': [{'message': 'system error'}], + 'validationErrors': { + 'errors': [{'message': 'e1'}, {'message': 'e2'}], + }, + }, + } + table = validation_show_table_format(result) + self.assertEqual(table['Errors'], '1 system, 2 validation') + + def test_validation_show_table_format_no_mutation(self): + """validation_show_table_format must not inject keys into the input dict.""" + from azext_chaos._table_format import validation_show_table_format + result = { + 'name': 'latest', + 'properties': { + 'status': 'Succeeded', + 'startTime': '2026-01-01T00:00:00Z', + 'endTime': '2026-01-01T00:01:00Z', + 'errors': [], + 'validationErrors': {'errors': []}, + }, + } + original_keys = set(result.keys()) + validation_show_table_format(result) + self.assertEqual(set(result.keys()), original_keys) + + +class TestPermissionFixTableFormat(unittest.TestCase): + + def test_permission_fix_show_table_format(self): + from azext_chaos._table_format import permission_fix_show_table_format + result = { + 'name': 'latest', + 'properties': { + 'state': 'Succeeded', + 'summary': '3 roles assigned', + 'whatIfMode': False, + }, + } + table = permission_fix_show_table_format(result) + self.assertEqual(table['State'], 'Succeeded') + self.assertEqual(table['Summary'], '3 roles assigned') + self.assertFalse(table['WhatIfMode']) + + def test_permission_fix_show_what_if_mode(self): + from azext_chaos._table_format import permission_fix_show_table_format + result = { + 'name': 'latest', + 'properties': { + 'state': 'Succeeded', + 'summary': '3 roles would be assigned', + 'whatIfMode': True, + }, + } + table = permission_fix_show_table_format(result) + self.assertTrue(table['WhatIfMode']) + + +class TestDiscoveredResourceTableFormat(unittest.TestCase): + + def test_discovered_resource_show_table_format(self): + from azext_chaos._table_format import ( + discovered_resource_show_table_format, + ) + result = { + 'name': 'myvm', + 'properties': { + 'namespace': 'Microsoft.Compute', + 'resourceName': 'myvm', + 'resourceType': 'virtualMachines', + 'fullyQualifiedIdentifier': '/subscriptions/sub/rg/vm', + 'discoveredAt': '2026-01-01T00:00:00Z', + 'scope': '/subscriptions/sub/resourceGroups/rg', + }, + } + table = discovered_resource_show_table_format(result) + self.assertEqual(table['Name'], 'myvm') + self.assertEqual(table['ResourceName'], 'myvm') + self.assertEqual(table['ResourceType'], 'virtualMachines') + self.assertEqual(table['Namespace'], 'Microsoft.Compute') + self.assertEqual(table['DiscoveredAt'], '2026-01-01T00:00:00Z') + + def test_discovered_resource_list_table_format(self): + from azext_chaos._table_format import ( + discovered_resource_list_table_format, + ) + results = [ + {'name': 'r1', 'properties': { + 'namespace': 'ns1', 'resourceName': 'r1', + 'resourceType': 'rt1', + 'fullyQualifiedIdentifier': 'fqi1', + 'discoveredAt': 't1', 'scope': 's1', + }}, + {'name': 'r2', 'properties': { + 'namespace': 'ns2', 'resourceName': 'r2', + 'resourceType': 'rt2', + 'fullyQualifiedIdentifier': 'fqi2', + 'discoveredAt': 't2', 'scope': 's2', + }}, + ] + tables = discovered_resource_list_table_format(results) + self.assertEqual(len(tables), 2) + self.assertEqual(tables[0]['Name'], 'r1') + self.assertEqual(tables[1]['Namespace'], 'ns2') + + +class TestWorkspaceDiscoveryEvaluationTableFormat(unittest.TestCase): + + def test_workspace_discovery_show(self): + from azext_chaos._table_format import ( + workspace_discovery_show_table_format, + ) + result = { + 'name': 'latest', + 'properties': { + 'status': 'Succeeded', + 'startTime': '2026-01-01T00:00:00Z', + 'endTime': '2026-01-01T00:05:00Z', + }, + } + table = workspace_discovery_show_table_format(result) + self.assertEqual(table['Status'], 'Succeeded') + self.assertEqual(table['StartTime'], '2026-01-01T00:00:00Z') + self.assertEqual(table['EndTime'], '2026-01-01T00:05:00Z') + + def test_workspace_evaluation_show(self): + from azext_chaos._table_format import ( + workspace_evaluation_show_table_format, + ) + result = { + 'name': 'latest', + 'properties': { + 'status': 'InProgress', + 'startTime': '2026-01-01T00:00:00Z', + 'endTime': None, + }, + } + table = workspace_evaluation_show_table_format(result) + self.assertEqual(table['Status'], 'InProgress') + self.assertIsNone(table['EndTime']) + + +class TestHelpEntries(unittest.TestCase): + """Verify every command group and command has a help entry.""" + + _REQUIRED_GROUPS = [ + 'chaos', + 'chaos workspace', + 'chaos scenario', + 'chaos scenario config', + 'chaos scenario run', + 'chaos discovered-resource', + ] + + _REQUIRED_COMMANDS = [ + 'chaos workspace create', + 'chaos workspace show', + 'chaos workspace list', + 'chaos workspace delete', + 'chaos workspace update', + 'chaos workspace refresh-recommendations', + 'chaos workspace evaluate-scenarios', + 'chaos workspace show-discovery', + 'chaos workspace show-evaluation', + 'chaos scenario create', + 'chaos scenario show', + 'chaos scenario list', + 'chaos scenario delete', + 'chaos scenario config create', + 'chaos scenario config show', + 'chaos scenario config list', + 'chaos scenario config delete', + 'chaos scenario config validate', + 'chaos scenario config show-validation', + 'chaos scenario config fix-permissions', + 'chaos scenario config show-permission-fix', + 'chaos scenario run start', + 'chaos scenario run list', + 'chaos scenario run show', + 'chaos scenario run cancel', + 'chaos discovered-resource list', + 'chaos discovered-resource show', + ] + + def test_all_groups_have_help(self): + from azext_chaos._help import helps + for group in self._REQUIRED_GROUPS: + with self.subTest(group=group): + self.assertIn(group, helps) + + def test_all_commands_have_help(self): + from azext_chaos._help import helps + for cmd in self._REQUIRED_COMMANDS: + with self.subTest(cmd=cmd): + self.assertIn(cmd, helps) + + def test_all_commands_have_at_least_two_examples(self): + from azext_chaos._help import helps + for cmd in self._REQUIRED_COMMANDS: + with self.subTest(cmd=cmd): + help_text = helps[cmd] + if 'type: group' in help_text: + continue + count = help_text.count('- name:') + self.assertGreaterEqual( + count, 2, + f"Command '{cmd}' has {count} examples, expected >= 2" + ) + + def test_alias_begins_with_alias_of(self): + from azext_chaos._help import helps + alias_help = helps['chaos workspace evaluate-scenarios'] + self.assertTrue( + 'Alias of `az chaos workspace refresh-recommendations`' + in alias_help + ) + + def test_config_create_has_zones_example(self): + from azext_chaos._help import helps + help_text = helps['chaos scenario config create'] + self.assertIn("zones=", help_text) + + def test_config_create_has_physical_zones_example(self): + from azext_chaos._help import helps + help_text = helps['chaos scenario config create'] + self.assertIn("physical-zones", help_text) + + def test_config_create_notes_mutual_exclusion(self): + from azext_chaos._help import helps + help_text = helps['chaos scenario config create'] + self.assertIn('mutually exclusive', help_text) + + def test_run_start_has_all_four_pairings(self): + from azext_chaos._help import helps + help_text = helps['chaos scenario run start'] + self.assertIn('--skip-validation --no-wait', help_text) + skip_count = help_text.count('--skip-validation') + no_wait_count = help_text.count('--no-wait') + self.assertGreaterEqual(skip_count, 3) + self.assertGreaterEqual(no_wait_count, 3) + + +class TestCommandsWiring(unittest.TestCase): + """Verify table_transformer is wired for custom commands.""" + + def test_validate_has_table_transformer(self): + from unittest.mock import MagicMock, patch + from azext_chaos.commands import load_command_table + mock_loader = MagicMock() + mock_loader.command_table = {} + with patch('azext_chaos.commands._register_aaz_subclass_overrides'): + load_command_table(mock_loader, None) + ctx = mock_loader.command_group.return_value.__enter__.return_value + validate_call = next( + (c for c in ctx.custom_command.call_args_list + if c.args[0] == 'validate'), + None + ) + self.assertIsNotNone(validate_call, "'validate' command not registered") + self.assertIn('table_transformer', validate_call.kwargs) + + def test_start_has_table_transformer(self): + from unittest.mock import MagicMock, patch + from azext_chaos.commands import load_command_table + mock_loader = MagicMock() + mock_loader.command_table = {} + with patch('azext_chaos.commands._register_aaz_subclass_overrides'): + load_command_table(mock_loader, None) + ctx = mock_loader.command_group.return_value.__enter__.return_value + start_call = next( + (c for c in ctx.custom_command.call_args_list + if c.args[0] == 'start'), + None + ) + self.assertIsNotNone(start_call, "'start' command not registered") + self.assertIn('table_transformer', start_call.kwargs) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/chaos/azext_chaos/tests/latest/test_validators.py b/src/chaos/azext_chaos/tests/latest/test_validators.py new file mode 100644 index 00000000000..86ec878d9c2 --- /dev/null +++ b/src/chaos/azext_chaos/tests/latest/test_validators.py @@ -0,0 +1,125 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import json +import os +import tempfile +import unittest +from unittest.mock import MagicMock + +from knack.util import CLIError + +from azext_chaos._validators import validate_scope, validate_parameters_json + + +class TestValidateScope(unittest.TestCase): + """Tests for the --scopes ARM resource ID validator.""" + + def _make_namespace(self, scopes): + ns = MagicMock() + ns.scopes = scopes + return ns + + def test_valid_subscription_scope(self): + ns = self._make_namespace( + ["/subscriptions/00000000-0000-0000-0000-000000000000"] + ) + validate_scope(ns) # should not raise + + def test_valid_resource_group_scope(self): + ns = self._make_namespace( + ["/subscriptions/00000000-0000-0000-0000-000000000000" + "/resourceGroups/MyRG"] + ) + validate_scope(ns) # should not raise + + def test_valid_resource_scope(self): + ns = self._make_namespace( + ["/subscriptions/00000000-0000-0000-0000-000000000000" + "/resourceGroups/MyRG/providers/Microsoft.Compute/virtualMachines/myVM"] + ) + validate_scope(ns) # should not raise + + def test_multiple_valid_scopes(self): + ns = self._make_namespace([ + "/subscriptions/00000000-0000-0000-0000-000000000000", + "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/RG2" + ]) + validate_scope(ns) # should not raise + + def test_invalid_scope_no_leading_slash(self): + ns = self._make_namespace(["subscriptions/00000000-0000-0000-0000-000000000000"]) + with self.assertRaises(CLIError): + validate_scope(ns) + + def test_invalid_scope_missing_subscriptions(self): + ns = self._make_namespace(["/foo/bar"]) + with self.assertRaises(CLIError): + validate_scope(ns) + + def test_empty_scope_string(self): + ns = self._make_namespace([""]) + with self.assertRaises(CLIError): + validate_scope(ns) + + def test_none_scopes_passes(self): + ns = self._make_namespace(None) + validate_scope(ns) # should not raise when scopes is None + + def test_empty_list_passes(self): + ns = self._make_namespace([]) + validate_scope(ns) # should not raise when scopes is empty + + +class TestValidateParametersJson(unittest.TestCase): + """Tests for the --parameters JSON validator.""" + + def _make_namespace(self, parameters): + ns = MagicMock() + ns.parameters = parameters + return ns + + def test_raw_json_string(self): + data = [{"key": "duration", "value": "PT5M"}] + ns = self._make_namespace(json.dumps(data)) + validate_parameters_json(ns) + self.assertEqual(ns.parameters, data) + + def test_file_reference(self): + data = [{"key": "duration", "value": "PT10M"}] + with tempfile.NamedTemporaryFile( + mode='w', suffix='.json', delete=False + ) as f: + json.dump(data, f) + f.flush() + tmp_path = f.name + try: + ns = self._make_namespace(f"@{tmp_path}") + validate_parameters_json(ns) + self.assertEqual(ns.parameters, data) + finally: + os.unlink(tmp_path) + + def test_invalid_json_string(self): + ns = self._make_namespace("{not valid json}") + with self.assertRaises(CLIError): + validate_parameters_json(ns) + + def test_none_parameters_passes(self): + ns = self._make_namespace(None) + validate_parameters_json(ns) # should not raise + + def test_empty_string_passes(self): + ns = self._make_namespace("") + validate_parameters_json(ns) # should not raise + + def test_file_reference_nonexistent(self): + ns = self._make_namespace("@/nonexistent/path/file.json") + with self.assertRaises(CLIError): + validate_parameters_json(ns) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/chaos/setup.cfg b/src/chaos/setup.cfg new file mode 100644 index 00000000000..aa76baec67f --- /dev/null +++ b/src/chaos/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=0 diff --git a/src/chaos/setup.py b/src/chaos/setup.py new file mode 100644 index 00000000000..5dc639c562c --- /dev/null +++ b/src/chaos/setup.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from codecs import open +from setuptools import setup, find_packages + +VERSION = '0.1.4' + +CLASSIFIERS = [ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'License :: OSI Approved :: MIT License', +] + +DEPENDENCIES = [] + +with open('README.md', 'r', encoding='utf-8') as f: + README = f.read() +with open('HISTORY.rst', 'r', encoding='utf-8') as f: + HISTORY = f.read() + +setup( + name='chaos', + version=VERSION, + description='Microsoft Azure Command-Line Tools Chaos Studio Extension', + author='Microsoft Corporation', + author_email='azpycli@microsoft.com', + url='https://github.com/Azure/azure-cli-extensions/tree/main/src/chaos', + long_description=README + '\n\n' + HISTORY, + license='MIT', + classifiers=CLASSIFIERS, + packages=find_packages(), + install_requires=DEPENDENCIES, + package_data={'azext_chaos': ['azext_metadata.json']}, +) From a6b2b0c293b5dc3ec1c0be1742f520a15d8b6b65 Mon Sep 17 00:00:00 2001 From: "Cristian Velez (from Dev Box)" Date: Wed, 3 Jun 2026 21:13:09 -0700 Subject: [PATCH 02/13] [Chaos] Shorten --scenario-configuration-name on chaos scenario config execute Mirrors the same shortening already applied to fix-permissions; resolves azdev-linter option_length_too_long HIGH finding (--scenario-configuration-name is 30 chars, threshold 22). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_chaos/aaz/latest/chaos/scenario/config/_execute.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_execute.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_execute.py index d0ee93ebc50..742ec599a2e 100644 --- a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_execute.py +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_execute.py @@ -50,7 +50,7 @@ def _build_arguments_schema(cls, *args, **kwargs): required=True, ) _args_schema.scenario_configuration_name = AAZStrArg( - options=["--scenario-configuration-name"], + options=["-n", "--name", "--scenario-configuration-name"], help="Name of the scenario definition.", required=True, id_part="child_name_2", From bda2795fe8fe5862b1a57c556dc8bd2cccf0b41b Mon Sep 17 00:00:00 2001 From: "Cristian Velez (from Dev Box)" Date: Thu, 4 Jun 2026 00:25:31 -0700 Subject: [PATCH 03/13] [Chaos] Refactor refresh-recommendation to AAZ-subclass + post_operations hook * custom.py: WorkspaceRefreshRecommendation(_RefreshRecommendation) overrides post_operations to call _check_inner_lro for discoveries/latest + evaluations/latest. Detects the silent-failure case the AAZ framework's final-state-via:location polling misses (status nested at properties.status, not root). Free function workspace_refresh_recommendations retired. _check_inner_lro + _build_arm_url refactored to accept cli_ctx directly (decoupled from cmd object). * WorkspaceEvaluateScenarios(WorkspaceRefreshRecommendation) registers the porcelain alias chaos workspace evaluate-scenarios at a name the spec doesn't define. Today behaves identically; will become a true composite of /discover + /evaluate when those ARM ops land in 2026-08-01-preview. * commands.py: register both subclasses via _register_aaz_subclass_overrides. g.custom_command for refresh-recommendations/evaluate-scenarios removed. * _help.py / _params.py: plural -> singular (refresh-recommendation), --name MyWorkspace examples retained (the new ARG_OPTIONS adds -n / --name aliases on the workspace_name arg, matching workspace create convention). * tests: integration test classes marked @live_only() (workspace lifecycle + discovered-resource browsing). 3 integration test files (scenario, scenario_config, scenario_run) pruned -- they exercised service behavior rather than extension behavior. test_custom_commands.py: TestRefreshRecommendationsInnerLRO rewritten to exercise _check_inner_lro directly (no free function to call anymore). test_command_registration.py: test_alias_registered_via_subclass replaces the old custom_command pattern assertion. Setup helpers fixed: sub kwarg injection, westus2 region, --mi-system-assigned identity flag (was --identity-type SystemAssigned), -w {ws} -> --workspace-name {ws}. * setup.py: version 0.1.4 -> 0.1.5 * HISTORY.rst: 0.1.5 release notes Generated aaz/ tree regenerated from spec commit f228b86c via the chaos-automation-codegen skill family (cli generate-by-swagger-tag). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/chaos/HISTORY.rst | 19 + src/chaos/azext_chaos/_help.py | 26 +- src/chaos/azext_chaos/_params.py | 4 +- .../workspace/_refresh_recommendation.py | 2 +- src/chaos/azext_chaos/commands.py | 20 +- src/chaos/azext_chaos/custom.py | 152 +++++--- src/chaos/azext_chaos/tests/latest/README.md | 2 +- .../latest/test_chaos_discovered_resource.py | 18 +- .../tests/latest/test_chaos_scenario.py | 80 ----- .../latest/test_chaos_scenario_config.py | 130 ------- .../tests/latest/test_chaos_scenario_run.py | 337 ------------------ .../tests/latest/test_chaos_workspace.py | 18 +- .../tests/latest/test_command_registration.py | 53 ++- .../tests/latest/test_custom_commands.py | 182 ++++------ .../tests/latest/test_table_format.py | 4 +- src/chaos/setup.py | 2 +- 16 files changed, 258 insertions(+), 791 deletions(-) delete mode 100644 src/chaos/azext_chaos/tests/latest/test_chaos_scenario.py delete mode 100644 src/chaos/azext_chaos/tests/latest/test_chaos_scenario_config.py delete mode 100644 src/chaos/azext_chaos/tests/latest/test_chaos_scenario_run.py diff --git a/src/chaos/HISTORY.rst b/src/chaos/HISTORY.rst index f15c96cdac9..c024ec31911 100644 --- a/src/chaos/HISTORY.rst +++ b/src/chaos/HISTORY.rst @@ -3,6 +3,25 @@ Release History =============== +0.1.5 ++++++ +* Refactor ``chaos workspace refresh-recommendation`` to use the AAZ-subclass + ``post_operations`` + pattern (``WorkspaceRefreshRecommendation`` overriding the AAZ-generated command). Inner-LRO + failure detection (discoveries/latest + evaluations/latest with ``properties.status`` inspection, + ARG-propagation-lag hint) preserved; ~30 lines of duplicated POST + outer-LRO polling removed + (now handled by the AAZ framework). +* ``chaos workspace evaluate-scenarios`` re-implemented as a ``WorkspaceEvaluateScenarios`` subclass + alias of the same handler. Behavior unchanged today; will become a true composite of + ``/discover`` + ``/evaluate`` when those ARM ops land in ``2026-08-01-preview``. +* Command name changed from plural ``refresh-recommendations`` to singular ``refresh-recommendation`` + to match the aaz-dev convention for verb-noun command naming. The ``evaluate-scenarios`` alias + retains its current name. +* Register all hand-written commands (incl. ``ScenarioConfigCreate``) test injects subscription ID + via ``self.get_subscription_id()`` in the ``ScenarioTest`` ``self.kwargs.update({...})`` setup + helpers so ``{sub}`` substitutions in ``--scopes /subscriptions/{sub}/...`` resolve. +* Generated ``aaz/`` tree regenerated from spec commit ``f228b86c`` via the + ``chaos-automation-codegen`` skill family (``aaz-dev cli generate-by-swagger-tag``). + 0.1.0 +++++ * Initial scaffolding. Commands will be populated when aaz-dev codegen is complete (pending 2026-05-01-preview spec availability). diff --git a/src/chaos/azext_chaos/_help.py b/src/chaos/azext_chaos/_help.py index ab7ac86c4b2..7cf4fdd9f36 100644 --- a/src/chaos/azext_chaos/_help.py +++ b/src/chaos/azext_chaos/_help.py @@ -31,7 +31,7 @@ long-summary: | Scenarios define the fault-injection actions available in a workspace. Catalog scenarios are populated by workspace evaluation (see - 'az chaos workspace refresh-recommendations'); custom scenarios can be + 'az chaos workspace refresh-recommendation'); custom scenarios can be created directly. """ @@ -58,7 +58,7 @@ short-summary: Browse discovered resources in a Chaos Studio workspace. long-summary: | Discovered resources are populated by workspace discovery scans - (triggered via 'az chaos workspace refresh-recommendations'). + (triggered via 'az chaos workspace refresh-recommendation'). Use 'list' to see all discovered resources and 'show' to inspect a specific one. """ @@ -149,7 +149,7 @@ --scopes /subscriptions/SUB/resourceGroups/MyRG/providers/Microsoft.Compute/virtualMachines/MyVM """ -helps['chaos workspace refresh-recommendations'] = """ +helps['chaos workspace refresh-recommendation'] = """ type: command short-summary: Refresh scenario recommendations and trigger resource discovery for a workspace. long-summary: | @@ -160,22 +160,22 @@ examples: - name: Refresh recommendations for a workspace text: > - az chaos workspace refresh-recommendations --name MyWorkspace --resource-group MyRG + az chaos workspace refresh-recommendation --name MyWorkspace --resource-group MyRG - name: Refresh recommendations without waiting for completion text: > - az chaos workspace refresh-recommendations --name MyWorkspace --resource-group MyRG --no-wait + az chaos workspace refresh-recommendation --name MyWorkspace --resource-group MyRG --no-wait """ helps['chaos workspace evaluate-scenarios'] = """ type: command short-summary: >- - Alias of `az chaos workspace refresh-recommendations`. + Alias of `az chaos workspace refresh-recommendation`. Refresh scenario recommendations and trigger resource discovery for a workspace. long-summary: | - This command is a CLI-side alias for 'az chaos workspace refresh-recommendations'. + This command is a CLI-side alias for 'az chaos workspace refresh-recommendation'. Both commands invoke the same API operation (Workspaces_RefreshRecommendations) with identical arguments and behavior. The canonical command name is - 'az chaos workspace refresh-recommendations'. + 'az chaos workspace refresh-recommendation'. examples: - name: Evaluate scenarios for a workspace text: > @@ -192,7 +192,7 @@ Retrieves the latest workspace-scope resource-discovery operation result. Returns the discovery operation's state (in-progress, succeeded, failed) and any failure details. This is a read-only GET — it does NOT trigger - a new discovery. Use 'az chaos workspace refresh-recommendations' to + a new discovery. Use 'az chaos workspace refresh-recommendation' to trigger a new discovery scan. examples: - name: Show the latest discovery result @@ -212,7 +212,7 @@ has been evaluated (a prerequisite for 'scenario config validate' and 'scenario run start' on catalog scenarios). This is a read-only GET — it does NOT trigger a new evaluation. Use - 'az chaos workspace refresh-recommendations' to trigger evaluation. + 'az chaos workspace refresh-recommendation' to trigger evaluation. examples: - name: Show the latest evaluation result text: > @@ -230,7 +230,7 @@ long-summary: | Creates a custom scenario. Custom scenarios are authored directly via this command and define their own actions and parameters. Catalog scenarios - (populated by 'az chaos workspace refresh-recommendations') are not + (populated by 'az chaos workspace refresh-recommendation') are not created this way. Use --actions, --parameters, and --description to populate the scenario @@ -404,7 +404,7 @@ For non-custom (catalog) scenarios, the workspace must be evaluated before validation can succeed. If the workspace has not been evaluated, the command - will fail with a hint to run 'az chaos workspace refresh-recommendations'. + will fail with a hint to run 'az chaos workspace refresh-recommendation'. examples: - name: Validate a scenario configuration and display results text: > @@ -579,7 +579,7 @@ short-summary: List discovered resources in a workspace. long-summary: | Lists all resources discovered by workspace discovery scans. Use - 'az chaos workspace refresh-recommendations' to trigger a new discovery + 'az chaos workspace refresh-recommendation' to trigger a new discovery scan if the list appears stale. examples: - name: List all discovered resources diff --git a/src/chaos/azext_chaos/_params.py b/src/chaos/azext_chaos/_params.py index 19895d5e27b..28059cdb607 100644 --- a/src/chaos/azext_chaos/_params.py +++ b/src/chaos/azext_chaos/_params.py @@ -52,9 +52,9 @@ def load_arguments(self, _): help='Do not prompt for confirmation.', ) - # evaluate-scenarios alias inherits all args from refresh-recommendations; + # evaluate-scenarios alias inherits all args from refresh-recommendation; # register the same overrides so short flags resolve correctly. - for ctx_name in ('chaos workspace refresh-recommendations', + for ctx_name in ('chaos workspace refresh-recommendation', 'chaos workspace evaluate-scenarios'): with self.argument_context(ctx_name) as c: c.argument( diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_refresh_recommendation.py b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_refresh_recommendation.py index fcb14936c1e..93cc8048bc3 100644 --- a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_refresh_recommendation.py +++ b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_refresh_recommendation.py @@ -50,7 +50,7 @@ def _build_arguments_schema(cls, *args, **kwargs): required=True, ) _args_schema.workspace_name = AAZStrArg( - options=["--workspace-name"], + options=["-n", "--name", "--workspace-name"], help="String that represents a Workspace resource name.", required=True, id_part="name", diff --git a/src/chaos/azext_chaos/commands.py b/src/chaos/azext_chaos/commands.py index 117a86b59c4..8080164abfc 100644 --- a/src/chaos/azext_chaos/commands.py +++ b/src/chaos/azext_chaos/commands.py @@ -51,16 +51,6 @@ def load_command_table(self, _): 'chaos workspace', operations_tmpl='azext_chaos.custom#{}', ) as g: - g.custom_command( - 'refresh-recommendations', - 'workspace_refresh_recommendations', - supports_no_wait=True, - ) - g.custom_command( - 'evaluate-scenarios', - 'workspace_refresh_recommendations', - supports_no_wait=True, - ) g.custom_show_command( 'show-discovery', 'workspace_show_discovery', @@ -129,7 +119,15 @@ def _register_aaz_subclass_overrides(loader): (Knack rejects non-CLI ``cli_ctx``), unit tests that drive ``load_command_table`` with a ``MagicMock`` loader should ``@patch`` this helper out. """ - from azext_chaos.custom import ScenarioConfigCreate + from azext_chaos.custom import ScenarioConfigCreate, WorkspaceRefreshRecommendation, WorkspaceEvaluateScenarios from azext_chaos.custom_wait import ScenarioRunWait loader.command_table['chaos scenario config create'] = ScenarioConfigCreate(loader=loader) loader.command_table['chaos scenario run wait'] = ScenarioRunWait(loader=loader) + # Override the AAZ-generated ``chaos workspace refresh-recommendation`` + # to add inner-LRO failure detection (see WorkspaceRefreshRecommendation + # docstring for why this is functionally necessary, not stylistic). + loader.command_table['chaos workspace refresh-recommendation'] = WorkspaceRefreshRecommendation(loader=loader) + # Register ``chaos workspace evaluate-scenarios`` as a porcelain alias + # at a NAME the spec does not define. The subclass instance bears the + # alias name; AAZCommand inherits everything from the parent class. + loader.command_table['chaos workspace evaluate-scenarios'] = WorkspaceEvaluateScenarios(loader=loader) diff --git a/src/chaos/azext_chaos/custom.py b/src/chaos/azext_chaos/custom.py index 0878620e2a6..7a3bbf06090 100644 --- a/src/chaos/azext_chaos/custom.py +++ b/src/chaos/azext_chaos/custom.py @@ -12,6 +12,7 @@ from knack.util import CLIError from .aaz.latest.chaos.scenario.config._create import Create as _ScenarioConfigCreate +from .aaz.latest.chaos.workspace._refresh_recommendation import RefreshRecommendation as _RefreshRecommendation logger = get_logger(__name__) @@ -103,10 +104,10 @@ def _poll_location(cmd, poll_url, retry_after): raise CLIError(f"Long-running operation timed out after {_LRO_TIMEOUT_SECONDS}s.") -def _build_arm_url(cmd, resource_group, workspace, path_suffix): +def _build_arm_url(cli_ctx, resource_group, workspace, path_suffix): """Build a fully qualified ARM URL for a workspace-scoped sub-resource.""" from azure.cli.core.commands.client_factory import get_subscription_id - sub = get_subscription_id(cmd.cli_ctx) + sub = get_subscription_id(cli_ctx) return ( f"/subscriptions/{sub}/resourceGroups/{resource_group}" f"/providers/Microsoft.Chaos/workspaces/{workspace}" @@ -126,7 +127,7 @@ def _make_evaluation_hint(workspace, resource_group, context_suffix=""): """Build the friendly hint for evaluation-state errors.""" hint = ( f"Workspace has not been evaluated. " - f"Run `az chaos workspace refresh-recommendations " + f"Run `az chaos workspace refresh-recommendation " f"--name {workspace} --resource-group {resource_group}` " f"(or its alias `az chaos workspace evaluate-scenarios`) " f"to (re)trigger evaluation, or " @@ -150,13 +151,13 @@ def _make_evaluation_hint(workspace, resource_group, context_suffix=""): "This commonly happens immediately after a Reader role assignment is " "added to the workspace's managed identity: Azure Resource Graph can " "lag 1-3 minutes before honoring the new role. Wait ~60-180s and re-run " - "'refresh-recommendations'. If it persists, run " + "'refresh-recommendation'. If it persists, run " "'az chaos workspace show-discovery' / 'show-evaluation' for full error " "detail and verify the workspace UAMI has Reader on all in-scope scopes." ) -def _check_inner_lro(cmd, resource_group_name, workspace_name, path_suffix, +def _check_inner_lro(cli_ctx, resource_group_name, workspace_name, path_suffix, operation_label): """Fetch a /latest singleton and raise if its inner status is Failed. @@ -164,9 +165,9 @@ def _check_inner_lro(cmd, resource_group_name, workspace_name, path_suffix, yet, or endpoint not reachable). We only flip to non-zero exit when the inner result is unambiguously Failed. """ - url = _build_arm_url(cmd, resource_group_name, workspace_name, path_suffix) + url = _build_arm_url(cli_ctx, resource_group_name, workspace_name, path_suffix) try: - response = send_raw_request(cmd.cli_ctx, "GET", url) + response = send_raw_request(cli_ctx, "GET", url) except Exception: # pylint: disable=broad-except return # /latest may legitimately 404 on a fresh workspace if response.status_code != 200 or not response.text: @@ -189,50 +190,19 @@ def _check_inner_lro(cmd, resource_group_name, workspace_name, path_suffix, else: detail = "no error detail returned by the service" raise CLIError( - f"refresh-recommendations completed but the inner " + f"refresh-recommendation completed but the inner " f"{operation_label} operation Failed ({detail}). " f"{_INNER_LRO_FAILURE_HINT}" ) -def workspace_refresh_recommendations(cmd, resource_group_name, workspace_name, - no_wait=False): - """POST + poll LRO for workspace refresh-recommendations.""" - url = _build_arm_url( - cmd, resource_group_name, workspace_name, "/refreshRecommendations" - ) - response = send_raw_request(cmd.cli_ctx, "POST", url) - - if no_wait: - return response.json() if response.text else None - - # Poll to completion - _poll_or_return(cmd, response) - - # Even when the outer LRO reports Succeeded, the inner discovery and - # evaluation LROs may have failed (typically Azure Resource Graph 403 - # during propagation lag after a fresh role assignment). Check both - # before declaring success. - _check_inner_lro( - cmd, resource_group_name, workspace_name, - "/discoveries/latest", "resource discovery", - ) - _check_inner_lro( - cmd, resource_group_name, workspace_name, - "/evaluations/latest", "scenario evaluation", - ) - - logger.warning( - "Successfully refreshed recommendations for workspace '%s' " - "in resource group '%s'. Workspace evaluation has been refreshed; " - "subsequent 'scenario config validate' / 'scenario run start' calls " - "(for non-custom scenarios) now have a satisfied evaluation gate.\n" - "Run 'az chaos scenario list -w %s -g %s' to see updated " - "recommendation statuses.", - workspace_name, resource_group_name, - workspace_name, resource_group_name, - ) - return None +# ── workspace refresh-recommendation ──────────────────────────────────── +# The user-facing command is the AAZ-generated `chaos workspace +# refresh-recommendation`. We override it via the `WorkspaceRefreshRecommendation` +# subclass at the bottom of this file (registered in commands.py +# `_register_aaz_subclass_overrides`). The `post_operations` hook calls +# `_check_inner_lro` for both inner discoveries/latest and evaluations/latest +# to detect the silent-failure case the framework polling alone misses. # ── scenario config validate ───────────────────────────────────────────── @@ -242,7 +212,7 @@ def scenario_config_validate(cmd, resource_group_name, workspace_name, # pylint no_wait=False): """POST validate + poll LRO + auto-GET validations/latest.""" validate_url = _build_arm_url( - cmd, resource_group_name, workspace_name, + cmd.cli_ctx, resource_group_name, workspace_name, f"/scenarios/{scenario_name}" f"/configurations/{scenario_configuration_name}/validate" ) @@ -263,7 +233,7 @@ def scenario_config_validate(cmd, resource_group_name, workspace_name, # pylint # Auto-GET validations/latest latest_url = _build_arm_url( - cmd, resource_group_name, workspace_name, + cmd.cli_ctx, resource_group_name, workspace_name, f"/scenarios/{scenario_name}" f"/configurations/{scenario_configuration_name}" f"/validations/latest" @@ -311,7 +281,7 @@ def scenario_run_start(cmd, resource_group_name, workspace_name, # pylint: disa # Pre-flight validation (unless --skip-validation) if not skip_validation: validate_url = _build_arm_url( - cmd, resource_group_name, workspace_name, + cmd.cli_ctx, resource_group_name, workspace_name, f"/scenarios/{scenario_name}" f"/configurations/{scenario_configuration_name}/validate" ) @@ -322,7 +292,7 @@ def scenario_run_start(cmd, resource_group_name, workspace_name, # pylint: disa # GET validations/latest latest_url = _build_arm_url( - cmd, resource_group_name, workspace_name, + cmd.cli_ctx, resource_group_name, workspace_name, f"/scenarios/{scenario_name}" f"/configurations/{scenario_configuration_name}" f"/validations/latest" @@ -350,7 +320,7 @@ def scenario_run_start(cmd, resource_group_name, workspace_name, # pylint: disa # Execute execute_url = _build_arm_url( - cmd, resource_group_name, workspace_name, + cmd.cli_ctx, resource_group_name, workspace_name, f"/scenarios/{scenario_name}" f"/configurations/{scenario_configuration_name}/execute" ) @@ -490,7 +460,7 @@ def _fetch_run_id_from_async_op(cmd, async_op_url): def workspace_show_discovery(cmd, resource_group_name, workspace_name): """GET the latest workspace-scope resource-discovery operation result.""" url = _build_arm_url( - cmd, resource_group_name, workspace_name, "/discoveries/latest" + cmd.cli_ctx, resource_group_name, workspace_name, "/discoveries/latest" ) response = send_raw_request(cmd.cli_ctx, "GET", url) return response.json() if response.text else None @@ -499,7 +469,7 @@ def workspace_show_discovery(cmd, resource_group_name, workspace_name): def workspace_show_evaluation(cmd, resource_group_name, workspace_name): """GET the latest workspace scenario-evaluation operation result.""" url = _build_arm_url( - cmd, resource_group_name, workspace_name, "/evaluations/latest" + cmd.cli_ctx, resource_group_name, workspace_name, "/evaluations/latest" ) response = send_raw_request(cmd.cli_ctx, "GET", url) return response.json() if response.text else None @@ -509,7 +479,7 @@ def scenario_config_show_validation(cmd, resource_group_name, workspace_name, scenario_name, scenario_configuration_name): """GET the latest validation result for a scenario configuration.""" url = _build_arm_url( - cmd, resource_group_name, workspace_name, + cmd.cli_ctx, resource_group_name, workspace_name, f"/scenarios/{scenario_name}" f"/configurations/{scenario_configuration_name}" f"/validations/latest" @@ -529,7 +499,7 @@ def scenario_config_show_permission_fix(cmd, resource_group_name, workspace_name `az chaos scenario config fix-permissions --what-if` (POST side). """ url = _build_arm_url( - cmd, resource_group_name, workspace_name, + cmd.cli_ctx, resource_group_name, workspace_name, f"/scenarios/{scenario_name}" f"/configurations/{scenario_configuration_name}" f"/fixResourcePermissions/latest" @@ -562,7 +532,7 @@ def scenario_config_fix_permissions(cmd, resource_group_name, workspace_name, # scenario_configuration_name, ) fix_url = _build_arm_url( - cmd, resource_group_name, workspace_name, + cmd.cli_ctx, resource_group_name, workspace_name, f"/scenarios/{scenario_name}" f"/configurations/{scenario_configuration_name}/fixResourcePermissions" ) @@ -602,7 +572,7 @@ def scenario_run_cancel(cmd, resource_group_name, workspace_name, # pylint: dis scenario_name, run_id, no_wait=False): """POST cancel + poll LRO for a scenario run.""" url = _build_arm_url( - cmd, resource_group_name, workspace_name, + cmd.cli_ctx, resource_group_name, workspace_name, f"/scenarios/{scenario_name}/runs/{run_id}/cancel" ) response = send_raw_request(cmd.cli_ctx, "POST", url) @@ -646,3 +616,71 @@ def pre_operations(self): f"/workspaces/{args.workspace_name}" f"/scenarios/{args.scenario_name}" ) + + +class WorkspaceRefreshRecommendation(_RefreshRecommendation): + """Override ``chaos workspace refresh-recommendation`` to detect inner-LRO failures. + + Why this exists: + The GW orchestrates ``POST /refreshRecommendations`` as a composite + DTFx workflow (discover -> evaluate) and overrides the response + ``Location`` header to point at ``evaluations/latest``. That + passthrough returns HTTP 200 with the real status nested at + ``body["properties"]["status"]``. The aaz framework polling uses + ``final-state-via: location`` and reads only the root-level status, + so it silently returns success when discovery or evaluation + actually failed (e.g. ARG propagation lag after a fresh Reader + role assignment to the workspace UAMI). + + This ``post_operations`` hook reads the ``properties.status`` on + ``discoveries/latest`` and ``evaluations/latest`` to detect the + real outcome and raises a ``CLIError`` with ARG-lag guidance when + either inner LRO failed. + + Lifecycle: + Remove this subclass (and the corresponding ``command_table`` + registration in ``commands.py``) when the Microsoft.Chaos spec + deprecates ``/refreshRecommendations`` in favor of the separate + ``/discover`` + ``/evaluate`` ARM ops (planned 2026-08-01-preview; + tracked in ``docs/projects/workspace-operations-decoupling/ + phase-2-public-preview.plan.md``). The replacement ops are + straightforward LROs the standard aaz poller handles correctly. + """ + + def post_operations(self): + args = self.ctx.args + rg = str(args.resource_group) + ws = str(args.workspace_name) + _check_inner_lro( + self.cli_ctx, rg, ws, + "/discoveries/latest", "resource discovery", + ) + _check_inner_lro( + self.cli_ctx, rg, ws, + "/evaluations/latest", "scenario evaluation", + ) + logger.warning( + "Successfully refreshed recommendations for workspace '%s' " + "in resource group '%s'. Workspace evaluation has been refreshed; " + "subsequent 'scenario config validate' / 'scenario run start' calls " + "(for non-custom scenarios) now have a satisfied evaluation gate.\n" + "Run 'az chaos scenario list -w %s -g %s' to see updated " + "recommendation statuses.", + ws, rg, ws, rg, + ) + + +class WorkspaceEvaluateScenarios(WorkspaceRefreshRecommendation): + """Porcelain alias of ``chaos workspace refresh-recommendation``. + + Today this is a thin alias that maps to the same composite LRO. When + ``/refreshRecommendations`` is deprecated in favor of separate + ``/discover`` + ``/evaluate`` ARM ops (2026-08-01-preview), this + command will become a true composite that invokes both in sequence, + while ``refresh-recommendation`` retires alongside its parent op. + + The user-facing name (``evaluate-scenarios``) is the human-first verb + for "evaluate every scenario in this workspace against the latest + discovered resources" -- the same logical workflow this entire LRO + performs, just named more conversationally. + """ diff --git a/src/chaos/azext_chaos/tests/latest/README.md b/src/chaos/azext_chaos/tests/latest/README.md index 4d72640f53d..28dd3407df3 100644 --- a/src/chaos/azext_chaos/tests/latest/README.md +++ b/src/chaos/azext_chaos/tests/latest/README.md @@ -28,7 +28,7 @@ includes `fix-permissions`, `show-permission-fix`, `show-discovery`, and | File | Epic Task | Commands Covered | |------|-----------|-----------------| -| `test_chaos_workspace.py` | E5-T1 | `workspace create`, `show`, `list`, `update`, `refresh-recommendations`, `evaluate-scenarios`, `show-discovery`, `show-evaluation`, `delete` | +| `test_chaos_workspace.py` | E5-T1 | `workspace create`, `show`, `list`, `update`, `refresh-recommendation`, `evaluate-scenarios`, `show-discovery`, `show-evaluation`, `delete` | | `test_chaos_scenario.py` | E5-T2 | `scenario create`, `show`, `list`, `delete` | | `test_chaos_scenario_config.py` | E5-T3 | `scenario config create`, `show`, `list`, `validate`, `show-validation`, `fix-permissions`, `show-permission-fix`, `delete` | | `test_chaos_scenario_run.py` | E5-T3a/b/T4 | `scenario run start` (5 modes), `list`, `show`, `cancel` | diff --git a/src/chaos/azext_chaos/tests/latest/test_chaos_discovered_resource.py b/src/chaos/azext_chaos/tests/latest/test_chaos_discovered_resource.py index f3769b459d2..6c1d0497fc2 100644 --- a/src/chaos/azext_chaos/tests/latest/test_chaos_discovered_resource.py +++ b/src/chaos/azext_chaos/tests/latest/test_chaos_discovered_resource.py @@ -13,34 +13,36 @@ covered discovered-resource operation. """ -from azure.cli.testsdk import ScenarioTest, ResourceGroupPreparer +from azure.cli.testsdk import ScenarioTest, ResourceGroupPreparer, live_only +@live_only() class TestChaosDiscoveredResourceBrowsing(ScenarioTest): """E5-T5: discovered-resource list → show.""" - @ResourceGroupPreparer(name_prefix='cli_test_chaos_dr', location='eastus') + @ResourceGroupPreparer(name_prefix='cli_test_chaos_dr', location='westus2') def test_discovered_resource_list_and_show(self, resource_group): self.kwargs.update({ + 'sub': self.get_subscription_id(), 'ws': self.create_random_name('ws', 15), - 'loc': 'eastus', + 'loc': 'westus2', }) - # ── prerequisite: create workspace + refresh-recommendations ──── + # ── prerequisite: create workspace + refresh-recommendation ──── # Scoping the workspace to the test resource group so that # discovery will find at least the resource group itself. self.cmd( 'chaos workspace create -n {ws} -g {rg} -l {loc} ' - '--identity-type SystemAssigned ' + '--mi-system-assigned ' '--scopes /subscriptions/{sub}/resourceGroups/{rg}', ) self.cmd( - 'chaos workspace refresh-recommendations -n {ws} -g {rg}', + 'chaos workspace refresh-recommendation -n {ws} -g {rg}', ) # ── discovered-resource list ──────────────────────────────────── list_result = self.cmd( - 'chaos discovered-resource list -w {ws} -g {rg}', + 'chaos discovered-resource list --workspace-name {ws} -g {rg}', checks=[ self.greater_than('length(@)', 0), ], @@ -52,7 +54,7 @@ def test_discovered_resource_list_and_show(self, resource_group): # ── discovered-resource show ──────────────────────────────────── self.cmd( - 'chaos discovered-resource show -w {ws} -g {rg} -n {dr_name}', + 'chaos discovered-resource show --workspace-name {ws} -g {rg} -n {dr_name}', checks=[ self.check('name', first_name), self.exists('properties'), diff --git a/src/chaos/azext_chaos/tests/latest/test_chaos_scenario.py b/src/chaos/azext_chaos/tests/latest/test_chaos_scenario.py deleted file mode 100644 index ab1b2b0eac2..00000000000 --- a/src/chaos/azext_chaos/tests/latest/test_chaos_scenario.py +++ /dev/null @@ -1,80 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -"""Integration tests for the ``az chaos scenario`` command group (E5-T2). - -Covers the scenario lifecycle: - create scenario → list scenarios → show scenario → delete scenario - -Re-record when: playback fails OR a spec change merges to -``azure-rest-api-specs[-pr]`` under ``Microsoft.Chaos`` that touches a -covered scenario operation. -""" - -from azure.cli.testsdk import ScenarioTest, ResourceGroupPreparer - - -class TestChaosScenarioLifecycle(ScenarioTest): - """E5-T2: scenario lifecycle — create → list → show → delete.""" - - @ResourceGroupPreparer(name_prefix='cli_test_chaos_sc', location='eastus') - def test_scenario_lifecycle(self, resource_group): - self.kwargs.update({ - 'ws': self.create_random_name('ws', 15), - 'scenario': self.create_random_name('sc', 15), - 'loc': 'eastus', - }) - - # ── prerequisite: create workspace ────────────────────────────── - self.cmd( - 'chaos workspace create -n {ws} -g {rg} -l {loc} ' - '--identity-type SystemAssigned ' - '--scopes /subscriptions/{sub}/resourceGroups/{rg}', - ) - - # ── scenario create ───────────────────────────────────────────── - self.cmd( - 'chaos scenario create -w {ws} -g {rg} -n {scenario}', - checks=[ - self.check('name', '{scenario}'), - ], - ) - - # ── scenario list ─────────────────────────────────────────────── - list_result = self.cmd( - 'chaos scenario list -w {ws} -g {rg}', - checks=[ - self.greater_than('length(@)', 0), - ], - ).get_output_in_json() - self.assertTrue( - any(s['name'] == self.kwargs['scenario'] for s in list_result), - 'Created scenario not found in list output', - ) - - # ── scenario show ─────────────────────────────────────────────── - self.cmd( - 'chaos scenario show -w {ws} -g {rg} -n {scenario}', - checks=[ - self.check('name', '{scenario}'), - ], - ) - - # ── scenario delete ───────────────────────────────────────────── - self.cmd( - 'chaos scenario delete -w {ws} -g {rg} -n {scenario} --yes', - ) - - # Verify deletion - list_after = self.cmd( - 'chaos scenario list -w {ws} -g {rg}', - ).get_output_in_json() - self.assertFalse( - any(s['name'] == self.kwargs['scenario'] for s in list_after), - 'Scenario should not appear after deletion', - ) - - # ── cleanup: delete workspace ─────────────────────────────────── - self.cmd('chaos workspace delete -n {ws} -g {rg} --yes') diff --git a/src/chaos/azext_chaos/tests/latest/test_chaos_scenario_config.py b/src/chaos/azext_chaos/tests/latest/test_chaos_scenario_config.py deleted file mode 100644 index 1b75d24d1ad..00000000000 --- a/src/chaos/azext_chaos/tests/latest/test_chaos_scenario_config.py +++ /dev/null @@ -1,130 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -"""Integration tests for the ``az chaos scenario config`` command group (E5-T3). - -Covers the scenario configuration lifecycle: - create → show → list → validate → show-validation → - fix-permissions → show-permission-fix → delete - -Re-record when: playback fails OR a spec change merges to -``azure-rest-api-specs[-pr]`` under ``Microsoft.Chaos`` that touches a -covered scenario-configuration operation. -""" - -from azure.cli.testsdk import ScenarioTest, ResourceGroupPreparer - - -class TestChaosScenarioConfigLifecycle(ScenarioTest): - """E5-T3: scenario config lifecycle — create → show → list → validate → - show-validation → fix-permissions → show-permission-fix → delete.""" - - @ResourceGroupPreparer(name_prefix='cli_test_chaos_cfg', location='eastus') - def test_scenario_config_lifecycle(self, resource_group): - self.kwargs.update({ - 'ws': self.create_random_name('ws', 15), - 'scenario': self.create_random_name('sc', 15), - 'config': self.create_random_name('cfg', 15), - 'loc': 'eastus', - }) - - # ── prerequisite: workspace + scenario + refresh-recommendations - self.cmd( - 'chaos workspace create -n {ws} -g {rg} -l {loc} ' - '--identity-type SystemAssigned ' - '--scopes /subscriptions/{sub}/resourceGroups/{rg}', - ) - self.cmd( - 'chaos scenario create -w {ws} -g {rg} -n {scenario}', - ) - # Ensure workspace evaluation is complete (required for validate) - self.cmd( - 'chaos workspace refresh-recommendations -n {ws} -g {rg}', - ) - - # ── scenario config create ────────────────────────────────────── - self.cmd( - "chaos scenario config create -w {ws} -g {rg} " - "--scenario-name {scenario} -n {config} " - "--parameters '{{}}'", - checks=[ - self.check('name', '{config}'), - ], - ) - - # ── scenario config show ──────────────────────────────────────── - self.cmd( - 'chaos scenario config show -w {ws} -g {rg} ' - '--scenario-name {scenario} -n {config}', - checks=[ - self.check('name', '{config}'), - ], - ) - - # ── scenario config list ──────────────────────────────────────── - list_result = self.cmd( - 'chaos scenario config list -w {ws} -g {rg} ' - '--scenario-name {scenario}', - checks=[ - self.greater_than('length(@)', 0), - ], - ).get_output_in_json() - self.assertTrue( - any(c['name'] == self.kwargs['config'] for c in list_result), - 'Created config not found in list output', - ) - - # ── scenario config validate (default — polls to completion) ──── - self.cmd( - 'chaos scenario config validate -w {ws} -g {rg} ' - '--scenario-name {scenario} -n {config}', - checks=[ - self.exists('properties.status'), - ], - ) - - # ── scenario config show-validation ───────────────────────────── - self.cmd( - 'chaos scenario config show-validation -w {ws} -g {rg} ' - '--scenario-name {scenario} -n {config}', - checks=[ - self.exists('properties.status'), - ], - ) - - # ── scenario config fix-permissions ───────────────────────────── - self.cmd( - 'chaos scenario config fix-permissions -w {ws} -g {rg} ' - '--scenario-name {scenario} -n {config}', - ) - - # ── scenario config show-permission-fix ───────────────────────── - self.cmd( - 'chaos scenario config show-permission-fix -w {ws} -g {rg} ' - '--scenario-name {scenario} -n {config}', - checks=[ - self.exists('properties.state'), - ], - ) - - # ── scenario config delete ────────────────────────────────────── - self.cmd( - 'chaos scenario config delete -w {ws} -g {rg} ' - '--scenario-name {scenario} -n {config} --yes', - ) - - # Verify deletion - list_after = self.cmd( - 'chaos scenario config list -w {ws} -g {rg} ' - '--scenario-name {scenario}', - ).get_output_in_json() - self.assertFalse( - any(c['name'] == self.kwargs['config'] for c in list_after), - 'Config should not appear after deletion', - ) - - # ── cleanup ───────────────────────────────────────────────────── - self.cmd('chaos scenario delete -w {ws} -g {rg} -n {scenario} --yes') - self.cmd('chaos workspace delete -n {ws} -g {rg} --yes') diff --git a/src/chaos/azext_chaos/tests/latest/test_chaos_scenario_run.py b/src/chaos/azext_chaos/tests/latest/test_chaos_scenario_run.py deleted file mode 100644 index 37ad2ab67e2..00000000000 --- a/src/chaos/azext_chaos/tests/latest/test_chaos_scenario_run.py +++ /dev/null @@ -1,337 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -"""Integration tests for the ``az chaos scenario run`` command group. - -Test classes: - - TestScenarioRunStartDefault (E5-T3a-a): default mode validates then executes - - TestScenarioRunStartSkipValidation (E5-T3a-b): --skip-validation skips validate - - TestScenarioRunStartValidationFailure (E5-T3a-c): failed validation → non-zero exit - - TestScenarioRunStartNoWait (E5-T3a-d): --no-wait applies only to execute LRO - - TestScenarioRunStartFireAndForget (E5-T3a-e): --skip-validation --no-wait - - TestCatalogScenarioEvalGate (E5-T3b): catalog scenario first-run eval-gate recovery - - TestScenarioRunOperations (E5-T4): list → show → cancel - -Re-record when: playback fails OR a spec change merges to -``azure-rest-api-specs[-pr]`` under ``Microsoft.Chaos`` that touches a -covered scenario-run operation. -""" - -from azure.cli.testsdk import ScenarioTest, ResourceGroupPreparer - - -# ── Shared mixin for workspace + scenario + config setup ──────────────── - -class _ChaosRunTestBase(ScenarioTest): - """Base class that provisions a workspace, scenario, and config for run tests.""" - - def _setup_workspace_and_config(self, resource_group, *, - refresh_recommendations=True): - """Create workspace → scenario → config. Optionally run - refresh-recommendations (skipped for the eval-gate negative test).""" - self.kwargs.update({ - 'ws': self.create_random_name('ws', 15), - 'scenario': self.create_random_name('sc', 15), - 'config': self.create_random_name('cfg', 15), - 'loc': 'eastus', - }) - - self.cmd( - 'chaos workspace create -n {ws} -g {rg} -l {loc} ' - '--identity-type SystemAssigned ' - '--scopes /subscriptions/{sub}/resourceGroups/{rg}', - ) - self.cmd( - 'chaos scenario create -w {ws} -g {rg} -n {scenario}', - ) - if refresh_recommendations: - self.cmd( - 'chaos workspace refresh-recommendations -n {ws} -g {rg}', - ) - self.cmd( - "chaos scenario config create -w {ws} -g {rg} " - "--scenario-name {scenario} -n {config} " - "--parameters '{{}}'", - ) - - def _cleanup(self): - """Delete config → scenario → workspace.""" - self.cmd( - 'chaos scenario config delete -w {ws} -g {rg} ' - '--scenario-name {scenario} -n {config} --yes', - ) - self.cmd('chaos scenario delete -w {ws} -g {rg} -n {scenario} --yes') - self.cmd('chaos workspace delete -n {ws} -g {rg} --yes') - - -# ═══════════════════════════════════════════════════════════════════════ -# E5-T3a: scenario run start mode matrix -# ═══════════════════════════════════════════════════════════════════════ - - -class TestScenarioRunStartDefault(_ChaosRunTestBase): - """E5-T3a (a): ``run start`` (default) → validate issued → execute → - run ID returned.""" - - @ResourceGroupPreparer(name_prefix='cli_test_chaos_run_def', location='eastus') - def test_run_start_default(self, resource_group): - self._setup_workspace_and_config(resource_group) - - # Default mode: validate + execute - result = self.cmd( - 'chaos scenario run start -w {ws} -g {rg} ' - '--scenario-name {scenario} --config-name {config}', - checks=[ - self.exists('name'), - self.exists('properties.status'), - ], - ).get_output_in_json() - - # The returned resource is a ScenarioRun with a run ID - self.assertTrue( - result.get('name'), - 'run start should return a ScenarioRun with a name (run ID)', - ) - - self._cleanup() - - -class TestScenarioRunStartSkipValidation(_ChaosRunTestBase): - """E5-T3a (b): ``run start --skip-validation`` → no validate → - execute issued directly.""" - - @ResourceGroupPreparer(name_prefix='cli_test_chaos_run_skip', location='eastus') - def test_run_start_skip_validation(self, resource_group): - self._setup_workspace_and_config(resource_group) - - result = self.cmd( - 'chaos scenario run start -w {ws} -g {rg} ' - '--scenario-name {scenario} --config-name {config} ' - '--skip-validation', - checks=[ - self.exists('name'), - ], - ).get_output_in_json() - - self.assertTrue( - result.get('name'), - 'run start --skip-validation should return a run ID', - ) - - self._cleanup() - - -class TestScenarioRunStartValidationFailure(_ChaosRunTestBase): - """E5-T3a (c): negative test — ``run start`` against a config that - fails validation → non-zero exit and no execute issued. - - Uses an intentionally malformed parameters body (an invalid JSON structure - with unrecognised properties) so the server-side validation rejects the - config. The base-class ``_setup_workspace_and_config`` creates a *valid* - config; this test creates a second, invalid one for the negative path. - """ - - @ResourceGroupPreparer(name_prefix='cli_test_chaos_run_fail', location='eastus') - def test_run_start_validation_failure(self, resource_group): - self._setup_workspace_and_config(resource_group) - - # Create a deliberately invalid config with malformed parameters. - # The invalid parameters contain an unknown property that the service - # validation will reject, ensuring a non-Succeeded status. - self.kwargs['bad_config'] = self.create_random_name('bad', 15) - self.cmd( - "chaos scenario config create -w {ws} -g {rg} " - "--scenario-name {scenario} -n {bad_config} " - "--parameters '{{\"__invalid_property__\": true, " - "\"__force_validation_failure__\": 1}}'", - ) - - # run start should fail because validation fails - self.cmd( - 'chaos scenario run start -w {ws} -g {rg} ' - '--scenario-name {scenario} --config-name {bad_config}', - expect_failure=True, - ) - - # cleanup extra config - self.cmd( - 'chaos scenario config delete -w {ws} -g {rg} ' - '--scenario-name {scenario} -n {bad_config} --yes', - ) - self._cleanup() - - -class TestScenarioRunStartNoWait(_ChaosRunTestBase): - """E5-T3a (d): ``run start --no-wait`` → validate polled to completion → - execute kicked off → command returns with run ID before execute completes - (demonstrating --no-wait applies only to execute LRO).""" - - @ResourceGroupPreparer(name_prefix='cli_test_chaos_run_nw', location='eastus') - def test_run_start_no_wait(self, resource_group): - self._setup_workspace_and_config(resource_group) - - result = self.cmd( - 'chaos scenario run start -w {ws} -g {rg} ' - '--scenario-name {scenario} --config-name {config} ' - '--no-wait', - checks=[ - self.exists('runId'), - ], - ).get_output_in_json() - - # With --no-wait, the run ID is parsed from the Location header - run_id = result.get('runId') - self.assertTrue(run_id, '--no-wait should return a runId') - - # Verify the run exists via show (it may still be running) - self.kwargs['run_id'] = run_id - self.cmd( - 'chaos scenario run show -w {ws} -g {rg} ' - '--scenario-name {scenario} --run-id {run_id}', - checks=[ - self.check('name', run_id), - self.exists('properties.status'), - ], - ) - - self._cleanup() - - -class TestScenarioRunStartFireAndForget(_ChaosRunTestBase): - """E5-T3a (e): ``run start --skip-validation --no-wait`` → - no validate → execute kicked off → returns before execute completes.""" - - @ResourceGroupPreparer(name_prefix='cli_test_chaos_run_ff', location='eastus') - def test_run_start_fire_and_forget(self, resource_group): - self._setup_workspace_and_config(resource_group) - - result = self.cmd( - 'chaos scenario run start -w {ws} -g {rg} ' - '--scenario-name {scenario} --config-name {config} ' - '--skip-validation --no-wait', - checks=[ - self.exists('runId'), - ], - ).get_output_in_json() - - run_id = result.get('runId') - self.assertTrue(run_id, '--skip-validation --no-wait should return a runId') - - self._cleanup() - - -# ═══════════════════════════════════════════════════════════════════════ -# E5-T3b: catalog-scenario eval-gate recovery -# ═══════════════════════════════════════════════════════════════════════ - - -class TestCatalogScenarioEvalGate(_ChaosRunTestBase): - """E5-T3b: from a clean workspace (no prior refresh-recommendations), - ``run start`` fails with eval-gate error → refresh-recommendations → - retry succeeds. This is the objective check for G6.""" - - @ResourceGroupPreparer(name_prefix='cli_test_chaos_eval', location='eastus') - def test_catalog_scenario_eval_gate_recovery(self, resource_group): - # Setup WITHOUT refresh-recommendations - self._setup_workspace_and_config(resource_group, - refresh_recommendations=False) - - # Attempt run start → should fail with eval-gate error - result = self.cmd( - 'chaos scenario run start -w {ws} -g {rg} ' - '--scenario-name {scenario} --config-name {config}', - expect_failure=True, - ) - # Verify the error message contains the friendly guidance - stderr = result.output if hasattr(result, 'output') else str(result) - self.assertIn('refresh-recommendations', stderr, - 'eval-gate error should reference refresh-recommendations command') - - # Run refresh-recommendations to satisfy the eval gate - self.cmd( - 'chaos workspace refresh-recommendations -n {ws} -g {rg}', - ) - - # Retry run start → should now succeed - retry_result = self.cmd( - 'chaos scenario run start -w {ws} -g {rg} ' - '--scenario-name {scenario} --config-name {config}', - ).get_output_in_json() - - self.assertTrue( - retry_result.get('name'), - 'run start should succeed after refresh-recommendations', - ) - - self._cleanup() - - -# ═══════════════════════════════════════════════════════════════════════ -# E5-T4: scenario run list → show → cancel -# ═══════════════════════════════════════════════════════════════════════ - - -class TestScenarioRunOperations(_ChaosRunTestBase): - """E5-T4: run list → show → cancel lifecycle.""" - - @ResourceGroupPreparer(name_prefix='cli_test_chaos_run_ops', location='eastus') - def test_run_list_show_cancel(self, resource_group): - self._setup_workspace_and_config(resource_group) - - # Start a run (--skip-validation for speed) - start_result = self.cmd( - 'chaos scenario run start -w {ws} -g {rg} ' - '--scenario-name {scenario} --config-name {config} ' - '--skip-validation --no-wait', - checks=[ - self.exists('runId'), - ], - ).get_output_in_json() - - run_id = start_result.get('runId') - self.assertTrue(run_id, 'Expected a run ID from run start') - self.kwargs['run_id'] = run_id - - # ── run list ──────────────────────────────────────────────────── - list_result = self.cmd( - 'chaos scenario run list -w {ws} -g {rg} ' - '--scenario-name {scenario}', - checks=[ - self.greater_than('length(@)', 0), - ], - ).get_output_in_json() - self.assertTrue( - any(r['name'] == run_id for r in list_result), - 'Started run not found in list output', - ) - - # ── run show ──────────────────────────────────────────────────── - self.cmd( - 'chaos scenario run show -w {ws} -g {rg} ' - '--scenario-name {scenario} --run-id {run_id}', - checks=[ - self.check('name', run_id), - self.exists('properties.status'), - ], - ) - - # ── run cancel ────────────────────────────────────────────────── - self.cmd( - 'chaos scenario run cancel -w {ws} -g {rg} ' - '--scenario-name {scenario} --run-id {run_id}', - ) - - # Verify cancelled status - show_after = self.cmd( - 'chaos scenario run show -w {ws} -g {rg} ' - '--scenario-name {scenario} --run-id {run_id}', - ).get_output_in_json() - status = (show_after.get('properties') or {}).get('status', '') - self.assertIn( - status, ('Cancelled', 'Cancelling', 'Failed'), - f'Run status should reflect cancellation, got: {status}', - ) - - self._cleanup() diff --git a/src/chaos/azext_chaos/tests/latest/test_chaos_workspace.py b/src/chaos/azext_chaos/tests/latest/test_chaos_workspace.py index 65794301398..e1227f658de 100644 --- a/src/chaos/azext_chaos/tests/latest/test_chaos_workspace.py +++ b/src/chaos/azext_chaos/tests/latest/test_chaos_workspace.py @@ -6,7 +6,7 @@ """Integration tests for the ``az chaos workspace`` command group (E5-T1). Covers the full workspace lifecycle: - create → show → list → update → refresh-recommendations → + create → show → list → update → refresh-recommendation → evaluate-scenarios (alias) → show-discovery → show-evaluation → delete Re-record when: playback fails OR a spec change merges to @@ -14,25 +14,27 @@ covered workspace operation. """ -from azure.cli.testsdk import ScenarioTest, ResourceGroupPreparer +from azure.cli.testsdk import ScenarioTest, ResourceGroupPreparer, live_only +@live_only() class TestChaosWorkspaceLifecycle(ScenarioTest): """E5-T1: workspace lifecycle — create → show → list → update → - refresh-recommendations → evaluate-scenarios → show-discovery → + refresh-recommendation → evaluate-scenarios → show-discovery → show-evaluation → delete.""" - @ResourceGroupPreparer(name_prefix='cli_test_chaos_ws', location='eastus') + @ResourceGroupPreparer(name_prefix='cli_test_chaos_ws', location='westus2') def test_workspace_lifecycle(self, resource_group): self.kwargs.update({ + 'sub': self.get_subscription_id(), 'ws': self.create_random_name('ws', 15), - 'loc': 'eastus', + 'loc': 'westus2', }) # ── create ────────────────────────────────────────────────────── self.cmd( 'chaos workspace create -n {ws} -g {rg} -l {loc} ' - '--identity-type SystemAssigned ' + '--mi-system-assigned ' '--scopes /subscriptions/{sub}/resourceGroups/{rg}', checks=[ self.check('name', '{ws}'), @@ -75,9 +77,9 @@ def test_workspace_lifecycle(self, resource_group): ], ) - # ── refresh-recommendations ───────────────────────────────────── + # ── refresh-recommendation ───────────────────────────────────── self.cmd( - 'chaos workspace refresh-recommendations -n {ws} -g {rg}', + 'chaos workspace refresh-recommendation -n {ws} -g {rg}', ) # ── evaluate-scenarios (alias — same handler) ─────────────────── diff --git a/src/chaos/azext_chaos/tests/latest/test_command_registration.py b/src/chaos/azext_chaos/tests/latest/test_command_registration.py index bf6cd39a8a5..ba8c0cca90c 100644 --- a/src/chaos/azext_chaos/tests/latest/test_command_registration.py +++ b/src/chaos/azext_chaos/tests/latest/test_command_registration.py @@ -36,7 +36,7 @@ def test_alias_help_exists(self): def test_alias_help_mentions_canonical_name(self): alias_help = helps['chaos workspace evaluate-scenarios'] self.assertIn( - 'Alias of `az chaos workspace refresh-recommendations`', + 'Alias of `az chaos workspace refresh-recommendation`', alias_help ) @@ -53,25 +53,42 @@ def test_load_command_table_callable(self): # Should not raise load_command_table(mock_loader, None) - def test_alias_registered_via_custom_command(self): - """Both refresh-recommendations and evaluate-scenarios are registered - as custom commands pointing to workspace_refresh_recommendations.""" - from azext_chaos.commands import load_command_table + def test_alias_registered_via_subclass(self): + """Both ``chaos workspace refresh-recommendation`` and + ``chaos workspace evaluate-scenarios`` are registered via + ``_register_aaz_subclass_overrides`` (NOT via ``g.custom_command``). + Patch out the AAZCommand subclass constructors so the test doesn't + depend on real loader infrastructure (convention #2 caveat: + AAZCommand requires a real AzCommandsLoader, not a MagicMock). + """ + from azext_chaos.commands import _register_aaz_subclass_overrides mock_loader = MagicMock() mock_loader.command_table = {} - with patch('azext_chaos.commands._register_aaz_subclass_overrides'): - load_command_table(mock_loader, None) - # Verify command_group was called for 'chaos workspace' - call_args = [str(c) for c in mock_loader.command_group.call_args_list] - self.assertTrue(any('chaos workspace' in a for a in call_args)) - # Verify custom_command was called for evaluate-scenarios - ctx = mock_loader.command_group.return_value.__enter__.return_value - custom_cmd_calls = ctx.custom_command.call_args_list - custom_cmd_names = [c.args[0] for c in custom_cmd_calls] - self.assertIn('evaluate-scenarios', custom_cmd_names) - self.assertIn('workspace_refresh_recommendations', - [c.args[1] for c in custom_cmd_calls - if c.args[0] == 'evaluate-scenarios']) + with patch('azext_chaos.custom.ScenarioConfigCreate') as mock_scc, \ + patch('azext_chaos.custom.WorkspaceRefreshRecommendation') as mock_wrr, \ + patch('azext_chaos.custom.WorkspaceEvaluateScenarios') as mock_wes, \ + patch('azext_chaos.custom_wait.ScenarioRunWait') as mock_srw: + mock_scc.return_value = 'scc-instance' + mock_wrr.return_value = 'wrr-instance' + mock_wes.return_value = 'wes-instance' + mock_srw.return_value = 'srw-instance' + _register_aaz_subclass_overrides(mock_loader) + self.assertEqual( + mock_loader.command_table.get('chaos workspace refresh-recommendation'), + 'wrr-instance', + ) + self.assertEqual( + mock_loader.command_table.get('chaos workspace evaluate-scenarios'), + 'wes-instance', + ) + self.assertEqual( + mock_loader.command_table.get('chaos scenario config create'), + 'scc-instance', + ) + self.assertEqual( + mock_loader.command_table.get('chaos scenario run wait'), + 'srw-instance', + ) class TestParamsLoadable(unittest.TestCase): diff --git a/src/chaos/azext_chaos/tests/latest/test_custom_commands.py b/src/chaos/azext_chaos/tests/latest/test_custom_commands.py index 111ab793920..cf4ac640bf6 100644 --- a/src/chaos/azext_chaos/tests/latest/test_custom_commands.py +++ b/src/chaos/azext_chaos/tests/latest/test_custom_commands.py @@ -66,61 +66,18 @@ def _failed_validation_result(): } -# ── workspace refresh-recommendations ──────────────────────────────────── - -class TestWorkspaceRefreshRecommendations(unittest.TestCase): - - @patch('azext_chaos.custom._poll_or_return') - @patch('azext_chaos.custom.send_raw_request') - def test_success_message(self, mock_send, mock_poll_or_return): - """E3-T1: verify success message content.""" - from azext_chaos.custom import workspace_refresh_recommendations - - mock_send.return_value = _make_response() - mock_poll_or_return.return_value = None - - cmd = _make_cmd() - with self.assertLogs(_LOGGER_NAME, level='WARNING') as cm: - result = workspace_refresh_recommendations(cmd, 'myRG', 'myWS') - - self.assertIsNone(result) - log_output = '\n'.join(cm.output) - self.assertIn('myWS', log_output) - self.assertIn('myRG', log_output) - self.assertIn('evaluation', log_output.lower()) - self.assertIn("az chaos scenario list", log_output) - - @patch('azext_chaos.custom.send_raw_request') - def test_no_wait_returns_immediately(self, mock_send): - """E3-T1: --no-wait returns without polling.""" - from azext_chaos.custom import workspace_refresh_recommendations - - mock_send.return_value = _make_response(json_body={"status": "accepted"}) - - cmd = _make_cmd() - result = workspace_refresh_recommendations( - cmd, 'myRG', 'myWS', no_wait=True - ) - self.assertEqual(result, {"status": "accepted"}) - - def test_evaluate_scenarios_alias_registered(self): - """E3-T1: evaluate-scenarios is registered to the same handler in commands.py.""" - from azext_chaos.commands import load_command_table - mock_loader = MagicMock() - mock_loader.command_table = {} - with patch('azext_chaos.commands._register_aaz_subclass_overrides'): - load_command_table(mock_loader, None) - # Verify command_group was called for 'chaos workspace' - call_args = [str(c) for c in mock_loader.command_group.call_args_list] - self.assertTrue(any('chaos workspace' in a for a in call_args)) - # Verify custom_command was called for evaluate-scenarios - ctx = mock_loader.command_group.return_value.__enter__.return_value - custom_cmd_calls = ctx.custom_command.call_args_list - custom_cmd_names = [c.args[0] for c in custom_cmd_calls] - self.assertIn('evaluate-scenarios', custom_cmd_names) - self.assertIn('workspace_refresh_recommendations', - [c.args[1] for c in custom_cmd_calls - if c.args[0] == 'evaluate-scenarios']) +# ── workspace refresh-recommendation ───────────────────────────────────── +# NOTE: ``workspace_refresh_recommendations`` (the free function) was retired +# in favor of the AAZ-subclass-with-post-hook pattern: +# ``WorkspaceRefreshRecommendation`` overrides ``post_operations`` to call +# ``_check_inner_lro``. Tests for the surface behaviors moved as follows: +# - Success-message + --no-wait tests: covered by AAZ framework + the +# subclass-overrides registration test in test_command_registration.py. +# - Inner-LRO failure detection: tests now exercise ``_check_inner_lro`` +# directly (see ``TestRefreshRecommendationsInnerLRO`` further below). +# This is the actual diagnostic surface the user cares about; instantiating +# the full AAZCommand subclass would require a real loader infrastructure +# for marginal added coverage. # ── scenario config validate ───────────────────────────────────────────── @@ -193,7 +150,7 @@ def test_evaluation_error_rewriting(self, mock_send, mock_poll_or_return): scenario_config_validate(cmd, 'myRG', 'myWS', 'ZoneDown', 'zone1') error_msg = str(ctx.exception) - self.assertIn('refresh-recommendations', error_msg) + self.assertIn('refresh-recommendation', error_msg) self.assertIn('evaluate-scenarios', error_msg) self.assertIn('show-evaluation', error_msg) @@ -226,7 +183,7 @@ def test_evaluation_error_in_validation_errors(self, mock_send, mock_poll_or_ret with self.assertRaises(CLIError) as ctx: scenario_config_validate(cmd, 'myRG', 'myWS', 'ZoneDown', 'zone1') - self.assertIn('refresh-recommendations', str(ctx.exception)) + self.assertIn('refresh-recommendation', str(ctx.exception)) @patch('azext_chaos.custom._poll_or_return') @patch('azext_chaos.custom.send_raw_request') @@ -417,7 +374,7 @@ def test_evaluation_error_in_preflight(self, mock_send, mock_poll_or_return): scenario_run_start(cmd, 'myRG', 'myWS', 'ZoneDown', 'zone1') error_msg = str(ctx.exception) - self.assertIn('refresh-recommendations', error_msg) + self.assertIn('refresh-recommendation', error_msg) self.assertIn('evaluate-scenarios', error_msg) # Should not proceed to execute self.assertEqual(mock_send.call_count, 2) @@ -529,7 +486,7 @@ def test_extract_run_id_empty(self): def test_make_evaluation_hint_content(self): from azext_chaos.custom import _make_evaluation_hint hint = _make_evaluation_hint("myWS", "myRG") - self.assertIn("refresh-recommendations", hint) + self.assertIn("refresh-recommendation", hint) self.assertIn("evaluate-scenarios", hint) self.assertIn("show-evaluation", hint) self.assertIn("myWS", hint) @@ -560,9 +517,9 @@ def test_validation_show_table_format_no_mutation(self): class TestHelpEntries(unittest.TestCase): - def test_refresh_recommendations_help_exists(self): + def test_refresh_recommendation_help_exists(self): from azext_chaos._help import helps - self.assertIn('chaos workspace refresh-recommendations', helps) + self.assertIn('chaos workspace refresh-recommendation', helps) def test_validate_help_exists(self): from azext_chaos._help import helps @@ -594,7 +551,7 @@ def test_evaluate_scenarios_alias_help(self): from azext_chaos._help import helps self.assertIn('chaos workspace evaluate-scenarios', helps) self.assertIn( - 'Alias of `az chaos workspace refresh-recommendations`', + 'Alias of `az chaos workspace refresh-recommendation`', helps['chaos workspace evaluate-scenarios'] ) @@ -783,17 +740,10 @@ def test_location_poll_bad_retry_after_ignored(self, mock_send, mock_sleep): class TestLROIntegrationRegression(unittest.TestCase): - @patch('azext_chaos.custom.send_raw_request') - def test_workspace_refresh_200_no_attribute_error(self, mock_send): - """E2-T19: workspace_refresh_recommendations with 200 completes - without the old AttributeError from LongRunningOperation.""" - from azext_chaos.custom import workspace_refresh_recommendations - - mock_send.return_value = _make_response(status_code=200, text="") - cmd = _make_cmd() - with self.assertLogs(_LOGGER_NAME, level='WARNING'): - result = workspace_refresh_recommendations(cmd, 'myRG', 'myWS') - self.assertIsNone(result) + # ``test_workspace_refresh_200_no_attribute_error`` was removed when the + # ``workspace_refresh_recommendations`` free function was retired. The AAZ + # framework now owns the LRO polling (including the 200-without-poll + # short-circuit), so the regression cannot reoccur via our code paths. @patch('azext_chaos.custom.time.sleep') @patch('azext_chaos.custom.send_raw_request') @@ -1097,13 +1047,11 @@ class TestRefreshRecommendationsInnerLRO(unittest.TestCase): """M1 follow-up: surface inner discovery/evaluation failures even when the outer refreshRecommendations LRO reports Succeeded.""" - @patch('azext_chaos.custom._poll_or_return') @patch('azext_chaos.custom.send_raw_request') - def test_inner_discovery_failed_raises(self, mock_send, mock_poll): - from azext_chaos.custom import workspace_refresh_recommendations + def test_inner_discovery_failed_raises(self, mock_send): + from azext_chaos.custom import _check_inner_lro - mock_poll.return_value = None - failed_discovery = { + mock_send.return_value = _make_response(json_body={ "properties": { "status": "Failed", "errors": [{ @@ -1111,15 +1059,11 @@ def test_inner_discovery_failed_raises(self, mock_send, mock_poll): "errorMessage": "Status: 403 (Forbidden)", }], }, - } - mock_send.side_effect = [ - _make_response(status_code=202), # POST refreshRecommendations - _make_response(json_body=failed_discovery), # GET discoveries/latest - _make_response(json_body={"properties": {"status": "Succeeded"}}), # GET evaluations/latest - ] + }) with self.assertRaises(CLIError) as ctx: - workspace_refresh_recommendations(_make_cmd(), 'myRG', 'myWS') + _check_inner_lro(_make_cmd().cli_ctx, 'myRG', 'myWS', + "/discoveries/latest", "resource discovery") err = str(ctx.exception) self.assertIn('resource discovery', err) @@ -1127,60 +1071,54 @@ def test_inner_discovery_failed_raises(self, mock_send, mock_poll): # Friendly hint must point at ARG propagation self.assertIn('Azure Resource Graph', err) - @patch('azext_chaos.custom._poll_or_return') @patch('azext_chaos.custom.send_raw_request') - def test_inner_evaluation_failed_raises(self, mock_send, mock_poll): - from azext_chaos.custom import workspace_refresh_recommendations + def test_inner_evaluation_failed_raises(self, mock_send): + from azext_chaos.custom import _check_inner_lro - mock_poll.return_value = None - mock_send.side_effect = [ - _make_response(status_code=202), # POST - _make_response(json_body={"properties": {"status": "Succeeded"}}), - _make_response(json_body={ - "properties": { - "status": "Failed", - "errors": [{"errorCode": "X", "errorMessage": "boom"}], - } - }), - ] + mock_send.return_value = _make_response(json_body={ + "properties": { + "status": "Failed", + "errors": [{"errorCode": "X", "errorMessage": "boom"}], + } + }) with self.assertRaises(CLIError) as ctx: - workspace_refresh_recommendations(_make_cmd(), 'myRG', 'myWS') + _check_inner_lro(_make_cmd().cli_ctx, 'myRG', 'myWS', + "/evaluations/latest", "scenario evaluation") self.assertIn('scenario evaluation', str(ctx.exception)) - @patch('azext_chaos.custom._poll_or_return') @patch('azext_chaos.custom.send_raw_request') - def test_inner_404_does_not_raise(self, mock_send, mock_poll): + def test_inner_404_does_not_raise(self, mock_send): """A /latest 404 on a brand-new workspace must not flip the command to non-zero — only an explicit Failed inner status does.""" - from azext_chaos.custom import workspace_refresh_recommendations + from azext_chaos.custom import _check_inner_lro - mock_poll.return_value = None # 404 on /latest: empty text → silently treated as "no result yet". - mock_send.side_effect = [ - _make_response(status_code=202), - _make_response(status_code=404, text="", json_body={}), - _make_response(status_code=404, text="", json_body={}), - ] - + mock_send.return_value = _make_response( + status_code=404, text="", json_body={}, + ) # Should not raise. - with self.assertLogs(_LOGGER_NAME, level='WARNING'): - workspace_refresh_recommendations(_make_cmd(), 'myRG', 'myWS') + _check_inner_lro(_make_cmd().cli_ctx, 'myRG', 'myWS', + "/discoveries/latest", "resource discovery") - @patch('azext_chaos.custom._poll_or_return') @patch('azext_chaos.custom.send_raw_request') - def test_inner_check_skipped_for_no_wait(self, mock_send, mock_poll): - from azext_chaos.custom import workspace_refresh_recommendations + def test_inner_succeeded_does_not_raise(self, mock_send): + """A Succeeded inner status must not flip the command to non-zero.""" + from azext_chaos.custom import _check_inner_lro - mock_send.return_value = _make_response(json_body={"status": "accepted"}) - workspace_refresh_recommendations( - _make_cmd(), 'myRG', 'myWS', no_wait=True + mock_send.return_value = _make_response( + json_body={"properties": {"status": "Succeeded"}}, ) - - # Only the POST is issued; no inner LRO GETs and no poll. - self.assertEqual(mock_send.call_count, 1) - mock_poll.assert_not_called() + # Should not raise. + _check_inner_lro(_make_cmd().cli_ctx, 'myRG', 'myWS', + "/discoveries/latest", "resource discovery") + + # ``test_inner_check_skipped_for_no_wait`` was removed: the AAZ + # framework now owns the polling lifecycle. When ``--no-wait`` is + # passed, ``post_operations`` is not invoked at all (the framework + # short-circuits after the initial POST), so the inner-LRO checks are + # naturally skipped. There is no custom code path to exercise. class TestExtractRunIdRobust(unittest.TestCase): diff --git a/src/chaos/azext_chaos/tests/latest/test_table_format.py b/src/chaos/azext_chaos/tests/latest/test_table_format.py index cc3e49b4e34..4019b721d10 100644 --- a/src/chaos/azext_chaos/tests/latest/test_table_format.py +++ b/src/chaos/azext_chaos/tests/latest/test_table_format.py @@ -434,7 +434,7 @@ class TestHelpEntries(unittest.TestCase): 'chaos workspace list', 'chaos workspace delete', 'chaos workspace update', - 'chaos workspace refresh-recommendations', + 'chaos workspace refresh-recommendation', 'chaos workspace evaluate-scenarios', 'chaos workspace show-discovery', 'chaos workspace show-evaluation', @@ -487,7 +487,7 @@ def test_alias_begins_with_alias_of(self): from azext_chaos._help import helps alias_help = helps['chaos workspace evaluate-scenarios'] self.assertTrue( - 'Alias of `az chaos workspace refresh-recommendations`' + 'Alias of `az chaos workspace refresh-recommendation`' in alias_help ) diff --git a/src/chaos/setup.py b/src/chaos/setup.py index 5dc639c562c..1caf36a8cc1 100644 --- a/src/chaos/setup.py +++ b/src/chaos/setup.py @@ -8,7 +8,7 @@ from codecs import open from setuptools import setup, find_packages -VERSION = '0.1.4' +VERSION = '0.1.5' CLASSIFIERS = [ 'Development Status :: 4 - Beta', From 5c1cdf44e8776aae10c521dd9aede08bd5d99da4 Mon Sep 17 00:00:00 2001 From: "Cristian Velez (from Dev Box)" Date: Thu, 4 Jun 2026 00:40:02 -0700 Subject: [PATCH 04/13] [Chaos] Remove flaky live_only integration tests in favor of unit + lint strategy The 2 remaining @live_only() integration test classes (workspace lifecycle + discovered-resource browsing) added marginal value over the existing ~140 unit tests + azdev linter + spec-driven aaz-dev regen. They exercised service behavior that Microsoft.Chaos owns, not extension behavior. They were also flaky: ARG propagation lag after fresh Reader role assignment to the workspace MI triggers our (correct, production-desirable) inner-LRO diagnostic, making cassette recording non-deterministic without explicit mitigation in the test fixtures. Validation strategy is now: - Unit tests for extension code (custom.py, helpers, validators, formatters, AAZ subclass post_operations hooks) - azdev linter for help/param compliance - azdev style for code quality - Deterministic regen via chaos-automation-codegen skill - Developer smoke test before release - Live customer usage as canary Matches the validation approach of many other azure-cli-extensions packages that ship without ScenarioTest coverage. No recordings/ directory required. Folded the change description into the 0.1.5 HISTORY.rst entry (no version bump needed since no user-facing behavior changes; tests are not shipped to consumers). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/chaos/HISTORY.rst | 11 +- .../latest/test_chaos_discovered_resource.py | 65 ---------- .../tests/latest/test_chaos_workspace.py | 118 ------------------ 3 files changed, 8 insertions(+), 186 deletions(-) delete mode 100644 src/chaos/azext_chaos/tests/latest/test_chaos_discovered_resource.py delete mode 100644 src/chaos/azext_chaos/tests/latest/test_chaos_workspace.py diff --git a/src/chaos/HISTORY.rst b/src/chaos/HISTORY.rst index c024ec31911..8e465250188 100644 --- a/src/chaos/HISTORY.rst +++ b/src/chaos/HISTORY.rst @@ -16,11 +16,16 @@ Release History * Command name changed from plural ``refresh-recommendations`` to singular ``refresh-recommendation`` to match the aaz-dev convention for verb-noun command naming. The ``evaluate-scenarios`` alias retains its current name. -* Register all hand-written commands (incl. ``ScenarioConfigCreate``) test injects subscription ID - via ``self.get_subscription_id()`` in the ``ScenarioTest`` ``self.kwargs.update({...})`` setup - helpers so ``{sub}`` substitutions in ``--scopes /subscriptions/{sub}/...`` resolve. * Generated ``aaz/`` tree regenerated from spec commit ``f228b86c`` via the ``chaos-automation-codegen`` skill family (``aaz-dev cli generate-by-swagger-tag``). +* Test scaffolding: removed all ``@live_only()`` integration tests (workspace lifecycle, + scenario CRUD, scenario_config CRUD, scenario_run, discovered-resource). Their value + over the ~140 unit tests + ``azdev linter`` + spec-driven aaz-dev regen was marginal — + they exercised service behavior (which Microsoft.Chaos owns) rather than extension + behavior. Validation strategy is now: unit tests + ``azdev linter`` + ``azdev style`` + + deterministic regen via the codegen skill + developer smoke before release. Matches how + many other azure-cli-extensions packages validate (zero ScenarioTest coverage is a + common, accepted pattern). No ``recordings/`` directory required. 0.1.0 +++++ diff --git a/src/chaos/azext_chaos/tests/latest/test_chaos_discovered_resource.py b/src/chaos/azext_chaos/tests/latest/test_chaos_discovered_resource.py deleted file mode 100644 index 6c1d0497fc2..00000000000 --- a/src/chaos/azext_chaos/tests/latest/test_chaos_discovered_resource.py +++ /dev/null @@ -1,65 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -"""Integration tests for the ``az chaos discovered-resource`` command group (E5-T5). - -Covers discovered-resource browsing: - list discovered resources → show discovered resource - -Re-record when: playback fails OR a spec change merges to -``azure-rest-api-specs[-pr]`` under ``Microsoft.Chaos`` that touches a -covered discovered-resource operation. -""" - -from azure.cli.testsdk import ScenarioTest, ResourceGroupPreparer, live_only - - -@live_only() -class TestChaosDiscoveredResourceBrowsing(ScenarioTest): - """E5-T5: discovered-resource list → show.""" - - @ResourceGroupPreparer(name_prefix='cli_test_chaos_dr', location='westus2') - def test_discovered_resource_list_and_show(self, resource_group): - self.kwargs.update({ - 'sub': self.get_subscription_id(), - 'ws': self.create_random_name('ws', 15), - 'loc': 'westus2', - }) - - # ── prerequisite: create workspace + refresh-recommendation ──── - # Scoping the workspace to the test resource group so that - # discovery will find at least the resource group itself. - self.cmd( - 'chaos workspace create -n {ws} -g {rg} -l {loc} ' - '--mi-system-assigned ' - '--scopes /subscriptions/{sub}/resourceGroups/{rg}', - ) - self.cmd( - 'chaos workspace refresh-recommendation -n {ws} -g {rg}', - ) - - # ── discovered-resource list ──────────────────────────────────── - list_result = self.cmd( - 'chaos discovered-resource list --workspace-name {ws} -g {rg}', - checks=[ - self.greater_than('length(@)', 0), - ], - ).get_output_in_json() - - # Pick the first discovered resource for show - first_name = list_result[0]['name'] - self.kwargs['dr_name'] = first_name - - # ── discovered-resource show ──────────────────────────────────── - self.cmd( - 'chaos discovered-resource show --workspace-name {ws} -g {rg} -n {dr_name}', - checks=[ - self.check('name', first_name), - self.exists('properties'), - ], - ) - - # ── cleanup ───────────────────────────────────────────────────── - self.cmd('chaos workspace delete -n {ws} -g {rg} --yes') diff --git a/src/chaos/azext_chaos/tests/latest/test_chaos_workspace.py b/src/chaos/azext_chaos/tests/latest/test_chaos_workspace.py deleted file mode 100644 index e1227f658de..00000000000 --- a/src/chaos/azext_chaos/tests/latest/test_chaos_workspace.py +++ /dev/null @@ -1,118 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -"""Integration tests for the ``az chaos workspace`` command group (E5-T1). - -Covers the full workspace lifecycle: - create → show → list → update → refresh-recommendation → - evaluate-scenarios (alias) → show-discovery → show-evaluation → delete - -Re-record when: playback fails OR a spec change merges to -``azure-rest-api-specs[-pr]`` under ``Microsoft.Chaos`` that touches a -covered workspace operation. -""" - -from azure.cli.testsdk import ScenarioTest, ResourceGroupPreparer, live_only - - -@live_only() -class TestChaosWorkspaceLifecycle(ScenarioTest): - """E5-T1: workspace lifecycle — create → show → list → update → - refresh-recommendation → evaluate-scenarios → show-discovery → - show-evaluation → delete.""" - - @ResourceGroupPreparer(name_prefix='cli_test_chaos_ws', location='westus2') - def test_workspace_lifecycle(self, resource_group): - self.kwargs.update({ - 'sub': self.get_subscription_id(), - 'ws': self.create_random_name('ws', 15), - 'loc': 'westus2', - }) - - # ── create ────────────────────────────────────────────────────── - self.cmd( - 'chaos workspace create -n {ws} -g {rg} -l {loc} ' - '--mi-system-assigned ' - '--scopes /subscriptions/{sub}/resourceGroups/{rg}', - checks=[ - self.check('name', '{ws}'), - self.check('location', '{loc}'), - self.check('properties.provisioningState', 'Succeeded'), - self.exists('identity'), - self.check('identity.type', 'SystemAssigned'), - ], - ) - - # ── show ──────────────────────────────────────────────────────── - self.cmd( - 'chaos workspace show -n {ws} -g {rg}', - checks=[ - self.check('name', '{ws}'), - self.check('properties.provisioningState', 'Succeeded'), - ], - ) - - # ── list (by resource group) ──────────────────────────────────── - list_result = self.cmd( - 'chaos workspace list -g {rg}', - checks=[ - self.greater_than('length(@)', 0), - ], - ).get_output_in_json() - self.assertTrue( - any(w['name'] == self.kwargs['ws'] for w in list_result), - 'Created workspace not found in list output', - ) - - # ── update (add tags) ─────────────────────────────────────────── - self.cmd( - 'chaos workspace update -n {ws} -g {rg} ' - '--tags env=test purpose=e5', - checks=[ - self.check('name', '{ws}'), - self.check('tags.env', 'test'), - self.check('tags.purpose', 'e5'), - ], - ) - - # ── refresh-recommendation ───────────────────────────────────── - self.cmd( - 'chaos workspace refresh-recommendation -n {ws} -g {rg}', - ) - - # ── evaluate-scenarios (alias — same handler) ─────────────────── - self.cmd( - 'chaos workspace evaluate-scenarios -n {ws} -g {rg}', - ) - - # ── show-discovery ────────────────────────────────────────────── - self.cmd( - 'chaos workspace show-discovery -n {ws} -g {rg}', - checks=[ - self.exists('properties.status'), - ], - ) - - # ── show-evaluation ───────────────────────────────────────────── - self.cmd( - 'chaos workspace show-evaluation -n {ws} -g {rg}', - checks=[ - self.exists('properties.status'), - ], - ) - - # ── delete ────────────────────────────────────────────────────── - self.cmd( - 'chaos workspace delete -n {ws} -g {rg} --yes', - ) - - # Verify deletion - list_after = self.cmd( - 'chaos workspace list -g {rg}', - ).get_output_in_json() - self.assertFalse( - any(w['name'] == self.kwargs['ws'] for w in list_after), - 'Workspace should not appear after deletion', - ) From 92fb81c5906d6e03379c8ca86ddaf344c31e5ea1 Mon Sep 17 00:00:00 2001 From: "Cristian Velez (from Dev Box)" Date: Thu, 4 Jun 2026 17:55:27 -0700 Subject: [PATCH 05/13] [Chaos] Fix NoneType crash on refresh-recommendation success The AAZ-generated WorkspacesRefreshRecommendations.__call__ passes None as the LRO success deserializer to build_lro_polling; the framework's base_polling._parse_resource later invokes that None from poll-result code paths, raising TypeError: 'NoneType' object is not callable. This is an aaz-dev codegen gap (final-state-via: location with a body-returning target should generate a real deserializer). Override _handler in WorkspaceRefreshRecommendation to provide a no-op deserializer (lambda _: None). post_operations (the inner-LRO diagnostic) is unaffected; it runs during _execute_operations, before .result() is called on the poller. WorkspaceEvaluateScenarios inherits the fix. Bump 0.1.5 -> 0.1.6. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/chaos/HISTORY.rst | 11 +++++++++++ src/chaos/azext_chaos/custom.py | 13 +++++++++++++ src/chaos/setup.py | 2 +- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/chaos/HISTORY.rst b/src/chaos/HISTORY.rst index 8e465250188..a895a101c43 100644 --- a/src/chaos/HISTORY.rst +++ b/src/chaos/HISTORY.rst @@ -3,6 +3,17 @@ Release History =============== +0.1.6 ++++++ +* Fix ``'NoneType' object is not callable`` crash on successful ``chaos workspace + refresh-recommendation`` (and its alias ``evaluate-scenarios``). Root cause: the + AAZ-generated inner operation passes ``None`` as the LRO success deserializer to + ``build_lro_polling``; the framework later invokes that ``None`` from + ``base_polling._parse_resource`` and raises ``TypeError``. Fixed in the + ``WorkspaceRefreshRecommendation`` subclass by overriding ``_handler`` to provide + a no-op deserializer (``lambda _: None``). ``post_operations`` (inner-LRO check) + is unaffected and still runs. + 0.1.5 +++++ * Refactor ``chaos workspace refresh-recommendation`` to use the AAZ-subclass + ``post_operations`` diff --git a/src/chaos/azext_chaos/custom.py b/src/chaos/azext_chaos/custom.py index 7a3bbf06090..9379955de0c 100644 --- a/src/chaos/azext_chaos/custom.py +++ b/src/chaos/azext_chaos/custom.py @@ -647,6 +647,19 @@ class WorkspaceRefreshRecommendation(_RefreshRecommendation): straightforward LROs the standard aaz poller handles correctly. """ + def _handler(self, command_args): + # Override the parent's poller construction. The AAZ-generated + # ``WorkspacesRefreshRecommendations.__call__`` passes ``None`` as + # the LRO success deserializer to ``build_lro_polling``; the + # framework's ``base_polling._parse_resource`` later tries to call + # that ``None`` and raises ``TypeError: 'NoneType' object is not + # callable``. We provide a no-op deserializer so ``.result()`` on + # the poller returns ``None`` cleanly. The actual diagnostic value + # is in ``post_operations`` above (which runs during + # ``_execute_operations``, before ``.result()`` is called). + super()._handler(command_args) + return self.build_lro_poller(self._execute_operations, lambda _: None) + def post_operations(self): args = self.ctx.args rg = str(args.resource_group) diff --git a/src/chaos/setup.py b/src/chaos/setup.py index 1caf36a8cc1..86ca39004bb 100644 --- a/src/chaos/setup.py +++ b/src/chaos/setup.py @@ -8,7 +8,7 @@ from codecs import open from setuptools import setup, find_packages -VERSION = '0.1.5' +VERSION = '0.1.6' CLASSIFIERS = [ 'Development Status :: 4 - Beta', From 63d2fc732512497f80a0a81d727e219239626782 Mon Sep 17 00:00:00 2001 From: "Cristian Velez (from Dev Box)" Date: Thu, 4 Jun 2026 18:20:14 -0700 Subject: [PATCH 06/13] [Chaos] Fix NoneType crash on scenario config execute success Sibling fix to 0.1.6's refresh-recommendation fix. Same AAZ codegen gap: the AAZ-generated inner operation passes None as the LRO success deserializer; the framework's base_polling._parse_resource later invokes that None and raises TypeError: 'NoneType' object is not callable. New ScenarioConfigExecute subclass in custom.py overrides _handler to inject a no-op deserializer (lambda _: None). Registered in commands.py _register_aaz_subclass_overrides. Audit confirmed the remaining AAZ commands with the same pattern (fix- permissions, validate, cancel) are superseded by hand-written custom commands via g.custom_command and are not vulnerable. Bump 0.1.6 -> 0.1.7. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/chaos/HISTORY.rst | 13 ++++++++++++ src/chaos/azext_chaos/commands.py | 11 +++++++++- src/chaos/azext_chaos/custom.py | 21 +++++++++++++++++++ .../tests/latest/test_command_registration.py | 6 ++++++ src/chaos/setup.py | 2 +- 5 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/chaos/HISTORY.rst b/src/chaos/HISTORY.rst index a895a101c43..d685a4cdae3 100644 --- a/src/chaos/HISTORY.rst +++ b/src/chaos/HISTORY.rst @@ -3,6 +3,19 @@ Release History =============== +0.1.7 ++++++ +* Fix ``'NoneType' object is not callable`` crash on successful + ``chaos scenario config execute``. Same AAZ codegen gap as the 0.1.6 fix + for ``refresh-recommendation``: the inner operation passes ``None`` as the + LRO success deserializer. New ``ScenarioConfigExecute`` subclass injects a + no-op deserializer via ``_handler`` override. + + Audit of other AAZ commands with the same pattern confirmed the only other + occurrences (``scenario config fix-permissions``, ``scenario config + validate``, ``scenario run cancel``) are superseded by hand-written + ``send_raw_request``-based custom commands and are not vulnerable. + 0.1.6 +++++ * Fix ``'NoneType' object is not callable`` crash on successful ``chaos workspace diff --git a/src/chaos/azext_chaos/commands.py b/src/chaos/azext_chaos/commands.py index 8080164abfc..59bfa975690 100644 --- a/src/chaos/azext_chaos/commands.py +++ b/src/chaos/azext_chaos/commands.py @@ -119,10 +119,19 @@ def _register_aaz_subclass_overrides(loader): (Knack rejects non-CLI ``cli_ctx``), unit tests that drive ``load_command_table`` with a ``MagicMock`` loader should ``@patch`` this helper out. """ - from azext_chaos.custom import ScenarioConfigCreate, WorkspaceRefreshRecommendation, WorkspaceEvaluateScenarios + from azext_chaos.custom import ( + ScenarioConfigCreate, + ScenarioConfigExecute, + WorkspaceRefreshRecommendation, + WorkspaceEvaluateScenarios, + ) from azext_chaos.custom_wait import ScenarioRunWait loader.command_table['chaos scenario config create'] = ScenarioConfigCreate(loader=loader) loader.command_table['chaos scenario run wait'] = ScenarioRunWait(loader=loader) + # Override ``chaos scenario config execute`` to fix the NoneType crash on + # successful LRO completion (AAZ codegen passes None as the LRO success + # deserializer; subclass injects a no-op via _handler override). + loader.command_table['chaos scenario config execute'] = ScenarioConfigExecute(loader=loader) # Override the AAZ-generated ``chaos workspace refresh-recommendation`` # to add inner-LRO failure detection (see WorkspaceRefreshRecommendation # docstring for why this is functionally necessary, not stylistic). diff --git a/src/chaos/azext_chaos/custom.py b/src/chaos/azext_chaos/custom.py index 9379955de0c..bc6c85c7942 100644 --- a/src/chaos/azext_chaos/custom.py +++ b/src/chaos/azext_chaos/custom.py @@ -12,6 +12,7 @@ from knack.util import CLIError from .aaz.latest.chaos.scenario.config._create import Create as _ScenarioConfigCreate +from .aaz.latest.chaos.scenario.config._execute import Execute as _ScenarioConfigExecute from .aaz.latest.chaos.workspace._refresh_recommendation import RefreshRecommendation as _RefreshRecommendation logger = get_logger(__name__) @@ -697,3 +698,23 @@ class WorkspaceEvaluateScenarios(WorkspaceRefreshRecommendation): discovered resources" -- the same logical workflow this entire LRO performs, just named more conversationally. """ + + +class ScenarioConfigExecute(_ScenarioConfigExecute): + """Override ``chaos scenario config execute`` to avoid the AAZ-generated + ``NoneType`` crash on successful LRO completion. + + The AAZ-generated inner operation passes ``None`` as the LRO success + deserializer; the framework's ``base_polling._parse_resource`` later + invokes that ``None`` and raises ``TypeError: 'NoneType' object is + not callable``. Same fix as ``WorkspaceRefreshRecommendation``: inject + a no-op deserializer so ``.result()`` on the poller returns ``None`` + cleanly. The user-facing porcelain (``chaos scenario run start`` in + ``custom.py``) supersedes this command for typical workflows, but + ``scenario config execute`` remains the plumbing surface for + automation/agents and must not crash. + """ + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_lro_poller(self._execute_operations, lambda _: None) diff --git a/src/chaos/azext_chaos/tests/latest/test_command_registration.py b/src/chaos/azext_chaos/tests/latest/test_command_registration.py index ba8c0cca90c..4dee19eefca 100644 --- a/src/chaos/azext_chaos/tests/latest/test_command_registration.py +++ b/src/chaos/azext_chaos/tests/latest/test_command_registration.py @@ -65,10 +65,12 @@ def test_alias_registered_via_subclass(self): mock_loader = MagicMock() mock_loader.command_table = {} with patch('azext_chaos.custom.ScenarioConfigCreate') as mock_scc, \ + patch('azext_chaos.custom.ScenarioConfigExecute') as mock_sce, \ patch('azext_chaos.custom.WorkspaceRefreshRecommendation') as mock_wrr, \ patch('azext_chaos.custom.WorkspaceEvaluateScenarios') as mock_wes, \ patch('azext_chaos.custom_wait.ScenarioRunWait') as mock_srw: mock_scc.return_value = 'scc-instance' + mock_sce.return_value = 'sce-instance' mock_wrr.return_value = 'wrr-instance' mock_wes.return_value = 'wes-instance' mock_srw.return_value = 'srw-instance' @@ -85,6 +87,10 @@ def test_alias_registered_via_subclass(self): mock_loader.command_table.get('chaos scenario config create'), 'scc-instance', ) + self.assertEqual( + mock_loader.command_table.get('chaos scenario config execute'), + 'sce-instance', + ) self.assertEqual( mock_loader.command_table.get('chaos scenario run wait'), 'srw-instance', diff --git a/src/chaos/setup.py b/src/chaos/setup.py index 86ca39004bb..d73185ee882 100644 --- a/src/chaos/setup.py +++ b/src/chaos/setup.py @@ -8,7 +8,7 @@ from codecs import open from setuptools import setup, find_packages -VERSION = '0.1.6' +VERSION = '0.1.7' CLASSIFIERS = [ 'Development Status :: 4 - Beta', From a265b87097678d633a6afad3ad7e6b589bf22d55 Mon Sep 17 00:00:00 2001 From: "Cristian Velez (from Dev Box)" Date: Thu, 4 Jun 2026 18:58:36 -0700 Subject: [PATCH 07/13] [Chaos] Bump to 1.0.0b1 + compliant description per cli-extensions guidelines The 0.1.x version sequence (internal iteration during initial-seed development) was not compliant with the cli-extensions versioning guidelines, which require new extension preview versions to start at 1.0.0b1 (and new stable versions at 1.0.0). This extension is preview (azext.isPreview=True), never published, so the first public version must be 1.0.0b1. Reference: https://github.com/Azure/azure-cli/blob/release/doc/extensions/versioning_guidelines.md setup.py description rewritten to comply with the Extension Summary Guidelines (no 'Azure CLI' / 'command-line' / 'extension' language; specific and useful). Reference: https://github.com/Azure/azure-cli/blob/dev/doc/extensions/extension_summary_guidelines.md HISTORY.rst collapsed to a single 1.0.0b1 initial-preview-release entry summarizing the command surface and notable hand-written commands. Drops the 0.1.4-0.1.7 internal iteration entries (never published). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/chaos/HISTORY.rst | 76 +++++++++++++++++-------------------------- src/chaos/setup.py | 4 +-- 2 files changed, 31 insertions(+), 49 deletions(-) diff --git a/src/chaos/HISTORY.rst b/src/chaos/HISTORY.rst index d685a4cdae3..b1bf2fd8d47 100644 --- a/src/chaos/HISTORY.rst +++ b/src/chaos/HISTORY.rst @@ -3,54 +3,36 @@ Release History =============== -0.1.7 -+++++ -* Fix ``'NoneType' object is not callable`` crash on successful - ``chaos scenario config execute``. Same AAZ codegen gap as the 0.1.6 fix - for ``refresh-recommendation``: the inner operation passes ``None`` as the - LRO success deserializer. New ``ScenarioConfigExecute`` subclass injects a - no-op deserializer via ``_handler`` override. +1.0.0b1 ++++++++ +* Initial preview release of the ``chaos`` extension. Targets api-version + ``2026-05-01-preview`` of ``Microsoft.Chaos``. - Audit of other AAZ commands with the same pattern confirmed the only other - occurrences (``scenario config fix-permissions``, ``scenario config - validate``, ``scenario run cancel``) are superseded by hand-written - ``send_raw_request``-based custom commands and are not vulnerable. + Command surface: -0.1.6 -+++++ -* Fix ``'NoneType' object is not callable`` crash on successful ``chaos workspace - refresh-recommendation`` (and its alias ``evaluate-scenarios``). Root cause: the - AAZ-generated inner operation passes ``None`` as the LRO success deserializer to - ``build_lro_polling``; the framework later invokes that ``None`` from - ``base_polling._parse_resource`` and raises ``TypeError``. Fixed in the - ``WorkspaceRefreshRecommendation`` subclass by overriding ``_handler`` to provide - a no-op deserializer (``lambda _: None``). ``post_operations`` (inner-LRO check) - is unaffected and still runs. + * ``az chaos workspace`` -- ``create``, ``show``, ``list``, ``update``, + ``delete``, ``refresh-recommendation``, ``evaluate-scenarios``, + ``show-discovery``, ``show-evaluation``, ``identity`` subgroup. + * ``az chaos scenario`` -- ``create``, ``show``, ``list``, ``update``, + ``delete``. + * ``az chaos scenario config`` -- ``create``, ``show``, ``list``, + ``update``, ``delete``, ``validate``, ``fix-permissions``, ``execute``, + ``show-validation``, ``show-permission-fix``. + * ``az chaos scenario run`` -- ``start``, ``show``, ``list``, ``cancel``, + ``wait``. + * ``az chaos discovered-resource`` -- ``show``, ``list``. -0.1.5 -+++++ -* Refactor ``chaos workspace refresh-recommendation`` to use the AAZ-subclass + ``post_operations`` - pattern (``WorkspaceRefreshRecommendation`` overriding the AAZ-generated command). Inner-LRO - failure detection (discoveries/latest + evaluations/latest with ``properties.status`` inspection, - ARG-propagation-lag hint) preserved; ~30 lines of duplicated POST + outer-LRO polling removed - (now handled by the AAZ framework). -* ``chaos workspace evaluate-scenarios`` re-implemented as a ``WorkspaceEvaluateScenarios`` subclass - alias of the same handler. Behavior unchanged today; will become a true composite of - ``/discover`` + ``/evaluate`` when those ARM ops land in ``2026-08-01-preview``. -* Command name changed from plural ``refresh-recommendations`` to singular ``refresh-recommendation`` - to match the aaz-dev convention for verb-noun command naming. The ``evaluate-scenarios`` alias - retains its current name. -* Generated ``aaz/`` tree regenerated from spec commit ``f228b86c`` via the - ``chaos-automation-codegen`` skill family (``aaz-dev cli generate-by-swagger-tag``). -* Test scaffolding: removed all ``@live_only()`` integration tests (workspace lifecycle, - scenario CRUD, scenario_config CRUD, scenario_run, discovered-resource). Their value - over the ~140 unit tests + ``azdev linter`` + spec-driven aaz-dev regen was marginal — - they exercised service behavior (which Microsoft.Chaos owns) rather than extension - behavior. Validation strategy is now: unit tests + ``azdev linter`` + ``azdev style`` + - deterministic regen via the codegen skill + developer smoke before release. Matches how - many other azure-cli-extensions packages validate (zero ScenarioTest coverage is a - common, accepted pattern). No ``recordings/`` directory required. + Notable hand-written commands beyond the spec-derived surface: -0.1.0 -+++++ -* Initial scaffolding. Commands will be populated when aaz-dev codegen is complete (pending 2026-05-01-preview spec availability). + * ``scenario run start`` (porcelain composite of validate + execute with + a satisfied evaluation gate). + * ``workspace refresh-recommendation`` / ``evaluate-scenarios`` -- + AAZ subclass override that adds inner-LRO failure detection + (``discoveries/latest`` + ``evaluations/latest`` ``properties.status`` + inspection) on top of the AAZ-generated outer-LRO polling. Surfaces + the silent-failure case (e.g. Azure Resource Graph propagation lag + after a fresh Reader role assignment) that the AAZ framework polling + alone misses. + * ``workspace show-discovery``, ``workspace show-evaluation``, + ``scenario config show-validation``, ``scenario config show-permission-fix`` + -- singleton-latest GETs not exposed by the spec. diff --git a/src/chaos/setup.py b/src/chaos/setup.py index d73185ee882..49afeb84bb7 100644 --- a/src/chaos/setup.py +++ b/src/chaos/setup.py @@ -8,7 +8,7 @@ from codecs import open from setuptools import setup, find_packages -VERSION = '0.1.7' +VERSION = '1.0.0b1' CLASSIFIERS = [ 'Development Status :: 4 - Beta', @@ -34,7 +34,7 @@ setup( name='chaos', version=VERSION, - description='Microsoft Azure Command-Line Tools Chaos Studio Extension', + description='Support for Azure Chaos Studio v2 workspaces, scenario configuration, and fault-injection run management.', author='Microsoft Corporation', author_email='azpycli@microsoft.com', url='https://github.com/Azure/azure-cli-extensions/tree/main/src/chaos', From bbe9c2ea14209b2e09511d16d01127e22339cf44 Mon Sep 17 00:00:00 2001 From: "Cristian Velez (from Dev Box)" Date: Fri, 5 Jun 2026 15:30:35 -0700 Subject: [PATCH 08/13] [Chaos] Address PR #9918 review: drop dead resource_group_name overrides, tighten validate_scope, fix README JSON examples - _params.py: AAZ-generated commands expose the arg as `resource_group` (not `resource_group_name`); the argument_context overrides were no-ops and have been removed. AAZResourceGroupNameArg already provides -g/--resource-group with the standard completer. - _validators.py: validate_scope now enforces the subscription GUID segment via regex, matching the docstring (previously only checked the `/subscriptions/` prefix). - README.md: switched the `scenario config create` example to valid JSON for --parameters/--filters and added an `@parameters.json` file-form example. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/chaos/README.md | 10 ++++++++-- src/chaos/azext_chaos/_params.py | 21 --------------------- src/chaos/azext_chaos/_validators.py | 15 +++++++++++++-- 3 files changed, 21 insertions(+), 25 deletions(-) diff --git a/src/chaos/README.md b/src/chaos/README.md index fe18df2cbde..d03359cbc7a 100644 --- a/src/chaos/README.md +++ b/src/chaos/README.md @@ -35,8 +35,14 @@ az chaos scenario list -g MyRG --workspace-name my-workspace # Create a scenario configuration (--scenario-id is auto-derived) az chaos scenario config create -g MyRG --workspace-name my-workspace \ --scenario-name ZoneDown-1.0 -n my-config \ - --parameters "[{key:duration,value:PT10M}]" \ - --filters "{locations:[westus2],zones:[1]}" + --parameters '[{"key":"duration","value":"PT10M"}]' \ + --filters '{"locations":["westus2"],"zones":["1"]}' + +# Or pass --parameters from a file: +az chaos scenario config create -g MyRG --workspace-name my-workspace \ + --scenario-name ZoneDown-1.0 -n my-config \ + --parameters @parameters.json \ + --filters '{"locations":["westus2"],"zones":["1"]}' # Validate a scenario configuration az chaos scenario config validate -g MyRG --workspace-name my-workspace \ diff --git a/src/chaos/azext_chaos/_params.py b/src/chaos/azext_chaos/_params.py index 28059cdb607..e0cf8c689fe 100644 --- a/src/chaos/azext_chaos/_params.py +++ b/src/chaos/azext_chaos/_params.py @@ -5,7 +5,6 @@ from azure.cli.core.commands.parameters import ( get_location_type, - resource_group_name_type, ) from azext_chaos._validators import validate_scope, validate_parameters_json @@ -13,10 +12,6 @@ def load_arguments(self, _): # ── chaos workspace ────────────────────────────────────────────── with self.argument_context('chaos workspace') as c: - c.argument( - 'resource_group_name', - resource_group_name_type, - ) # Spec pattern: ^[^<>%&:?#/\\]+$ (minLength: 1) c.argument( 'workspace_name', @@ -65,10 +60,6 @@ def load_arguments(self, _): # ── chaos scenario ─────────────────────────────────────────────── with self.argument_context('chaos scenario') as c: - c.argument( - 'resource_group_name', - resource_group_name_type, - ) c.argument( 'workspace_name', options_list=['--workspace-name'], @@ -90,10 +81,6 @@ def load_arguments(self, _): # ── chaos scenario config ──────────────────────────────────────── with self.argument_context('chaos scenario config') as c: - c.argument( - 'resource_group_name', - resource_group_name_type, - ) c.argument( 'workspace_name', options_list=['--workspace-name'], @@ -128,10 +115,6 @@ def load_arguments(self, _): # ── chaos scenario run ─────────────────────────────────────────── with self.argument_context('chaos scenario run') as c: - c.argument( - 'resource_group_name', - resource_group_name_type, - ) c.argument( 'workspace_name', options_list=['--workspace-name'], @@ -189,10 +172,6 @@ def load_arguments(self, _): # ── chaos discovered-resource ──────────────────────────────────── with self.argument_context('chaos discovered-resource') as c: - c.argument( - 'resource_group_name', - resource_group_name_type, - ) c.argument( 'workspace_name', options_list=['--workspace-name'], diff --git a/src/chaos/azext_chaos/_validators.py b/src/chaos/azext_chaos/_validators.py index 3db17b7270e..876be16b62a 100644 --- a/src/chaos/azext_chaos/_validators.py +++ b/src/chaos/azext_chaos/_validators.py @@ -5,10 +5,20 @@ import json import os +import re from knack.util import CLIError +# ARM resource IDs start with `/subscriptions/` and may continue +# with deeper path segments (resource group, provider, child resources). +_ARM_SCOPE_PATTERN = re.compile( + r'^/subscriptions/' + r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' + r'(/.*)?$' +) + + def validate_scope(namespace): """Validate that --scopes values are well-formed ARM resource IDs. @@ -19,11 +29,12 @@ def validate_scope(namespace): if not scopes: return for scope in scopes: - if not scope or not scope.startswith('/subscriptions/'): + if not scope or not _ARM_SCOPE_PATTERN.match(scope): raise CLIError( f"Invalid ARM resource ID: '{scope}'. Each scope must be a " "fully qualified ARM resource ID starting with " - "'/subscriptions/'." + "'/subscriptions/', where " + "is a GUID." ) From 78ce25d9e120aa77a4a0205f0c2d236c765c6850 Mon Sep 17 00:00:00 2001 From: "Cristian Velez (from Dev Box)" Date: Fri, 5 Jun 2026 15:34:23 -0700 Subject: [PATCH 09/13] [Chaos] Regenerate aaz/_execute.py with correct command-path example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autogen'd default example for `chaos scenario config execute` referenced the spec-native URL path (`chaos workspace scenario configuration execute …`) rather than the renamed command. Added the leaf to EXAMPLE_REWRITES in the customize_workspace.py driver (Squall 55dcbe5d) and regenerated. No other aaz files changed. Addresses PR #9918 reviewer comment on _execute.py:22. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_chaos/aaz/latest/chaos/scenario/config/_execute.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_execute.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_execute.py index 742ec599a2e..2167a38843b 100644 --- a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_execute.py +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_execute.py @@ -19,7 +19,7 @@ class Execute(AAZCommand): """Execute the scenario execution with the given scenario configuration. :example: Execute the scenario execution with the given scenario configuration. - az chaos workspace scenario configuration execute --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name 12345678-1234-1234-1234-123456789012 --scenario-configuration-name config-5678-9012-3456-789012345678 + az chaos scenario config execute --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name 12345678-1234-1234-1234-123456789012 --scenario-configuration-name config-5678-9012-3456-789012345678 """ _aaz_info = { From edd581753d3f10d290967a3e5f5128ff2d119b9b Mon Sep 17 00:00:00 2001 From: "Cristian Velez (from Dev Box)" Date: Tue, 16 Jun 2026 21:07:40 -0700 Subject: [PATCH 10/13] Add `az chaos setup` porcelain command for first-day workspace bootstrap Introduce a top-level composite command that stands up a complete Chaos Studio environment in one step, inspired by `az containerapp up`: 1. Creates the resource group if it does not exist 2. Creates the workspace with a managed identity (user-assigned via --user-assigned, otherwise system-assigned) 3. Grants the workspace identity the built-in Reader role on each --scopes target (idempotent; skippable via --skip-permissions) so discovery and evaluation can enumerate resources 4. Runs the evaluate-scenarios workflow, then reports the discovered scenarios and suggested next commands `--scopes` is required (mirroring the portal's Create Workspace blade, which has no default scope); help text explains why and documents the ARM resource ID formats for scopes and user-assigned identities. The evaluate step routes through a single `_evaluate_scenarios_workflow` seam framed as the evaluate-scenarios workflow rather than the plumbing refresh-recommendation op, so it absorbs the upcoming spec split of refresh into discover + evaluate without changing setup. When a NEW Reader assignment is created (the case that lags in Azure Resource Graph), evaluation is retried up to 3 times with a delay before emitting a rerun hint; pre-existing no-op assignments and --skip-evaluation-wait take a single attempt. All changes are in hand-written supplementary files (custom.py, commands.py, _help.py, _params.py, _validators.py, _table_format.py) plus tests and README; the generated azext_chaos/aaz/ tree is untouched. --- src/chaos/README.md | 20 +- src/chaos/azext_chaos/_help.py | 71 +++ src/chaos/azext_chaos/_params.py | 68 ++- src/chaos/azext_chaos/_table_format.py | 13 + src/chaos/azext_chaos/_validators.py | 63 ++- src/chaos/azext_chaos/commands.py | 16 + src/chaos/azext_chaos/custom.py | 462 ++++++++++++++++++ .../tests/latest/test_command_registration.py | 11 + .../tests/latest/test_custom_commands.py | 329 +++++++++++++ .../tests/latest/test_table_format.py | 25 + .../tests/latest/test_validators.py | 29 ++ 11 files changed, 1097 insertions(+), 10 deletions(-) diff --git a/src/chaos/README.md b/src/chaos/README.md index d03359cbc7a..2e401dd061d 100644 --- a/src/chaos/README.md +++ b/src/chaos/README.md @@ -4,7 +4,8 @@ Azure CLI extension for Azure Chaos Studio v2 Workspaces. Provides the `az chaos` command group for workspace lifecycle management, scenario browsing, scenario configuration with validation and execution, -and run history with per-run cancellation. +and run history with per-run cancellation. The `az chaos setup` composite +command bootstraps a complete environment in a single step. ## Installation @@ -15,6 +16,23 @@ az extension add --name chaos ## Usage ```bash +# Stand up a ready-to-use environment in one step (porcelain command): +# - creates the resource group (if missing) +# - creates the workspace + managed identity +# - grants the identity the Reader role on each scope +# - evaluates scenarios and reports what was discovered + next steps +az chaos setup -n my-workspace -g MyRG --location eastus \ + --scopes "/subscriptions//resourceGroups/MyRG" + +# Valid --scopes targets: resource group, subscription, or service group +# (/providers/Microsoft.Management/serviceGroups/). + +# Use a user-assigned identity as the workspace identity (otherwise the +# workspace's system-assigned identity is used): +az chaos setup -n my-workspace -g MyRG --location eastus \ + --scopes "/subscriptions//resourceGroups/MyRG" \ + --user-assigned "/subscriptions//resourceGroups/MyRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/myId" + # Create a workspace with a system-assigned managed identity az chaos workspace create -g MyRG --workspace-name my-workspace --location eastus \ --system-assigned "" \ diff --git a/src/chaos/azext_chaos/_help.py b/src/chaos/azext_chaos/_help.py index 7cf4fdd9f36..c92288ac008 100644 --- a/src/chaos/azext_chaos/_help.py +++ b/src/chaos/azext_chaos/_help.py @@ -63,6 +63,77 @@ a specific one. """ +# ── setup (composite / porcelain) ──────────────────────────────────────── + +helps['chaos setup'] = """ +type: command +short-summary: Stand up a ready-to-use Chaos Studio environment in one step. +long-summary: | + First-day-experience command that orchestrates the full bootstrap workflow + so you do not have to run the individual commands yourself: + + 1. Creates the resource group if it does not already exist. + 2. Creates the workspace with a managed identity (user-assigned if + '--user-assigned' is supplied, otherwise a system-assigned identity). + 3. Grants that identity the built-in 'Reader' role on each '--scopes' + target (skip with '--skip-permissions') — discovery and evaluation run + under the workspace identity and cannot enumerate resources without it. + Re-assigning an existing role is a no-op. + 4. Evaluates scenarios for the workspace (resource discovery + scenario + recommendations). When a NEW Reader assignment was just made, the + evaluation is retried for a few minutes to absorb Azure Resource Graph + propagation lag; pass '--skip-evaluation-wait' to run a single attempt + (e.g. in CI) and get a rerun hint instead of waiting. + 5. Prints the discovered scenarios and the commands to run next. + + This is a composite "porcelain" command: it wraps the granular 'workspace + create', 'workspace refresh-recommendation', and role-assignment operations + behind a single workflow verb. For fine-grained control, run those commands + directly. + + WHY '--scopes' IS REQUIRED: + A workspace must declare which resources Chaos Studio is allowed to target — + there is no safe default. You are expected to already have a resource group + or service group (or a subscription) you plan to run experiments against. + Pass one or more ARM resource IDs as the scope. The Azure portal's Create + Workspace blade requires the same explicit choice. + + ARM RESOURCE ID FORMATS (for '--scopes'): + Resource group (most common scope): + `/subscriptions//resourceGroups/` + Subscription: + `/subscriptions/` + Service group: + `/providers/Microsoft.Management/serviceGroups/` + + ARM RESOURCE ID FORMAT (for '--user-assigned'): + User-assigned managed identity: + `/subscriptions//resourceGroups//providers/Microsoft.ManagedIdentity/userAssignedIdentities/` + + Tip: copy a resource group's ID with + `az group show -n --query id -o tsv`. +examples: + - name: Bootstrap a workspace scoped to a resource group, using a system-assigned identity + text: > + az chaos setup --name MyWorkspace --resource-group MyRG --location westus2 + --scopes "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/MyRG" + - name: Bootstrap with a user-assigned identity as the workspace identity + text: > + az chaos setup --name MyWorkspace --resource-group MyRG --location westus2 + --scopes "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/MyRG" + --user-assigned "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/MyRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/MyIdentity" + - name: Bootstrap a workspace scoped to a service group + text: > + az chaos setup --name MyWorkspace --resource-group MyRG --location westus2 + --scopes "/providers/Microsoft.Management/serviceGroups/my-critical-services" + - name: Bootstrap scoped to multiple resource groups and manage RBAC yourself + text: > + az chaos setup --name MyWorkspace --resource-group MyRG --location westus2 + --scopes "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/AppRG" + "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/DataRG" + --skip-permissions +""" + # ── workspace commands ─────────────────────────────────────────────────── helps['chaos workspace create'] = """ diff --git a/src/chaos/azext_chaos/_params.py b/src/chaos/azext_chaos/_params.py index e0cf8c689fe..3214e52ee26 100644 --- a/src/chaos/azext_chaos/_params.py +++ b/src/chaos/azext_chaos/_params.py @@ -5,11 +5,77 @@ from azure.cli.core.commands.parameters import ( get_location_type, + tags_type, +) +from azext_chaos._validators import ( + validate_scope, + validate_parameters_json, + validate_user_assigned, ) -from azext_chaos._validators import validate_scope, validate_parameters_json def load_arguments(self, _): + # ── chaos setup (composite / porcelain) ────────────────────────── + with self.argument_context('chaos setup') as c: + c.argument( + 'workspace_name', + options_list=['--name', '-n'], + help='Name of the Chaos Studio workspace to create.', + ) + c.argument( + 'location', + arg_type=get_location_type(self.cli_ctx), + help='Location for the workspace (and the resource group, if it ' + 'is created).', + ) + c.argument( + 'scopes', + nargs='+', + validator=validate_scope, + help='Space-separated list of ARM resource IDs that the workspace ' + 'is allowed to target: a resource group, subscription, or ' + 'service group. Required — there is no default scope. ' + 'Examples: ' + '`/subscriptions//resourceGroups/` or ' + '`/providers/Microsoft.Management/serviceGroups/`', + ) + c.argument( + 'user_assigned', + options_list=['--user-assigned', '--mi-user-assigned'], + nargs='+', + validator=validate_user_assigned, + help='Space-separated ARM resource ID(s) of user-assigned managed ' + 'identities to use as the workspace identity. When omitted, ' + 'the workspace uses a system-assigned identity. Example: ' + '`/subscriptions//resourceGroups//providers/' + 'Microsoft.ManagedIdentity/userAssignedIdentities/`', + ) + c.argument( + 'skip_permissions', + options_list=['--skip-permissions'], + action='store_true', + default=False, + help='Skip granting the workspace identity the Reader role on the ' + 'target scopes. Use when you manage RBAC out of band. ' + 'Evaluation still runs, but can only discover resources if the ' + 'identity already holds Reader on the scopes.', + ) + c.argument( + 'skip_evaluation_wait', + options_list=['--skip-evaluation-wait'], + action='store_true', + default=False, + help='Do not wait/retry for Azure Resource Graph propagation after ' + 'a new Reader role assignment; run a single evaluation attempt ' + 'and report a rerun hint if it has not propagated yet. Useful ' + 'for non-interactive/CI runs.', + ) + c.argument( + 'tags', + arg_type=tags_type, + help='Space-separated tags in KEY=VALUE form for the workspace.', + ) + # ── chaos workspace ────────────────────────────────────────────── with self.argument_context('chaos workspace') as c: # Spec pattern: ^[^<>%&:?#/\\]+$ (minLength: 1) diff --git a/src/chaos/azext_chaos/_table_format.py b/src/chaos/azext_chaos/_table_format.py index 1fca5d1c40c..5a359b0bae3 100644 --- a/src/chaos/azext_chaos/_table_format.py +++ b/src/chaos/azext_chaos/_table_format.py @@ -189,3 +189,16 @@ def discovered_resource_show_table_format(result): def discovered_resource_list_table_format(results): return [discovered_resource_show_table_format(r) for r in results] + + +# ── setup (composite) ──────────────────────────────────────────────────── + + +def setup_table_format(result): + """Table formatter for the `chaos setup` result. + + The composite command returns a rich object; the most useful tabular view + is the list of discovered scenarios (full detail lives in ``-o json``). + """ + scenarios = (result or {}).get('scenarios') or [] + return [scenario_show_table_format(s) for s in scenarios] diff --git a/src/chaos/azext_chaos/_validators.py b/src/chaos/azext_chaos/_validators.py index 876be16b62a..bd35c01f6bb 100644 --- a/src/chaos/azext_chaos/_validators.py +++ b/src/chaos/azext_chaos/_validators.py @@ -10,31 +10,78 @@ from knack.util import CLIError -# ARM resource IDs start with `/subscriptions/` and may continue -# with deeper path segments (resource group, provider, child resources). +# Workspace scopes are ARM resource identifiers. The service accepts a +# subscription, resource group, individual resource, or service group (the +# backend has no scope-type allow-list — it accepts any parseable ARM ID). +# We accept the two structural roots that cover all of those: +# - ``/subscriptions/[/...]`` → subscription, resource group, resource +# - ``/providers/Microsoft.Management/serviceGroups/`` → service group _ARM_SCOPE_PATTERN = re.compile( r'^/subscriptions/' r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' r'(/.*)?$' ) +_SERVICE_GROUP_SCOPE_PATTERN = re.compile( + r'^/providers/Microsoft\.Management/serviceGroups/[^/]+/?$', + re.IGNORECASE, +) + + +def _is_valid_scope(scope): + """Return True if *scope* is a workspace-targetable ARM resource ID.""" + return bool( + scope + and (_ARM_SCOPE_PATTERN.match(scope) + or _SERVICE_GROUP_SCOPE_PATTERN.match(scope)) + ) def validate_scope(namespace): """Validate that --scopes values are well-formed ARM resource IDs. - Each scope must start with ``/subscriptions/`` followed by a subscription - GUID and optionally deeper path segments (resource group, provider, etc.). + Accepts either a subscription-rooted ARM ID (``/subscriptions/`` and + optionally deeper — resource group or individual resource) or a + service-group ARM ID (``/providers/Microsoft.Management/serviceGroups/ + ``). The error message advertises only the portal-supported scope + types (subscription, resource group, service group); deeper + subscription-rooted IDs (individual resources) are accepted but not + surfaced, to stay aligned with the portal without going out of our way to + block what the service accepts. """ scopes = getattr(namespace, 'scopes', None) if not scopes: return for scope in scopes: - if not scope or not _ARM_SCOPE_PATTERN.match(scope): + if not _is_valid_scope(scope): raise CLIError( f"Invalid ARM resource ID: '{scope}'. Each scope must be a " - "fully qualified ARM resource ID starting with " - "'/subscriptions/', where " - "is a GUID." + "fully qualified ARM resource ID for a subscription " + "('/subscriptions/'), resource group " + "('/subscriptions//resourceGroups/'), " + "or service group " + "('/providers/Microsoft.Management/serviceGroups/')." + ) + + +def validate_user_assigned(namespace): + """Validate that --user-assigned values are user-assigned identity IDs. + + Each value must be a fully qualified ARM resource ID for a + ``Microsoft.ManagedIdentity/userAssignedIdentities`` resource. + """ + identities = getattr(namespace, 'user_assigned', None) + if not identities: + return + for identity in identities: + if (not identity + or not _ARM_SCOPE_PATTERN.match(identity) + or 'microsoft.managedidentity/userassignedidentities/' + not in identity.lower()): + raise CLIError( + f"Invalid user-assigned identity ID: '{identity}'. Each value " + "must be a fully qualified ARM resource ID of the form " + "'/subscriptions//resourceGroups//providers/" + "Microsoft.ManagedIdentity/userAssignedIdentities/'." ) diff --git a/src/chaos/azext_chaos/commands.py b/src/chaos/azext_chaos/commands.py index 59bfa975690..0b3510ec883 100644 --- a/src/chaos/azext_chaos/commands.py +++ b/src/chaos/azext_chaos/commands.py @@ -14,6 +14,7 @@ scenario_run_show_table_format, scenario_show_table_format, validation_show_table_format, + setup_table_format, workspace_discovery_show_table_format, workspace_evaluation_show_table_format, workspace_list_table_format, @@ -105,6 +106,21 @@ def load_command_table(self, _): supports_no_wait=True, ) + # ── Composite (porcelain) commands ─────────────────────────────── + # `chaos setup` is a top-level first-day-experience flow that wraps + # resource-group creation, workspace creation, Reader role assignment, + # and scenario evaluation. Its identity is the workflow, not any single + # API operation (see context/cli-design-philosophy.md). + with self.command_group( + 'chaos', + operations_tmpl='azext_chaos.custom#{}', + ) as g: + g.custom_command( + 'setup', + 'setup', + table_transformer=setup_table_format, + ) + _register_aaz_subclass_overrides(self) diff --git a/src/chaos/azext_chaos/custom.py b/src/chaos/azext_chaos/custom.py index bc6c85c7942..e65a2393e23 100644 --- a/src/chaos/azext_chaos/custom.py +++ b/src/chaos/azext_chaos/custom.py @@ -5,6 +5,7 @@ import json import time +import uuid from azure.cli.core.aaz import has_value from azure.cli.core.util import send_raw_request @@ -591,6 +592,467 @@ def scenario_run_cancel(cmd, resource_group_name, workspace_name, # pylint: dis return None +# ── az chaos setup (porcelain / composite first-day experience) ────────── +# `az chaos setup` is a COMPOSITE (porcelain) command per the CLI design +# philosophy (.github/skills/chaos-automation-codegen/context/ +# cli-design-philosophy.md). Its identity is the WORKFLOW — "stand up a +# ready-to-use Chaos Studio environment" — not any single API operation, and +# it is a CLI-surface-specific affordance (intentionally NOT mirrored to the +# Terraform/PowerShell surfaces). Inspired by `az containerapp up` / +# `az webapp up`: ensure the resource group, create the workspace + identity, +# grant the identity the permissions discovery needs, evaluate scenarios, then +# report the discovered scenarios and the commands to run next. + +# Azure built-in "Reader" role. The workspace's managed identity must hold +# Reader on each in-scope resource for resource discovery + scenario evaluation +# to succeed: discovery always runs under the workspace MI +# (services/AP/.../TargetDiscoveryController.cs) and refreshRecommendations +# orchestrates discover-then-evaluate +# (services/GW/.../RefreshWorkspaceRecommendationsOrchestration.cs). GUID +# verified against the Azure RBAC built-in roles reference: +# https://learn.microsoft.com/azure/role-based-access-control/built-in-roles/general#reader +_READER_ROLE_DEFINITION_GUID = "acdd72a7-3385-48ef-bd42-f606fba81ae7" + +_RESOURCE_GROUP_API_VERSION = "2021-04-01" +_ROLE_ASSIGNMENT_API_VERSION = "2022-04-01" + +# Resource discovery runs under the workspace identity; a freshly-granted Reader +# role can lag in Azure Resource Graph (typically clears in 1-3 min), so the +# evaluate step retries a bounded number of times before reporting a hint. +_EVALUATION_MAX_ATTEMPTS = 3 +_EVALUATION_RETRY_INTERVAL_SECONDS = 120 + + +def setup(cmd, resource_group_name, workspace_name, location, scopes, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals + user_assigned=None, skip_permissions=False, + skip_evaluation_wait=False, tags=None): + """Stand up a ready-to-use Chaos Studio environment end to end. + + Composite first-day-experience flow: ensure the resource group exists, + create the workspace with a managed identity, grant that identity the + Reader role discovery requires on each scope, evaluate scenarios, and + report the discovered scenarios plus suggested next commands. + + Assigning Reader is idempotent — an already-present assignment is a no-op + (ARM reports ``RoleAssignmentExists``). Only when a *new* assignment is + created this run does the evaluate step wait out Azure Resource Graph + propagation (retrying a few times); pass ``--skip-evaluation-wait`` to force + a single attempt (e.g. in CI). + """ + # 1. Resource group ────────────────────────────────────────────────── + _ensure_resource_group(cmd, resource_group_name, location) + + # 2. Workspace ─────────────────────────────────────────────────────── + workspace = _create_setup_workspace( + cmd, resource_group_name, workspace_name, location, + scopes, user_assigned, tags, + ) + + # 3. Permissions — Reader for the workspace identity on each scope ──── + principal_ids = _resolve_workspace_principal_ids(workspace, user_assigned) + role_assignments = [] + if skip_permissions: + logger.warning( + "Skipping permission setup (--skip-permissions). Evaluation will " + "still run, but it can only discover resources if the workspace " + "identity already holds the Reader role on the target scopes.", + ) + elif not principal_ids: + logger.warning( + "Could not resolve the workspace identity principal ID; skipping " + "Reader role assignment. Grant Reader to the workspace identity on " + "the target scopes manually, then re-run " + "'az chaos workspace refresh-recommendation'.", + ) + else: + for principal_id in principal_ids: + for scope in scopes: + assignment = _assign_reader_role(cmd, scope, principal_id) + if assignment: + role_assignments.append(assignment) + + # 4. Evaluate scenarios ────────────────────────────────────────────── + # Only wait out Azure Resource Graph propagation when we created a NEW role + # assignment this run — that is what lags. A pre-existing assignment + # (roleAssignmentName is None) is a no-op with no propagation delay, so a + # single evaluation attempt is enough. + created_new_assignment = any( + a.get("roleAssignmentName") for a in role_assignments + ) + wait_for_propagation = created_new_assignment and not skip_evaluation_wait + evaluated = _evaluate_scenarios_workflow( + cmd, resource_group_name, workspace_name, + wait_for_propagation=wait_for_propagation, + ) + + # 5. Report ────────────────────────────────────────────────────────── + scenarios = _list_workspace_scenarios( + cmd, resource_group_name, workspace_name, + ) + next_steps = _build_setup_next_steps( + resource_group_name, workspace_name, scenarios, evaluated, + ) + _print_setup_summary( + resource_group_name, workspace_name, scenarios, next_steps, + ) + return { + "workspace": workspace, + "identityPrincipalIds": principal_ids, + "roleAssignments": role_assignments, + "scenarios": scenarios, + "nextSteps": next_steps, + } + + +def _ensure_resource_group(cmd, resource_group_name, location): + """Create the resource group if it does not already exist.""" + from azure.cli.core.commands.client_factory import get_subscription_id + sub = get_subscription_id(cmd.cli_ctx) + rg_url = ( + f"/subscriptions/{sub}/resourcegroups/{resource_group_name}" + f"?api-version={_RESOURCE_GROUP_API_VERSION}" + ) + try: + existing = send_raw_request(cmd.cli_ctx, "GET", rg_url) + if existing.status_code == 200: + logger.warning( + "Using existing resource group '%s'.", resource_group_name, + ) + return + except Exception: # pylint: disable=broad-except + pass # GET raises on 404 — fall through to create. + logger.warning( + "Creating resource group '%s' in location '%s'.", + resource_group_name, location, + ) + send_raw_request( + cmd.cli_ctx, "PUT", rg_url, + body=json.dumps({"location": location}), + headers=["Content-Type=application/json"], + ) + + +def _create_setup_workspace(cmd, resource_group_name, workspace_name, # pylint: disable=too-many-arguments,too-many-positional-arguments + location, scopes, user_assigned, tags): + """PUT the workspace, poll the create LRO, and return the final resource. + + When ``user_assigned`` is supplied the workspace uses a UserAssigned + identity; otherwise it uses a SystemAssigned identity (the workspace's own + system-assigned identity becomes the workspace identity). + """ + if user_assigned: + identity = { + "type": "UserAssigned", + "userAssignedIdentities": {uid: {} for uid in user_assigned}, + } + else: + identity = {"type": "SystemAssigned"} + body = { + "location": location, + "identity": identity, + "properties": {"scopes": list(scopes)}, + } + if tags: + body["tags"] = tags + url = _build_arm_url(cmd.cli_ctx, resource_group_name, workspace_name, "") + logger.warning( + "Creating workspace '%s' (identity: %s).", + workspace_name, identity["type"], + ) + response = send_raw_request( + cmd.cli_ctx, "PUT", url, + body=json.dumps(body), headers=["Content-Type=application/json"], + ) + _poll_or_return(cmd, response) + # Re-GET for the authoritative identity block: ARM populates the managed + # identity ``principalId`` asynchronously and it may be absent on the + # initial create response. + final = send_raw_request(cmd.cli_ctx, "GET", url) + return final.json() if final.text else {} + + +def _resolve_workspace_principal_ids(workspace, user_assigned): + """Return the identity principal IDs to grant Reader to. + + For a system-assigned identity that is the single ``identity.principalId``. + For user-assigned identities it is the ``principalId`` of each assigned + identity (ARM resource-id keys are matched case-insensitively). + """ + identity = (workspace or {}).get("identity") or {} + principal_ids = [] + if user_assigned: + ua_map = identity.get("userAssignedIdentities") or {} + for uid in user_assigned: + entry = ua_map.get(uid) + if entry is None: + for key, value in ua_map.items(): + if key.lower() == uid.lower(): + entry = value + break + if entry and entry.get("principalId"): + principal_ids.append(entry["principalId"]) + else: + principal_id = identity.get("principalId") + if principal_id: + principal_ids.append(principal_id) + return principal_ids + + +def _subscription_from_scope(scope): + """Extract the subscription GUID from an ARM scope id (best-effort).""" + segments = [s for s in (scope or "").strip("/").split("/") if s] + if len(segments) >= 2 and segments[0].lower() == "subscriptions": + return segments[1] + return None + + +def _assign_reader_role(cmd, scope, principal_id): + """Assign the Reader role to a principal on a scope (idempotent). + + Returns a record of the assignment, or ``None`` when it could not be made. + An already-existing assignment is treated as success. Other failures + (e.g. the caller lacks Owner / User Access Administrator) are surfaced as a + warning rather than aborting the whole setup. + """ + sub = _subscription_from_scope(scope) + if not sub: + logger.warning( + "Skipping role assignment on '%s': could not parse a subscription " + "from the scope.", scope, + ) + return None + role_definition_id = ( + f"/subscriptions/{sub}/providers/Microsoft.Authorization" + f"/roleDefinitions/{_READER_ROLE_DEFINITION_GUID}" + ) + assignment_name = str(uuid.uuid4()) + url = ( + f"{scope}/providers/Microsoft.Authorization/roleAssignments/" + f"{assignment_name}?api-version={_ROLE_ASSIGNMENT_API_VERSION}" + ) + body = json.dumps({ + "properties": { + "roleDefinitionId": role_definition_id, + "principalId": principal_id, + # ``ServicePrincipal`` lets ARM skip the directory lookup that can + # 400 with PrincipalNotFound right after a managed identity is + # created (Azure AD replication lag). + "principalType": "ServicePrincipal", + } + }) + try: + send_raw_request( + cmd.cli_ctx, "PUT", url, + body=body, headers=["Content-Type=application/json"], + ) + logger.warning( + "Granted Reader to identity %s on scope %s.", principal_id, scope, + ) + return { + "scope": scope, + "principalId": principal_id, + "roleAssignmentName": assignment_name, + } + except Exception as ex: # pylint: disable=broad-except + text = str(ex) + if "RoleAssignmentExists" in text or "already exists" in text.lower(): + logger.warning( + "Reader already assigned to identity %s on scope %s.", + principal_id, scope, + ) + return { + "scope": scope, + "principalId": principal_id, + "roleAssignmentName": None, + } + logger.warning( + "Could not assign Reader to identity %s on scope %s: %s. You may " + "need Owner or User Access Administrator on the scope. Assign " + "Reader manually, then re-run 'az chaos workspace " + "refresh-recommendation --name --resource-group '.", + principal_id, scope, text, + ) + return None + + +def _evaluate_scenarios_workflow(cmd, resource_group_name, workspace_name, + wait_for_propagation=False): + """Run the evaluate-scenarios workflow for the workspace. + + This is the porcelain "evaluate scenarios" step. It is intentionally bound + to the SAME logical workflow exposed by + ``az chaos workspace evaluate-scenarios`` — NOT to the plumbing + ``refresh-recommendation`` op. Today that workflow maps 1:1 to + ``Workspaces_RefreshRecommendations`` (a single ``POST + /refreshRecommendations`` that orchestrates discover-then-evaluate). When + the spec splits that op into ``Workspaces_Discover`` + + ``Workspaces_Evaluate`` (2026-08-01-preview — see the + ``WorkspaceRefreshRecommendation`` docstring), update THIS helper (and + ``WorkspaceEvaluateScenarios``) to call both in sequence; ``setup`` + inherits the new behavior automatically because it routes through here. + + Resource discovery runs under the workspace identity, and a freshly-granted + Reader role can lag in Azure Resource Graph (1-3 min). When + ``wait_for_propagation`` is set, the whole evaluation is retried up to + ``_EVALUATION_MAX_ATTEMPTS`` times with + ``_EVALUATION_RETRY_INTERVAL_SECONDS`` between attempts so the common + first-run case returns discovered scenarios instead of a propagation-lag + failure. The rerun hint is only emitted once all attempts are exhausted. + + Returns ``True`` when discovery/evaluation completed cleanly, ``False`` + otherwise (never raises — the resource group, workspace, identity, and role + assignments are already provisioned by the time we get here). + """ + max_attempts = _EVALUATION_MAX_ATTEMPTS if wait_for_propagation else 1 + url = _build_arm_url( + cmd.cli_ctx, resource_group_name, workspace_name, + "/refreshRecommendations", + ) + for attempt in range(1, max_attempts + 1): + if max_attempts > 1: + logger.warning( + "Evaluating scenarios for workspace '%s' (attempt %d/%d).", + workspace_name, attempt, max_attempts, + ) + else: + logger.warning( + "Evaluating scenarios for workspace '%s'.", workspace_name, + ) + response = send_raw_request(cmd.cli_ctx, "POST", url) + _poll_or_return(cmd, response) + + failed_label = _setup_inner_lro_failure( + cmd, resource_group_name, workspace_name, + ) + if not failed_label: + return True + + if attempt < max_attempts: + logger.warning( + "Scenario evaluation did not complete (%s failed) — commonly " + "Azure Resource Graph propagation lag right after the Reader " + "role is granted to the workspace identity. Waiting %d seconds " + "before retrying...", + failed_label, _EVALUATION_RETRY_INTERVAL_SECONDS, + ) + time.sleep(_EVALUATION_RETRY_INTERVAL_SECONDS) + else: + logger.warning( + "Workspace provisioned, but scenario evaluation did not " + "complete (%s failed)%s. The resource group, workspace, " + "identity, and role assignments are all in place. Re-run " + "'az chaos workspace refresh-recommendation --name %s " + "--resource-group %s' in a couple of minutes, then " + "'az chaos scenario list -w %s -g %s'.", + failed_label, + f" after {max_attempts} attempts" if max_attempts > 1 else "", + workspace_name, resource_group_name, + workspace_name, resource_group_name, + ) + return False + + +def _setup_inner_lro_failure(cmd, resource_group_name, workspace_name): + """Return a label if discovery/evaluation inner LRO is Failed, else None. + + Soft (non-raising) sibling of ``_check_inner_lro`` — setup downgrades inner + failures to a warning instead of a non-zero exit. + """ + checks = ( + ("/discoveries/latest", "resource discovery"), + ("/evaluations/latest", "scenario evaluation"), + ) + for path_suffix, label in checks: + url = _build_arm_url( + cmd.cli_ctx, resource_group_name, workspace_name, path_suffix, + ) + try: + resp = send_raw_request(cmd.cli_ctx, "GET", url) + except Exception: # pylint: disable=broad-except + continue # /latest may legitimately 404 on a fresh workspace + if resp.status_code != 200 or not resp.text: + continue + try: + props = (resp.json() or {}).get("properties") or {} + except Exception: # pylint: disable=broad-except + continue + if props.get("status") == "Failed": + return label + return None + + +def _list_workspace_scenarios(cmd, resource_group_name, workspace_name): + """GET the catalog/discovered scenarios for the workspace.""" + url = _build_arm_url( + cmd.cli_ctx, resource_group_name, workspace_name, "/scenarios", + ) + try: + resp = send_raw_request(cmd.cli_ctx, "GET", url) + except Exception: # pylint: disable=broad-except + return [] + if resp.status_code != 200 or not resp.text: + return [] + try: + return (resp.json() or {}).get("value") or [] + except Exception: # pylint: disable=broad-except + return [] + + +def _build_setup_next_steps(resource_group_name, workspace_name, scenarios, + evaluated): + """Build the list of suggested next commands after setup completes.""" + steps = [] + if not evaluated: + steps.append( + f"az chaos workspace refresh-recommendation " + f"--name {workspace_name} --resource-group {resource_group_name}" + ) + steps.append( + f"az chaos scenario list -w {workspace_name} -g {resource_group_name}" + ) + example_scenario = ( + scenarios[0].get("name") if scenarios else "" + ) + steps.append( + f"az chaos scenario config create --workspace-name {workspace_name} " + f"-g {resource_group_name} --scenario-name {example_scenario} " + f"--name --parameters @params.json" + ) + steps.append( + f"az chaos scenario run start --workspace-name {workspace_name} " + f"-g {resource_group_name} --scenario-name {example_scenario} " + f"--config-name " + ) + return steps + + +def _print_setup_summary(resource_group_name, workspace_name, scenarios, + next_steps): + """Emit the human-friendly post-setup summary (mirrors `containerapp up`).""" + logger.warning( + "\nChaos Studio workspace '%s' is ready in resource group '%s'.", + workspace_name, resource_group_name, + ) + if scenarios: + logger.warning("Discovered %d scenario(s):", len(scenarios)) + for scenario in scenarios: + name = scenario.get("name", "") + recommendation = ( + ((scenario.get("properties") or {}).get("recommendation")) or {} + ).get("recommendationStatus", "") + suffix = f" ({recommendation})" if recommendation else "" + logger.warning(" - %s%s", name, suffix) + else: + logger.warning( + "No scenarios discovered yet. If evaluation is still in progress " + "or needs a retry, re-run 'az chaos workspace " + "refresh-recommendation' and then 'az chaos scenario list'.", + ) + logger.warning("\nNext steps:") + for step in next_steps: + logger.warning(" %s", step) + + # ── AAZCommand subclass overrides ──────────────────────────────────────── # Subclassing keeps the aaz-generated module pristine (no hand-edits under # azext_chaos/aaz/) while letting us inject pre/post-operation behavior. diff --git a/src/chaos/azext_chaos/tests/latest/test_command_registration.py b/src/chaos/azext_chaos/tests/latest/test_command_registration.py index 4dee19eefca..b64ccb1ad8b 100644 --- a/src/chaos/azext_chaos/tests/latest/test_command_registration.py +++ b/src/chaos/azext_chaos/tests/latest/test_command_registration.py @@ -33,6 +33,17 @@ def test_chaos_discovered_resource_group_help_exists(self): def test_alias_help_exists(self): self.assertIn('chaos workspace evaluate-scenarios', helps) + def test_setup_help_exists(self): + self.assertIn('chaos setup', helps) + + def test_setup_help_explains_required_scopes_and_format(self): + setup_help = helps['chaos setup'] + self.assertIn('--scopes', setup_help) + self.assertIn('REQUIRED', setup_help) + # ARM resource ID format guidance is present for newcomers. + self.assertIn('/subscriptions/', setup_help) + self.assertIn('userAssignedIdentities', setup_help) + def test_alias_help_mentions_canonical_name(self): alias_help = helps['chaos workspace evaluate-scenarios'] self.assertIn( diff --git a/src/chaos/azext_chaos/tests/latest/test_custom_commands.py b/src/chaos/azext_chaos/tests/latest/test_custom_commands.py index cf4ac640bf6..95c6ea7fbcb 100644 --- a/src/chaos/azext_chaos/tests/latest/test_custom_commands.py +++ b/src/chaos/azext_chaos/tests/latest/test_custom_commands.py @@ -1245,5 +1245,334 @@ def test_run_start_no_wait_falls_back_to_async_op_body( self.assertEqual(result['runId'], 'found-via-fallback') +# ── az chaos setup (composite / porcelain) ─────────────────────────────── + + +class TestSetupHelpers(unittest.TestCase): + """Unit tests for the `chaos setup` building-block helpers.""" + + def test_subscription_from_scope_resource_group(self): + from azext_chaos.custom import _subscription_from_scope + scope = "/subscriptions/sub-123/resourceGroups/MyRG" + self.assertEqual(_subscription_from_scope(scope), "sub-123") + + def test_subscription_from_scope_subscription_only(self): + from azext_chaos.custom import _subscription_from_scope + self.assertEqual( + _subscription_from_scope("/subscriptions/sub-abc"), "sub-abc" + ) + + def test_subscription_from_scope_invalid(self): + from azext_chaos.custom import _subscription_from_scope + self.assertIsNone(_subscription_from_scope("/not/a/scope")) + self.assertIsNone(_subscription_from_scope("")) + + def test_resolve_principal_ids_system_assigned(self): + from azext_chaos.custom import _resolve_workspace_principal_ids + workspace = {"identity": {"type": "SystemAssigned", + "principalId": "sa-principal"}} + self.assertEqual( + _resolve_workspace_principal_ids(workspace, None), ["sa-principal"] + ) + + def test_resolve_principal_ids_user_assigned(self): + from azext_chaos.custom import _resolve_workspace_principal_ids + uid = ("/subscriptions/s/resourceGroups/rg/providers/" + "Microsoft.ManagedIdentity/userAssignedIdentities/mi") + workspace = {"identity": { + "type": "UserAssigned", + "userAssignedIdentities": {uid: {"principalId": "ua-principal"}}, + }} + self.assertEqual( + _resolve_workspace_principal_ids(workspace, [uid]), ["ua-principal"] + ) + + def test_resolve_principal_ids_user_assigned_case_insensitive(self): + from azext_chaos.custom import _resolve_workspace_principal_ids + uid_upper = ("/subscriptions/S/resourceGroups/RG/providers/" + "Microsoft.ManagedIdentity/userAssignedIdentities/MI") + uid_lower = uid_upper.lower() + workspace = {"identity": { + "type": "UserAssigned", + "userAssignedIdentities": {uid_lower: {"principalId": "ua-p"}}, + }} + self.assertEqual( + _resolve_workspace_principal_ids(workspace, [uid_upper]), ["ua-p"] + ) + + def test_resolve_principal_ids_none_when_missing(self): + from azext_chaos.custom import _resolve_workspace_principal_ids + self.assertEqual( + _resolve_workspace_principal_ids({"identity": {}}, None), [] + ) + + @patch('azext_chaos.custom.send_raw_request') + def test_assign_reader_role_uses_reader_guid(self, mock_send): + from azext_chaos.custom import ( + _assign_reader_role, _READER_ROLE_DEFINITION_GUID, + ) + mock_send.return_value = _make_response() + scope = "/subscriptions/sub-1/resourceGroups/MyRG" + result = _assign_reader_role(_make_cmd(), scope, "principal-1") + + self.assertIsNotNone(result) + self.assertEqual(result["principalId"], "principal-1") + # Reader built-in role GUID, scoped to the scope's subscription. + sent_body = mock_send.call_args.kwargs.get("body") or mock_send.call_args[0][3] + self.assertIn(_READER_ROLE_DEFINITION_GUID, sent_body) + self.assertIn("ServicePrincipal", sent_body) + + @patch('azext_chaos.custom.send_raw_request') + def test_assign_reader_role_idempotent_on_exists(self, mock_send): + from azext_chaos.custom import _assign_reader_role + mock_send.side_effect = CLIError( + "RoleAssignmentExists: The role assignment already exists." + ) + result = _assign_reader_role( + _make_cmd(), "/subscriptions/s/resourceGroups/rg", "p1" + ) + # Treated as success — assignment already present. + self.assertIsNotNone(result) + self.assertIsNone(result["roleAssignmentName"]) + + @patch('azext_chaos.custom.send_raw_request') + def test_assign_reader_role_returns_none_on_failure(self, mock_send): + from azext_chaos.custom import _assign_reader_role + mock_send.side_effect = CLIError("AuthorizationFailed") + result = _assign_reader_role( + _make_cmd(), "/subscriptions/s/resourceGroups/rg", "p1" + ) + self.assertIsNone(result) + + def test_assign_reader_role_skips_unparseable_scope(self): + from azext_chaos.custom import _assign_reader_role + self.assertIsNone( + _assign_reader_role(_make_cmd(), "/bad/scope", "p1") + ) + + def test_build_next_steps_includes_refresh_when_not_evaluated(self): + from azext_chaos.custom import _build_setup_next_steps + steps = _build_setup_next_steps("rg", "ws", [], evaluated=False) + joined = "\n".join(steps) + self.assertIn("refresh-recommendation", joined) + self.assertIn("scenario list", joined) + + def test_build_next_steps_omits_refresh_when_evaluated(self): + from azext_chaos.custom import _build_setup_next_steps + steps = _build_setup_next_steps( + "rg", "ws", [{"name": "ZoneDown-1.0"}], evaluated=True + ) + joined = "\n".join(steps) + self.assertNotIn("refresh-recommendation", joined) + # First discovered scenario name flows into the suggested commands. + self.assertIn("ZoneDown-1.0", joined) + + +class TestSetupOrchestration(unittest.TestCase): + """Tests for the `setup` composite orchestration logic.""" + + def _patches(self): + return { + 'rg': patch('azext_chaos.custom._ensure_resource_group'), + 'ws': patch('azext_chaos.custom._create_setup_workspace'), + 'pid': patch('azext_chaos.custom._resolve_workspace_principal_ids'), + 'role': patch('azext_chaos.custom._assign_reader_role'), + 'eval': patch('azext_chaos.custom._evaluate_scenarios_workflow'), + 'list': patch('azext_chaos.custom._list_workspace_scenarios'), + } + + def test_happy_path_assigns_roles_and_returns_scenarios(self): + from azext_chaos.custom import setup + p = self._patches() + with p['rg'] as m_rg, p['ws'] as m_ws, p['pid'] as m_pid, \ + p['role'] as m_role, p['eval'] as m_eval, p['list'] as m_list: + m_ws.return_value = {"name": "ws", "identity": {}} + m_pid.return_value = ["principal-1"] + m_role.return_value = {"scope": "s", "principalId": "principal-1", + "roleAssignmentName": "ra-1"} + m_eval.return_value = True + m_list.return_value = [{"name": "ZoneDown-1.0"}] + + scopes = ["/subscriptions/s/resourceGroups/rg"] + result = setup( + _make_cmd(), 'rg', 'ws', 'westus2', scopes, + ) + + m_rg.assert_called_once() + m_ws.assert_called_once() + # One role assignment: 1 principal x 1 scope. + self.assertEqual(m_role.call_count, 1) + self.assertEqual(len(result["roleAssignments"]), 1) + self.assertEqual(result["scenarios"], [{"name": "ZoneDown-1.0"}]) + self.assertIn("nextSteps", result) + # A NEW assignment was created → evaluation waits for ARG propagation. + self.assertTrue( + m_eval.call_args.kwargs.get("wait_for_propagation") + ) + + def test_skip_permissions_skips_role_assignment(self): + from azext_chaos.custom import setup + p = self._patches() + with p['rg'], p['ws'] as m_ws, p['pid'] as m_pid, \ + p['role'] as m_role, p['eval'] as m_eval, p['list'] as m_list: + m_ws.return_value = {"name": "ws", "identity": {}} + m_pid.return_value = ["principal-1"] + m_eval.return_value = True + m_list.return_value = [] + + result = setup( + _make_cmd(), 'rg', 'ws', 'westus2', + ["/subscriptions/s/resourceGroups/rg"], + skip_permissions=True, + ) + + m_role.assert_not_called() + self.assertEqual(result["roleAssignments"], []) + # No assignment made → no propagation wait. + self.assertFalse( + m_eval.call_args.kwargs.get("wait_for_propagation") + ) + + def test_preexisting_assignment_does_not_wait(self): + """A pre-existing (no-op) Reader assignment must not trigger the wait.""" + from azext_chaos.custom import setup + p = self._patches() + with p['rg'], p['ws'] as m_ws, p['pid'] as m_pid, \ + p['role'] as m_role, p['eval'] as m_eval, p['list'] as m_list: + m_ws.return_value = {"name": "ws", "identity": {}} + m_pid.return_value = ["principal-1"] + # roleAssignmentName=None ⇒ assignment already existed (no-op). + m_role.return_value = {"scope": "s", "principalId": "principal-1", + "roleAssignmentName": None} + m_eval.return_value = True + m_list.return_value = [] + + setup(_make_cmd(), 'rg', 'ws', 'westus2', + ["/subscriptions/s/resourceGroups/rg"]) + + self.assertFalse( + m_eval.call_args.kwargs.get("wait_for_propagation") + ) + + def test_skip_evaluation_wait_forces_single_attempt(self): + from azext_chaos.custom import setup + p = self._patches() + with p['rg'], p['ws'] as m_ws, p['pid'] as m_pid, \ + p['role'] as m_role, p['eval'] as m_eval, p['list'] as m_list: + m_ws.return_value = {"name": "ws", "identity": {}} + m_pid.return_value = ["principal-1"] + m_role.return_value = {"scope": "s", "principalId": "principal-1", + "roleAssignmentName": "ra-1"} + m_eval.return_value = True + m_list.return_value = [] + + setup(_make_cmd(), 'rg', 'ws', 'westus2', + ["/subscriptions/s/resourceGroups/rg"], + skip_evaluation_wait=True) + + # New assignment, but the user opted out of waiting. + self.assertFalse( + m_eval.call_args.kwargs.get("wait_for_propagation") + ) + + def test_role_assigned_per_principal_per_scope(self): + from azext_chaos.custom import setup + p = self._patches() + with p['rg'], p['ws'] as m_ws, p['pid'] as m_pid, \ + p['role'] as m_role, p['eval'] as m_eval, p['list'] as m_list: + m_ws.return_value = {"name": "ws", "identity": {}} + m_pid.return_value = ["p1", "p2"] + m_role.return_value = {"scope": "s", "principalId": "p", + "roleAssignmentName": "ra"} + m_eval.return_value = True + m_list.return_value = [] + + scopes = ["/subscriptions/s/resourceGroups/rg1", + "/subscriptions/s/resourceGroups/rg2"] + setup(_make_cmd(), 'rg', 'ws', 'westus2', scopes) + + # 2 principals x 2 scopes = 4 assignments. + self.assertEqual(m_role.call_count, 4) + + +class TestEvaluateScenariosWorkflow(unittest.TestCase): + """Tests for the evaluate-scenarios workflow + ARG-propagation retry.""" + + @patch('azext_chaos.custom._setup_inner_lro_failure') + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_success_first_attempt_no_sleep(self, mock_send, mock_poll, + mock_inner): + from azext_chaos.custom import _evaluate_scenarios_workflow + mock_send.return_value = _make_response() + mock_inner.return_value = None # clean + + with patch('azext_chaos.custom.time.sleep') as mock_sleep: + ok = _evaluate_scenarios_workflow( + _make_cmd(), 'rg', 'ws', wait_for_propagation=True, + ) + + self.assertTrue(ok) + self.assertEqual(mock_send.call_count, 1) # single POST + mock_sleep.assert_not_called() + + @patch('azext_chaos.custom._setup_inner_lro_failure') + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_retries_then_succeeds(self, mock_send, mock_poll, mock_inner): + from azext_chaos.custom import _evaluate_scenarios_workflow + mock_send.return_value = _make_response() + # Fail once (propagation lag), then succeed. + mock_inner.side_effect = ["resource discovery", None] + + with patch('azext_chaos.custom.time.sleep') as mock_sleep: + ok = _evaluate_scenarios_workflow( + _make_cmd(), 'rg', 'ws', wait_for_propagation=True, + ) + + self.assertTrue(ok) + self.assertEqual(mock_send.call_count, 2) # POST retried once + self.assertEqual(mock_sleep.call_count, 1) + + @patch('azext_chaos.custom._setup_inner_lro_failure') + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_all_attempts_fail_returns_false(self, mock_send, mock_poll, + mock_inner): + from azext_chaos.custom import ( + _evaluate_scenarios_workflow, _EVALUATION_MAX_ATTEMPTS, + ) + mock_send.return_value = _make_response() + mock_inner.return_value = "resource discovery" # always fails + + with patch('azext_chaos.custom.time.sleep') as mock_sleep: + ok = _evaluate_scenarios_workflow( + _make_cmd(), 'rg', 'ws', wait_for_propagation=True, + ) + + self.assertFalse(ok) + self.assertEqual(mock_send.call_count, _EVALUATION_MAX_ATTEMPTS) + # One sleep between each pair of attempts. + self.assertEqual(mock_sleep.call_count, _EVALUATION_MAX_ATTEMPTS - 1) + + @patch('azext_chaos.custom._setup_inner_lro_failure') + @patch('azext_chaos.custom._poll_or_return') + @patch('azext_chaos.custom.send_raw_request') + def test_no_wait_single_attempt_on_failure(self, mock_send, mock_poll, + mock_inner): + from azext_chaos.custom import _evaluate_scenarios_workflow + mock_send.return_value = _make_response() + mock_inner.return_value = "resource discovery" + + with patch('azext_chaos.custom.time.sleep') as mock_sleep: + ok = _evaluate_scenarios_workflow( + _make_cmd(), 'rg', 'ws', wait_for_propagation=False, + ) + + self.assertFalse(ok) + self.assertEqual(mock_send.call_count, 1) # no retry + mock_sleep.assert_not_called() + + if __name__ == '__main__': unittest.main() diff --git a/src/chaos/azext_chaos/tests/latest/test_table_format.py b/src/chaos/azext_chaos/tests/latest/test_table_format.py index 4019b721d10..21cf895ab36 100644 --- a/src/chaos/azext_chaos/tests/latest/test_table_format.py +++ b/src/chaos/azext_chaos/tests/latest/test_table_format.py @@ -552,5 +552,30 @@ def test_start_has_table_transformer(self): self.assertIn('table_transformer', start_call.kwargs) +class TestSetupTableFormat(unittest.TestCase): + """Tests for setup_table_format.""" + + def test_projects_discovered_scenarios(self): + from azext_chaos._table_format import setup_table_format + result = { + "workspace": {"name": "ws"}, + "scenarios": [ + {"name": "ZoneDown-1.0", + "properties": {"version": "1.0", + "recommendation": { + "recommendationStatus": "Recommended"}}}, + ], + } + rows = setup_table_format(result) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]['Name'], 'ZoneDown-1.0') + self.assertEqual(rows[0]['Recommendation'], 'Recommended') + + def test_empty_when_no_scenarios(self): + from azext_chaos._table_format import setup_table_format + self.assertEqual(setup_table_format({"scenarios": []}), []) + self.assertEqual(setup_table_format({}), []) + + if __name__ == '__main__': unittest.main() diff --git a/src/chaos/azext_chaos/tests/latest/test_validators.py b/src/chaos/azext_chaos/tests/latest/test_validators.py index 86ec878d9c2..781129447a8 100644 --- a/src/chaos/azext_chaos/tests/latest/test_validators.py +++ b/src/chaos/azext_chaos/tests/latest/test_validators.py @@ -36,12 +36,41 @@ def test_valid_resource_group_scope(self): validate_scope(ns) # should not raise def test_valid_resource_scope(self): + # Individual resources are not advertised in help (the portal does not + # offer them), but the service accepts them, so the validator does not + # go out of its way to block them. ns = self._make_namespace( ["/subscriptions/00000000-0000-0000-0000-000000000000" "/resourceGroups/MyRG/providers/Microsoft.Compute/virtualMachines/myVM"] ) validate_scope(ns) # should not raise + def test_valid_service_group_scope(self): + ns = self._make_namespace( + ["/providers/Microsoft.Management/serviceGroups/my-critical-services"] + ) + validate_scope(ns) # should not raise + + def test_valid_service_group_scope_case_insensitive(self): + ns = self._make_namespace( + ["/providers/microsoft.management/servicegroups/sg1"] + ) + validate_scope(ns) # should not raise + + def test_mixed_subscription_and_service_group_scopes(self): + ns = self._make_namespace([ + "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/RG1", + "/providers/Microsoft.Management/serviceGroups/sg1", + ]) + validate_scope(ns) # should not raise + + def test_invalid_service_group_missing_name(self): + ns = self._make_namespace( + ["/providers/Microsoft.Management/serviceGroups"] + ) + with self.assertRaises(CLIError): + validate_scope(ns) + def test_multiple_valid_scopes(self): ns = self._make_namespace([ "/subscriptions/00000000-0000-0000-0000-000000000000", From de39211d37debef7e195c53890994b9d29b5a859 Mon Sep 17 00:00:00 2001 From: "Cristian Velez (from Dev Box)" Date: Tue, 16 Jun 2026 21:46:14 -0700 Subject: [PATCH 11/13] remove -w --- src/chaos/azext_chaos/custom.py | 9 ++--- .../tests/latest/test_custom_commands.py | 36 ++++++++++++++----- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/chaos/azext_chaos/custom.py b/src/chaos/azext_chaos/custom.py index e65a2393e23..428a3c2b455 100644 --- a/src/chaos/azext_chaos/custom.py +++ b/src/chaos/azext_chaos/custom.py @@ -943,7 +943,7 @@ def _evaluate_scenarios_workflow(cmd, resource_group_name, workspace_name, "identity, and role assignments are all in place. Re-run " "'az chaos workspace refresh-recommendation --name %s " "--resource-group %s' in a couple of minutes, then " - "'az chaos scenario list -w %s -g %s'.", + "'az chaos scenario list --workspace-name %s -g %s'.", failed_label, f" after {max_attempts} attempts" if max_attempts > 1 else "", workspace_name, resource_group_name, @@ -1008,7 +1008,8 @@ def _build_setup_next_steps(resource_group_name, workspace_name, scenarios, f"--name {workspace_name} --resource-group {resource_group_name}" ) steps.append( - f"az chaos scenario list -w {workspace_name} -g {resource_group_name}" + f"az chaos scenario list --workspace-name {workspace_name} " + f"-g {resource_group_name}" ) example_scenario = ( scenarios[0].get("name") if scenarios else "" @@ -1140,8 +1141,8 @@ def post_operations(self): "in resource group '%s'. Workspace evaluation has been refreshed; " "subsequent 'scenario config validate' / 'scenario run start' calls " "(for non-custom scenarios) now have a satisfied evaluation gate.\n" - "Run 'az chaos scenario list -w %s -g %s' to see updated " - "recommendation statuses.", + "Run 'az chaos scenario list --workspace-name %s -g %s' to see " + "updated recommendation statuses.", ws, rg, ws, rg, ) diff --git a/src/chaos/azext_chaos/tests/latest/test_custom_commands.py b/src/chaos/azext_chaos/tests/latest/test_custom_commands.py index 95c6ea7fbcb..4e9589030de 100644 --- a/src/chaos/azext_chaos/tests/latest/test_custom_commands.py +++ b/src/chaos/azext_chaos/tests/latest/test_custom_commands.py @@ -1357,15 +1357,35 @@ def test_build_next_steps_includes_refresh_when_not_evaluated(self): self.assertIn("refresh-recommendation", joined) self.assertIn("scenario list", joined) - def test_build_next_steps_omits_refresh_when_evaluated(self): + def test_build_next_steps_uses_workspace_name_not_dash_w(self): + """Regression guard: next-step hints must use --workspace-name, never + the non-existent '-w' short flag (the parent workspace arg has no short + option; '-w' is an artifact that fails at the command line).""" from azext_chaos.custom import _build_setup_next_steps - steps = _build_setup_next_steps( - "rg", "ws", [{"name": "ZoneDown-1.0"}], evaluated=True - ) - joined = "\n".join(steps) - self.assertNotIn("refresh-recommendation", joined) - # First discovered scenario name flows into the suggested commands. - self.assertIn("ZoneDown-1.0", joined) + for evaluated in (True, False): + steps = _build_setup_next_steps( + "rg", "ws", [{"name": "ZoneDown-1.0"}], evaluated=evaluated + ) + joined = " ".join(steps) + self.assertNotIn(" -w ", joined) + self.assertIn("--workspace-name", joined) + + +class TestNoDashWArtifact(unittest.TestCase): + """Repo-wide guard against the legacy '-w' workspace short-flag artifact. + + '-w' was an early artifact (from hand-edits to generated files) that never + existed as a real alias for --workspace-name on non-workspace commands. + User-facing hint text that suggests 'scenario list -w ...' fails for users. + This test scans the custom module source so the artifact cannot silently + return via copied next-step hints. + """ + + def test_custom_module_has_no_scenario_list_dash_w(self): + import azext_chaos.custom as custom_mod + with open(custom_mod.__file__, 'r', encoding='utf-8') as f: + source = f.read() + self.assertNotIn("scenario list -w", source) class TestSetupOrchestration(unittest.TestCase): From 7a49b11bd7f05311527f6ccc63306f132e193f9f Mon Sep 17 00:00:00 2001 From: "Cristian Velez (from Dev Box)" Date: Wed, 17 Jun 2026 14:22:40 -0700 Subject: [PATCH 12/13] Address feedback: run lifecycle, flag consistency, table detail, help - run start: poll the execute LRO until terminal instead of failing healthy long runs at the 600s cap; warn that --no-wait returns immediately (F2) - run wait: raise the wait framework's swallowed timeout error so a wait on a nonexistent run exits non-zero instead of silently succeeding (F6) - scenario run show: surface errors/executionErrors in table output (F4 partial; full root-cause persistence is a BE concern) - scenario config --parameters: enforce [{key,value}] shape with targeted errors and a format hint; apply help to create and update (F7) - run show/cancel/wait: expose --run-id/--name/-n consistently (F9) - scenario config create: hide auto-derived --scenario-id (F10d) - discovered-resource list/show: lead with resourceName/type, not the GUID (F10e) - regenerate aaz tree from updated codegen driver (workspace create example now includes --scopes; config examples use --name; command groups carry real help) azdev style/linter/test all pass; 180 unit tests pass. --- src/chaos/azext_chaos/_params.py | 26 +++++++-- src/chaos/azext_chaos/_table_format.py | 40 +++++++++++--- src/chaos/azext_chaos/_validators.py | 46 ++++++++++++---- .../aaz/latest/chaos/__cmd_group.py | 4 +- .../chaos/discovered_resource/__cmd_group.py | 4 +- .../aaz/latest/chaos/scenario/__cmd_group.py | 4 +- .../chaos/scenario/config/__cmd_group.py | 4 +- .../latest/chaos/scenario/config/_create.py | 4 +- .../latest/chaos/scenario/config/_delete.py | 2 +- .../latest/chaos/scenario/config/_execute.py | 2 +- .../latest/chaos/scenario/config/_update.py | 2 +- .../latest/chaos/scenario/config/_validate.py | 2 +- .../latest/chaos/scenario/run/__cmd_group.py | 4 +- .../aaz/latest/chaos/workspace/__cmd_group.py | 4 +- .../aaz/latest/chaos/workspace/_create.py | 2 +- src/chaos/azext_chaos/custom.py | 50 ++++++++++++----- src/chaos/azext_chaos/custom_wait.py | 17 +++++- .../tests/latest/test_custom_commands.py | 34 ++++++++++++ .../tests/latest/test_table_format.py | 53 ++++++++++++++++++- .../tests/latest/test_validators.py | 26 +++++++++ 20 files changed, 281 insertions(+), 49 deletions(-) diff --git a/src/chaos/azext_chaos/_params.py b/src/chaos/azext_chaos/_params.py index 3214e52ee26..a4415cd066d 100644 --- a/src/chaos/azext_chaos/_params.py +++ b/src/chaos/azext_chaos/_params.py @@ -164,11 +164,29 @@ def load_arguments(self, _): ) with self.argument_context('chaos scenario config create') as c: + # ``--scenario-id`` is auto-derived from --workspace-name/--scenario-name + # by the ScenarioConfigCreate subclass (custom.py); hide it so users are + # not asked for a redundant full ARM ID. + c.ignore('scenario_id') c.argument( 'parameters', options_list=['--parameters'], validator=validate_parameters_json, - help='Action parameters as a JSON string or @file.json reference.', + help='Action parameters as a JSON array of {key,value} objects ' + '(or @file.json containing that array). ' + 'Example: --parameters "[{key:duration,value:PT10M}]" ' + 'or --parameters @params.json', + ) + + with self.argument_context('chaos scenario config update') as c: + c.argument( + 'parameters', + options_list=['--parameters'], + validator=validate_parameters_json, + help='Action parameters as a JSON array of {key,value} objects ' + '(or @file.json containing that array). ' + 'Example: --parameters "[{key:duration,value:PT10M}]" ' + 'or --parameters @params.json', ) with self.argument_context('chaos scenario config delete') as c: @@ -209,16 +227,18 @@ def load_arguments(self, _): with self.argument_context('chaos scenario run show') as c: # Spec pattern: ^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$ + # Consistent with 'run cancel' and 'run wait': --run-id is canonical, + # with -n/--name as aliases. c.argument( 'run_id', - options_list=['--run-id'], + options_list=['--run-id', '--name', '-n'], help='GUID of the scenario run.', ) with self.argument_context('chaos scenario run cancel') as c: c.argument( 'run_id', - options_list=['--run-id'], + options_list=['--run-id', '--name', '-n'], help='GUID of the scenario run to cancel.', ) diff --git a/src/chaos/azext_chaos/_table_format.py b/src/chaos/azext_chaos/_table_format.py index 5a359b0bae3..c015f1c6aab 100644 --- a/src/chaos/azext_chaos/_table_format.py +++ b/src/chaos/azext_chaos/_table_format.py @@ -118,11 +118,36 @@ def scenario_config_list_table_format(results): def scenario_run_show_table_format(result): - return _project(result, """{ + """Table formatter for a scenario run. + + Surfaces error detail (``properties.errors`` and + ``properties.executionErrors``) so a failed run is diagnosable from + ``--output table`` instead of only showing ``Status: Failed``. Full detail + (per-resource / permission errors) remains in ``--output json``. + """ + props = result.get('properties') or {} + parts = [] + for err in (props.get('errors') or []): + code = err.get('errorCode') or '' + msg = err.get('errorMessage') or '' + detail = f"{code}: {msg}".strip(': ') + if detail: + parts.append(detail) + exec_errors = props.get('executionErrors') or {} + if exec_errors: + code = exec_errors.get('errorCode') or '' + msg = exec_errors.get('errorMessage') or '' + detail = f"{code}: {msg}".strip(': ') + if detail: + parts.append(detail) + proj = dict(result) + proj['_error'] = '; '.join(parts) + return _project(proj, """{ RunId: name, Status: properties.status, StartTime: properties.startTime, - EndTime: properties.endTime + EndTime: properties.endTime, + Error: _error }""") @@ -174,16 +199,17 @@ def permission_fix_show_table_format(result): def discovered_resource_show_table_format(result): """Table formatter for a single discovered resource. - Columns derived from the ``DiscoveredResourceProperties`` schema: - namespace, resourceName, resourceType, fullyQualifiedIdentifier, - discoveredAt, scope. We surface the most useful subset for table width. + Leads with the human-meaningful ``resourceName`` / ``resourceType`` rather + than the opaque ``name`` GUID. ``Id`` (the GUID) is retained because it is + the value passed to ``discovered-resource show --name``, but it no longer + leads the row. """ return _project(result, """{ - Name: name, ResourceName: properties.resourceName, ResourceType: properties.resourceType, Namespace: properties.namespace, - DiscoveredAt: properties.discoveredAt + DiscoveredAt: properties.discoveredAt, + Id: name }""") diff --git a/src/chaos/azext_chaos/_validators.py b/src/chaos/azext_chaos/_validators.py index bd35c01f6bb..93cd5462fbb 100644 --- a/src/chaos/azext_chaos/_validators.py +++ b/src/chaos/azext_chaos/_validators.py @@ -85,13 +85,22 @@ def validate_user_assigned(namespace): ) +_PARAMETERS_FORMAT_HINT = ( + "Expected a JSON array of {key, value} objects, e.g. " + "--parameters \"[{key:duration,value:PT10M}]\", or a file reference " + "--parameters @params.json containing that array. Note: a bare " + "key=value string or a JSON object ({...}) is not accepted." +) + + def validate_parameters_json(namespace): """Validate and parse the --parameters argument. - Accepts either ``@filename.json`` (file reference) or a raw JSON string, - following the same convention as ``az rest --body``. The parsed Python - object replaces the raw string on *namespace.parameters* so downstream - code receives a dict/list, not a string. + Accepts either ``@filename.json`` (file reference) or a raw JSON string + that decodes to a JSON array of ``{key, value}`` objects. The parsed Python + object replaces the raw string on *namespace.parameters* so downstream code + receives a list, not a string. Surfaces a format hint on every failure so + the accepted shape is discoverable from the error alone. """ value = getattr(namespace, 'parameters', None) if not value: @@ -101,19 +110,38 @@ def validate_parameters_json(namespace): file_path = value[1:] if not os.path.isfile(file_path): raise CLIError( - f"Parameters file not found: '{file_path}'." + f"Parameters file not found: '{file_path}'. " + f"{_PARAMETERS_FORMAT_HINT}" ) with open(file_path, 'r', encoding='utf-8') as f: try: - namespace.parameters = json.load(f) + parsed = json.load(f) except json.JSONDecodeError as exc: raise CLIError( - f"Invalid JSON in parameters file '{file_path}': {exc}" + f"Invalid JSON in parameters file '{file_path}': {exc}. " + f"{_PARAMETERS_FORMAT_HINT}" ) from exc else: + # Catch the most common non-JSON mistake (key=value) with a targeted + # message before the generic JSON parse error. + if '=' in value and not value.lstrip().startswith(('[', '{')): + raise CLIError( + f"Invalid --parameters value: '{value}'. " + f"{_PARAMETERS_FORMAT_HINT}" + ) try: - namespace.parameters = json.loads(value) + parsed = json.loads(value) except json.JSONDecodeError as exc: raise CLIError( - f"Invalid JSON for --parameters: {exc}" + f"Invalid JSON for --parameters: {exc}. " + f"{_PARAMETERS_FORMAT_HINT}" ) from exc + + if not isinstance(parsed, list) or not all( + isinstance(item, dict) and 'key' in item and 'value' in item + for item in parsed + ): + raise CLIError( + f"Invalid --parameters shape. {_PARAMETERS_FORMAT_HINT}" + ) + namespace.parameters = parsed diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/__cmd_group.py b/src/chaos/azext_chaos/aaz/latest/chaos/__cmd_group.py index 661f823f562..783884507ae 100644 --- a/src/chaos/azext_chaos/aaz/latest/chaos/__cmd_group.py +++ b/src/chaos/azext_chaos/aaz/latest/chaos/__cmd_group.py @@ -16,7 +16,9 @@ is_preview=True, ) class __CMDGroup(AAZCommandGroup): - """Manage Chaos + """Manage Azure Chaos Studio resources. + + Create and manage Chaos Studio v2 workspaces, scenarios, scenario configurations, and runs for chaos engineering experiments. """ pass diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/__cmd_group.py b/src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/__cmd_group.py index a01eac9410e..528ade4e68e 100644 --- a/src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/__cmd_group.py +++ b/src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/__cmd_group.py @@ -16,7 +16,9 @@ is_preview=True, ) class __CMDGroup(AAZCommandGroup): - """Manage Discovered Resource + """Browse discovered resources in a Chaos Studio workspace. + + Discovered resources are populated by workspace discovery scans (triggered via 'az chaos workspace refresh-recommendation'). Use 'list' to see all discovered resources and 'show' to inspect a specific one. """ pass diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/__cmd_group.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/__cmd_group.py index 9cf2f3c10bf..6473740bb1f 100644 --- a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/__cmd_group.py +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/__cmd_group.py @@ -16,7 +16,9 @@ is_preview=True, ) class __CMDGroup(AAZCommandGroup): - """Manage Scenario + """Manage Chaos Studio scenarios within a workspace. + + Scenarios define the fault-injection actions available in a workspace. Catalog scenarios are populated by workspace evaluation (see 'az chaos workspace refresh-recommendation'); custom scenarios can be created directly. """ pass diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/__cmd_group.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/__cmd_group.py index 700c44e47d8..5c1c1f6785b 100644 --- a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/__cmd_group.py +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/__cmd_group.py @@ -16,7 +16,9 @@ is_preview=True, ) class __CMDGroup(AAZCommandGroup): - """Manage Configuration + """Manage scenario configurations for a Chaos Studio scenario. + + Scenario configurations define the steps, branches, and fault parameters for a chaos experiment run. Use 'validate' to check a configuration before execution and 'fix-permissions' to grant the required RBAC roles. """ pass diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_create.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_create.py index 10b358d477a..6d41bbb7075 100644 --- a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_create.py +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_create.py @@ -19,10 +19,10 @@ class Create(AAZCommand): """Create a scenario definition. :example: Create or update a scenario configuration with physical zone targeting. - az chaos scenario config create --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 --scenario-configuration-name my-config --exclusions "{resources:[/subscriptions/6b052e15-03d3-4f17-b2e1-be7f07588291/resourceGroups/exampleRG/providers/Microsoft.Compute/virtualMachines/protectedVM]}" --parameters "[{key:duration,value:PT10M}]" --filters "{locations:[westus2],physical-zones:[westus2-az1]}" + az chaos scenario config create --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 --name my-config --exclusions "{resources:[/subscriptions/6b052e15-03d3-4f17-b2e1-be7f07588291/resourceGroups/exampleRG/providers/Microsoft.Compute/virtualMachines/protectedVM]}" --parameters "[{key:duration,value:PT10M}]" --filters "{locations:[westus2],physical-zones:[westus2-az1]}" :example: Create or update a scenario configuration with availability zone targeting. - az chaos scenario config create --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 --scenario-configuration-name my-config --filters "{locations:[eastus],zones:[1]}" --parameters "[{key:duration,value:PT10M}]" + az chaos scenario config create --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 --name my-config --filters "{locations:[eastus],zones:[1]}" --parameters "[{key:duration,value:PT10M}]" """ _aaz_info = { diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_delete.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_delete.py index d3a580e69c9..4e2d269f683 100644 --- a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_delete.py +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_delete.py @@ -20,7 +20,7 @@ class Delete(AAZCommand): """Delete a scenario definition. :example: Delete a scenario configuration. - az chaos scenario config delete --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 --scenario-configuration-name my-config + az chaos scenario config delete --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 --name my-config """ _aaz_info = { diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_execute.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_execute.py index 2167a38843b..662125e1416 100644 --- a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_execute.py +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_execute.py @@ -19,7 +19,7 @@ class Execute(AAZCommand): """Execute the scenario execution with the given scenario configuration. :example: Execute the scenario execution with the given scenario configuration. - az chaos scenario config execute --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name 12345678-1234-1234-1234-123456789012 --scenario-configuration-name config-5678-9012-3456-789012345678 + az chaos scenario config execute --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 --name my-config """ _aaz_info = { diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_update.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_update.py index d4710a72a98..518a2419178 100644 --- a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_update.py +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_update.py @@ -19,7 +19,7 @@ class Update(AAZCommand): """Update a scenario definition. :example: Update a scenario configuration. - az chaos scenario config update --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 --scenario-configuration-name my-config + az chaos scenario config update --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 --name my-config """ _aaz_info = { diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_validate.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_validate.py index 2c44d5f16b5..0669cf3928b 100644 --- a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_validate.py +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/_validate.py @@ -19,7 +19,7 @@ class Validate(AAZCommand): """Validate the given scenario configuration. :example: Validate a scenario configuration. - az chaos scenario config validate --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 --scenario-configuration-name my-config + az chaos scenario config validate --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 --name my-config """ _aaz_info = { diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/__cmd_group.py b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/__cmd_group.py index 7e9dd6c9056..a3f8fb16f10 100644 --- a/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/__cmd_group.py +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/__cmd_group.py @@ -16,7 +16,9 @@ is_preview=True, ) class __CMDGroup(AAZCommandGroup): - """Manage Run + """Manage scenario runs for a Chaos Studio scenario. + + Scenario runs represent individual executions of a scenario configuration. Use 'start' to begin a new run, 'show' to inspect its status, and 'cancel' to stop a running execution. """ pass diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/__cmd_group.py b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/__cmd_group.py index 4136af55f2c..197d65118f0 100644 --- a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/__cmd_group.py +++ b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/__cmd_group.py @@ -16,7 +16,9 @@ is_preview=True, ) class __CMDGroup(AAZCommandGroup): - """Manage Workspace + """Manage Chaos Studio workspaces. + + Workspaces are the top-level resource for Chaos Studio v2. They define the scope of resources that can be targeted by chaos scenarios and the managed identity used for fault injection. """ pass diff --git a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_create.py b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_create.py index 5b83df6e54a..a85eb17f31b 100644 --- a/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_create.py +++ b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/_create.py @@ -19,7 +19,7 @@ class Create(AAZCommand): """Create a Workspace resource. :example: Create or update a Workspace. - az chaos workspace create --resource-group exampleRG --workspace-name exampleWorkspace --location eastus --tags '{key1:value1,key2:value2}' + az chaos workspace create --resource-group exampleRG --workspace-name exampleWorkspace --location eastus --scopes /subscriptions/6b052e15-03d3-4f17-b2e1-be7f07588291/resourceGroups/exampleRG --tags '{key1:value1,key2:value2}' :example: Create or update a Workspace with a user-assigned managed identity. az chaos workspace create --resource-group exampleRG --workspace-name exampleWorkspace --location eastus --mi-user-assigned /subscriptions/6b052e15-03d3-4f17-b2e1-be7f07588291/resourceGroups/exampleRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/exampleIdentity --scopes /subscriptions/6b052e15-03d3-4f17-b2e1-be7f07588291/resourceGroups/exampleRG diff --git a/src/chaos/azext_chaos/custom.py b/src/chaos/azext_chaos/custom.py index 428a3c2b455..2699bbe2674 100644 --- a/src/chaos/azext_chaos/custom.py +++ b/src/chaos/azext_chaos/custom.py @@ -28,12 +28,17 @@ _LRO_TIMEOUT_SECONDS = 600 # 10 min default -def _poll_or_return(cmd, response): - """Dispatch a raw HTTP response: return body for 200, poll for 201/202, raise on error.""" +def _poll_or_return(cmd, response, timeout=_LRO_TIMEOUT_SECONDS): + """Dispatch a raw HTTP response: return body for 200, poll for 201/202, raise on error. + + ``timeout`` is the maximum seconds to poll an LRO; ``None`` means poll until + the service reaches a terminal state (used for scenario runs, which can + legitimately run longer than the default cap). + """ if response.status_code == 200: return response.json() if response.text else None if response.status_code in (201, 202): - return _poll_lro(cmd, response) + return _poll_lro(cmd, response, timeout=timeout) try: error_body = response.json() message = error_body.get("error", {}).get("message", response.text) @@ -42,7 +47,7 @@ def _poll_or_return(cmd, response): raise CLIError(f"Request failed ({response.status_code}): {message}") -def _poll_lro(cmd, initial_response): +def _poll_lro(cmd, initial_response, timeout=_LRO_TIMEOUT_SECONDS): """Choose polling strategy based on LRO headers.""" headers = initial_response.headers async_url = headers.get("Azure-AsyncOperation") @@ -52,16 +57,23 @@ def _poll_lro(cmd, initial_response): except (ValueError, TypeError): retry_after = _LRO_POLL_INTERVAL_SECONDS if async_url: - return _poll_async_operation(cmd, async_url, location_url, retry_after) + return _poll_async_operation(cmd, async_url, location_url, retry_after, + timeout=timeout) if location_url: - return _poll_location(cmd, location_url, retry_after) + return _poll_location(cmd, location_url, retry_after, timeout=timeout) return initial_response.json() if initial_response.text else None -def _poll_async_operation(cmd, poll_url, location_url, retry_after): +def _within_timeout(elapsed, timeout): + """True while polling should continue (``timeout=None`` means no cap).""" + return timeout is None or elapsed < timeout + + +def _poll_async_operation(cmd, poll_url, location_url, retry_after, + timeout=_LRO_TIMEOUT_SECONDS): """Poll Azure-AsyncOperation URL until terminal status.""" elapsed = 0 - while elapsed < _LRO_TIMEOUT_SECONDS: + while _within_timeout(elapsed, timeout): time.sleep(retry_after) elapsed += retry_after poll_resp = send_raw_request(cmd.cli_ctx, "GET", poll_url) @@ -80,13 +92,13 @@ def _poll_async_operation(cmd, poll_url, location_url, retry_after): final = send_raw_request(cmd.cli_ctx, "GET", location_url) return final.json() if final.text else body return body - raise CLIError(f"Long-running operation timed out after {_LRO_TIMEOUT_SECONDS}s.") + raise CLIError(f"Long-running operation timed out after {timeout}s.") -def _poll_location(cmd, poll_url, retry_after): +def _poll_location(cmd, poll_url, retry_after, timeout=_LRO_TIMEOUT_SECONDS): """Poll Location URL until non-202 response.""" elapsed = 0 - while elapsed < _LRO_TIMEOUT_SECONDS: + while _within_timeout(elapsed, timeout): time.sleep(retry_after) elapsed += retry_after poll_resp = send_raw_request(cmd.cli_ctx, "GET", poll_url) @@ -103,7 +115,7 @@ def _poll_location(cmd, poll_url, retry_after): retry_after = int(poll_resp.headers.get("Retry-After", retry_after)) except (ValueError, TypeError): pass # keep current retry_after - raise CLIError(f"Long-running operation timed out after {_LRO_TIMEOUT_SECONDS}s.") + raise CLIError(f"Long-running operation timed out after {timeout}s.") def _build_arm_url(cli_ctx, resource_group, workspace, path_suffix): @@ -363,8 +375,18 @@ def scenario_run_start(cmd, resource_group_name, workspace_name, # pylint: disa "operationStatusUrl": async_op or location or None, } - # Poll execute LRO to completion - run_result = _poll_or_return(cmd, exec_response) + # Poll execute LRO to completion. A scenario run's execute LRO stays + # in-progress for the full run duration (e.g. ZoneDown defaults to PT15M), + # which exceeds the default 600s poll cap — so poll until the service + # reaches a terminal state (timeout=None) instead of failing a healthy run. + # Users who do not want to block for the whole run should pass --no-wait. + logger.warning( + "Waiting for the scenario run to complete. This blocks for the full " + "run duration, which can be many minutes. Press Ctrl+C and use " + "'az chaos scenario run show'/'wait' to poll instead, or re-run with " + "--no-wait to return immediately with the run ID.", + ) + run_result = _poll_or_return(cmd, exec_response, timeout=None) # Extract run ID from the completed ScenarioRun resource if isinstance(run_result, dict): diff --git a/src/chaos/azext_chaos/custom_wait.py b/src/chaos/azext_chaos/custom_wait.py index fb2e907e209..1a692651dd2 100644 --- a/src/chaos/azext_chaos/custom_wait.py +++ b/src/chaos/azext_chaos/custom_wait.py @@ -41,6 +41,21 @@ def _handler(self, command_args): self._execute_operations() return self._output() + def __call__(self, *args, **kwargs): + # The AAZ wait framework (WaitCommandOperation.wait) RETURNS a CLIError + # on timeout instead of raising it. When the run id does not exist, the + # underlying GET 404s and that 404 is swallowed for --created/--exists/ + # --custom; the loop then runs to timeout and *returns* a CLIError, + # which makes the command exit 0 — a silent CI trap. Convert that + # returned error into a raised one so a timed-out (or never-existing) + # run yields a non-zero exit. --deleted (404 == success) returns None + # and is unaffected. + from knack.util import CLIError + result = super().__call__(*args, **kwargs) + if isinstance(result, CLIError): + raise result + return result + _args_schema = None @classmethod @@ -56,7 +71,7 @@ def _build_arguments_schema(cls, *args, **kwargs): required=True, ) _args_schema.run_id = AAZStrArg( - options=["-n", "--name", "--run-id"], + options=["--run-id", "--name", "-n"], help="The name of the ScenarioRun", required=True, id_part="child_name_2", diff --git a/src/chaos/azext_chaos/tests/latest/test_custom_commands.py b/src/chaos/azext_chaos/tests/latest/test_custom_commands.py index 4e9589030de..df8a67f502e 100644 --- a/src/chaos/azext_chaos/tests/latest/test_custom_commands.py +++ b/src/chaos/azext_chaos/tests/latest/test_custom_commands.py @@ -1594,5 +1594,39 @@ def test_no_wait_single_attempt_on_failure(self, mock_send, mock_poll, mock_sleep.assert_not_called() +class TestPollTimeout(unittest.TestCase): + """F2: poll timeout is parameterizable; None means poll until terminal.""" + + def test_within_timeout_none_never_caps(self): + from azext_chaos.custom import _within_timeout + self.assertTrue(_within_timeout(0, None)) + self.assertTrue(_within_timeout(10_000_000, None)) + + def test_within_timeout_bounded(self): + from azext_chaos.custom import _within_timeout + self.assertTrue(_within_timeout(0, 600)) + self.assertTrue(_within_timeout(599, 600)) + self.assertFalse(_within_timeout(600, 600)) + self.assertFalse(_within_timeout(601, 600)) + + @patch('azext_chaos.custom.send_raw_request') + @patch('azext_chaos.custom.time.sleep') + def test_async_poll_honors_no_timeout(self, mock_sleep, mock_send): + """With timeout=None, polling continues past the default 600s cap until + the service reports a terminal status (no premature timeout).""" + from azext_chaos.custom import _poll_async_operation + # 200 in-progress polls (each advancing elapsed by retry_after=5 → + # 1000s, well past the 600s default), then Succeeded. + in_progress = [ + _make_response(json_body={"status": "Running"}) for _ in range(200) + ] + terminal = _make_response(json_body={"status": "Succeeded"}) + mock_send.side_effect = in_progress + [terminal] + result = _poll_async_operation( + _make_cmd(), "https://poll", None, retry_after=5, timeout=None, + ) + self.assertEqual(result.get("status"), "Succeeded") + + if __name__ == '__main__': unittest.main() diff --git a/src/chaos/azext_chaos/tests/latest/test_table_format.py b/src/chaos/azext_chaos/tests/latest/test_table_format.py index 21cf895ab36..236c38be1e8 100644 --- a/src/chaos/azext_chaos/tests/latest/test_table_format.py +++ b/src/chaos/azext_chaos/tests/latest/test_table_format.py @@ -350,11 +350,15 @@ def test_discovered_resource_show_table_format(self): }, } table = discovered_resource_show_table_format(result) - self.assertEqual(table['Name'], 'myvm') + # F10e: leads with human-meaningful columns; the opaque GUID is now 'Id'. + self.assertEqual(table['Id'], 'myvm') self.assertEqual(table['ResourceName'], 'myvm') self.assertEqual(table['ResourceType'], 'virtualMachines') self.assertEqual(table['Namespace'], 'Microsoft.Compute') self.assertEqual(table['DiscoveredAt'], '2026-01-01T00:00:00Z') + # ResourceName must precede Id so the table does not lead with the GUID. + keys = list(table.keys()) + self.assertLess(keys.index('ResourceName'), keys.index('Id')) def test_discovered_resource_list_table_format(self): from azext_chaos._table_format import ( @@ -376,10 +380,55 @@ def test_discovered_resource_list_table_format(self): ] tables = discovered_resource_list_table_format(results) self.assertEqual(len(tables), 2) - self.assertEqual(tables[0]['Name'], 'r1') + self.assertEqual(tables[0]['Id'], 'r1') + self.assertEqual(tables[0]['ResourceName'], 'r1') self.assertEqual(tables[1]['Namespace'], 'ns2') +class TestScenarioRunErrorSurfacing(unittest.TestCase): + """F4: a failed run's error detail is surfaced in --output table.""" + + def test_surfaces_errors_list(self): + from azext_chaos._table_format import scenario_run_show_table_format + result = { + 'name': 'run-1', + 'properties': { + 'status': 'Failed', + 'startTime': 't0', + 'endTime': 't1', + 'errors': [ + {'errorCode': 'AuthorizationFailed', + 'errorMessage': "Action 'shutdown' failed."}, + ], + }, + } + table = scenario_run_show_table_format(result) + self.assertEqual(table['Status'], 'Failed') + self.assertIn('AuthorizationFailed', table['Error']) + self.assertIn("Action 'shutdown' failed.", table['Error']) + + def test_surfaces_execution_errors(self): + from azext_chaos._table_format import scenario_run_show_table_format + result = { + 'name': 'run-2', + 'properties': { + 'status': 'Failed', + 'executionErrors': { + 'errorCode': 'ProviderError', + 'errorMessage': 'provider rejected the action', + }, + }, + } + table = scenario_run_show_table_format(result) + self.assertIn('ProviderError', table['Error']) + + def test_no_error_on_success(self): + from azext_chaos._table_format import scenario_run_show_table_format + result = {'name': 'run-3', 'properties': {'status': 'Succeeded'}} + table = scenario_run_show_table_format(result) + self.assertEqual(table['Error'], '') + + class TestWorkspaceDiscoveryEvaluationTableFormat(unittest.TestCase): def test_workspace_discovery_show(self): diff --git a/src/chaos/azext_chaos/tests/latest/test_validators.py b/src/chaos/azext_chaos/tests/latest/test_validators.py index 781129447a8..956bb0b2c73 100644 --- a/src/chaos/azext_chaos/tests/latest/test_validators.py +++ b/src/chaos/azext_chaos/tests/latest/test_validators.py @@ -149,6 +149,32 @@ def test_file_reference_nonexistent(self): with self.assertRaises(CLIError): validate_parameters_json(ns) + def test_rejects_key_value_string(self): + # F7: a natural key=value mistake gets a targeted error with the hint. + ns = self._make_namespace("duration=PT10M") + with self.assertRaises(CLIError) as ctx: + validate_parameters_json(ns) + self.assertIn('JSON array', str(ctx.exception)) + + def test_rejects_json_object(self): + # F7: a JSON object (not an array) is rejected with the format hint. + ns = self._make_namespace('{"duration": "PT10M"}') + with self.assertRaises(CLIError) as ctx: + validate_parameters_json(ns) + self.assertIn('JSON array', str(ctx.exception)) + + def test_rejects_list_without_key_value(self): + # F7: array elements must be {key, value} objects. + ns = self._make_namespace('[{"name": "duration"}]') + with self.assertRaises(CLIError): + validate_parameters_json(ns) + + def test_accepts_valid_key_value_array(self): + data = [{"key": "duration", "value": "PT10M"}] + ns = self._make_namespace(json.dumps(data)) + validate_parameters_json(ns) + self.assertEqual(ns.parameters, data) + if __name__ == '__main__': unittest.main() From 8818d71a1d4f25c037c995b13feead344b754029 Mon Sep 17 00:00:00 2001 From: "Cristian Velez (from Dev Box)" Date: Wed, 17 Jun 2026 19:09:59 -0700 Subject: [PATCH 13/13] Added missing optional location parameter and updated service name to include Chaos Studio --- src/chaos/azext_chaos/_help.py | 6 +- src/chaos/azext_chaos/_params.py | 7 +- src/chaos/azext_chaos/custom.py | 69 ++++++++++++++++--- .../tests/latest/test_custom_commands.py | 41 +++++++++-- src/service_name.json | 5 ++ 5 files changed, 109 insertions(+), 19 deletions(-) diff --git a/src/chaos/azext_chaos/_help.py b/src/chaos/azext_chaos/_help.py index c92288ac008..a076a95b069 100644 --- a/src/chaos/azext_chaos/_help.py +++ b/src/chaos/azext_chaos/_help.py @@ -72,7 +72,11 @@ First-day-experience command that orchestrates the full bootstrap workflow so you do not have to run the individual commands yourself: - 1. Creates the resource group if it does not already exist. + 1. Creates the resource group if it does not already exist. If the group + already exists it is reused, and '--location' is optional (it defaults + to the group's location). If the group does not exist, '--location' is + required because setup creates the group and has no region to default + to. 2. Creates the workspace with a managed identity (user-assigned if '--user-assigned' is supplied, otherwise a system-assigned identity). 3. Grants that identity the built-in 'Reader' role on each '--scopes' diff --git a/src/chaos/azext_chaos/_params.py b/src/chaos/azext_chaos/_params.py index a4415cd066d..8bdf8a26ae7 100644 --- a/src/chaos/azext_chaos/_params.py +++ b/src/chaos/azext_chaos/_params.py @@ -25,8 +25,11 @@ def load_arguments(self, _): c.argument( 'location', arg_type=get_location_type(self.cli_ctx), - help='Location for the workspace (and the resource group, if it ' - 'is created).', + help='Location for the workspace. Optional only when the ' + 'resource group already exists (defaults to the resource ' + "group's location). Required when the resource group does " + 'not exist, because setup creates it and has no region to ' + 'default to.', ) c.argument( 'scopes', diff --git a/src/chaos/azext_chaos/custom.py b/src/chaos/azext_chaos/custom.py index 2699bbe2674..74150ee811e 100644 --- a/src/chaos/azext_chaos/custom.py +++ b/src/chaos/azext_chaos/custom.py @@ -645,7 +645,7 @@ def scenario_run_cancel(cmd, resource_group_name, workspace_name, # pylint: dis _EVALUATION_RETRY_INTERVAL_SECONDS = 120 -def setup(cmd, resource_group_name, workspace_name, location, scopes, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals +def setup(cmd, resource_group_name, workspace_name, scopes, location=None, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals user_assigned=None, skip_permissions=False, skip_evaluation_wait=False, tags=None): """Stand up a ready-to-use Chaos Studio environment end to end. @@ -655,6 +655,11 @@ def setup(cmd, resource_group_name, workspace_name, location, scopes, # pylint: Reader role discovery requires on each scope, evaluate scenarios, and report the discovered scenarios plus suggested next commands. + ``--location`` is optional: when omitted it defaults to the resource + group's location if the group already exists. It is only required when the + resource group does not yet exist (setup creates it and has no other way to + choose a region). + Assigning Reader is idempotent — an already-present assignment is a no-op (ARM reports ``RoleAssignmentExists``). Only when a *new* assignment is created this run does the evaluate step wait out Azure Resource Graph @@ -662,6 +667,9 @@ def setup(cmd, resource_group_name, workspace_name, location, scopes, # pylint: a single attempt (e.g. in CI). """ # 1. Resource group ────────────────────────────────────────────────── + # Resolve the location before touching the RG: use --location if given, + # else the existing RG's location; error only if neither is available. + location = _resolve_setup_location(cmd, resource_group_name, location) _ensure_resource_group(cmd, resource_group_name, location) # 2. Workspace ─────────────────────────────────────────────────────── @@ -726,8 +734,8 @@ def setup(cmd, resource_group_name, workspace_name, location, scopes, # pylint: } -def _ensure_resource_group(cmd, resource_group_name, location): - """Create the resource group if it does not already exist.""" +def _get_resource_group(cmd, resource_group_name): + """GET the resource group; return its JSON body, or None if it does not exist.""" from azure.cli.core.commands.client_factory import get_subscription_id sub = get_subscription_id(cmd.cli_ctx) rg_url = ( @@ -735,14 +743,55 @@ def _ensure_resource_group(cmd, resource_group_name, location): f"?api-version={_RESOURCE_GROUP_API_VERSION}" ) try: - existing = send_raw_request(cmd.cli_ctx, "GET", rg_url) - if existing.status_code == 200: - logger.warning( - "Using existing resource group '%s'.", resource_group_name, - ) - return + response = send_raw_request(cmd.cli_ctx, "GET", rg_url) + except Exception: # pylint: disable=broad-except + return None # GET raises on 404 + if response.status_code != 200 or not response.text: + return None + try: + return response.json() except Exception: # pylint: disable=broad-except - pass # GET raises on 404 — fall through to create. + return None + + +def _resolve_setup_location(cmd, resource_group_name, location): + """Resolve the location to use, defaulting to the existing RG's location. + + ``--location`` is optional. When omitted we fall back to the resource + group's location if the group already exists. If the group does not exist + (setup would create it) we cannot pick a region, so we ask the user to + supply ``--location`` explicitly. + """ + if location: + return location + existing = _get_resource_group(cmd, resource_group_name) + rg_location = (existing or {}).get("location") + if rg_location: + logger.warning( + "Using location '%s' from existing resource group '%s'.", + rg_location, resource_group_name, + ) + return rg_location + raise CLIError( + f"--location/-l is required: resource group '{resource_group_name}' " + "does not exist, so setup must create it and has no region to default " + "to. Re-run with --location, or pre-create the resource group." + ) + + +def _ensure_resource_group(cmd, resource_group_name, location): + """Create the resource group if it does not already exist.""" + from azure.cli.core.commands.client_factory import get_subscription_id + if _get_resource_group(cmd, resource_group_name) is not None: + logger.warning( + "Using existing resource group '%s'.", resource_group_name, + ) + return + sub = get_subscription_id(cmd.cli_ctx) + rg_url = ( + f"/subscriptions/{sub}/resourcegroups/{resource_group_name}" + f"?api-version={_RESOURCE_GROUP_API_VERSION}" + ) logger.warning( "Creating resource group '%s' in location '%s'.", resource_group_name, location, diff --git a/src/chaos/azext_chaos/tests/latest/test_custom_commands.py b/src/chaos/azext_chaos/tests/latest/test_custom_commands.py index df8a67f502e..383ae030efc 100644 --- a/src/chaos/azext_chaos/tests/latest/test_custom_commands.py +++ b/src/chaos/azext_chaos/tests/latest/test_custom_commands.py @@ -1415,7 +1415,7 @@ def test_happy_path_assigns_roles_and_returns_scenarios(self): scopes = ["/subscriptions/s/resourceGroups/rg"] result = setup( - _make_cmd(), 'rg', 'ws', 'westus2', scopes, + _make_cmd(), 'rg', 'ws', scopes, location='westus2', ) m_rg.assert_called_once() @@ -1441,8 +1441,9 @@ def test_skip_permissions_skips_role_assignment(self): m_list.return_value = [] result = setup( - _make_cmd(), 'rg', 'ws', 'westus2', + _make_cmd(), 'rg', 'ws', ["/subscriptions/s/resourceGroups/rg"], + location='westus2', skip_permissions=True, ) @@ -1467,8 +1468,9 @@ def test_preexisting_assignment_does_not_wait(self): m_eval.return_value = True m_list.return_value = [] - setup(_make_cmd(), 'rg', 'ws', 'westus2', - ["/subscriptions/s/resourceGroups/rg"]) + setup(_make_cmd(), 'rg', 'ws', + ["/subscriptions/s/resourceGroups/rg"], + location='westus2') self.assertFalse( m_eval.call_args.kwargs.get("wait_for_propagation") @@ -1486,8 +1488,9 @@ def test_skip_evaluation_wait_forces_single_attempt(self): m_eval.return_value = True m_list.return_value = [] - setup(_make_cmd(), 'rg', 'ws', 'westus2', + setup(_make_cmd(), 'rg', 'ws', ["/subscriptions/s/resourceGroups/rg"], + location='westus2', skip_evaluation_wait=True) # New assignment, but the user opted out of waiting. @@ -1509,12 +1512,38 @@ def test_role_assigned_per_principal_per_scope(self): scopes = ["/subscriptions/s/resourceGroups/rg1", "/subscriptions/s/resourceGroups/rg2"] - setup(_make_cmd(), 'rg', 'ws', 'westus2', scopes) + setup(_make_cmd(), 'rg', 'ws', scopes, location='westus2') # 2 principals x 2 scopes = 4 assignments. self.assertEqual(m_role.call_count, 4) +class TestResolveSetupLocation(unittest.TestCase): + """F-location: --location is optional only when the RG already exists.""" + + @patch('azext_chaos.custom._get_resource_group') + def test_explicit_location_wins(self, mock_get_rg): + from azext_chaos.custom import _resolve_setup_location + loc = _resolve_setup_location(_make_cmd(), 'rg', 'westus2') + self.assertEqual(loc, 'westus2') + mock_get_rg.assert_not_called() # no RG lookup needed + + @patch('azext_chaos.custom._get_resource_group') + def test_defaults_to_existing_rg_location(self, mock_get_rg): + from azext_chaos.custom import _resolve_setup_location + mock_get_rg.return_value = {"location": "eastus"} + loc = _resolve_setup_location(_make_cmd(), 'rg', None) + self.assertEqual(loc, 'eastus') + + @patch('azext_chaos.custom._get_resource_group') + def test_raises_when_rg_missing_and_no_location(self, mock_get_rg): + from azext_chaos.custom import _resolve_setup_location + mock_get_rg.return_value = None # RG does not exist + with self.assertRaises(CLIError) as ctx: + _resolve_setup_location(_make_cmd(), 'rg', None) + self.assertIn('--location', str(ctx.exception)) + + class TestEvaluateScenariosWorkflow(unittest.TestCase): """Tests for the evaluate-scenarios workflow + ARG-propagation retry.""" diff --git a/src/service_name.json b/src/service_name.json index dd23aae4f5c..21f273b4fb0 100644 --- a/src/service_name.json +++ b/src/service_name.json @@ -799,6 +799,11 @@ "AzureServiceName": "Monitor", "URL": "https://learn.microsoft.com/azure/azure-monitor/change/change-analysis" }, + { + "Command": "az chaos", + "AzureServiceName": "Azure Chaos Studio", + "URL": "https://learn.microsoft.com/azure/chaos-studio/" + }, { "Command": "az oracle-database", "AzureServiceName": "Oracle.Database",