diff --git a/src/chaos/HISTORY.rst b/src/chaos/HISTORY.rst new file mode 100644 index 00000000000..b1bf2fd8d47 --- /dev/null +++ b/src/chaos/HISTORY.rst @@ -0,0 +1,38 @@ +.. :changelog: + +Release History +=============== + +1.0.0b1 ++++++++ +* Initial preview release of the ``chaos`` extension. Targets api-version + ``2026-05-01-preview`` of ``Microsoft.Chaos``. + + Command surface: + + * ``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``. + + Notable hand-written commands beyond the spec-derived surface: + + * ``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/README.md b/src/chaos/README.md new file mode 100644 index 00000000000..2e401dd061d --- /dev/null +++ b/src/chaos/README.md @@ -0,0 +1,115 @@ +# 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. The `az chaos setup` composite +command bootstraps a complete environment in a single step. + +## Installation + +```bash +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 "" \ + --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"]}' + +# 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 \ + --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..a076a95b069 --- /dev/null +++ b/src/chaos/azext_chaos/_help.py @@ -0,0 +1,681 @@ +# -------------------------------------------------------------------------------------------- +# 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-recommendation'); 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-recommendation'). + Use 'list' to see all discovered resources and 'show' to inspect + 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. 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' + 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'] = """ +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-recommendation'] = """ +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-recommendation --name MyWorkspace --resource-group MyRG + - name: Refresh recommendations without waiting for completion + text: > + 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-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-recommendation'. + Both commands invoke the same API operation (Workspaces_RefreshRecommendations) + with identical arguments and behavior. The canonical command name is + 'az chaos workspace refresh-recommendation'. +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-recommendation' 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-recommendation' 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-recommendation') 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-recommendation'. +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-recommendation' 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..8bdf8a26ae7 --- /dev/null +++ b/src/chaos/azext_chaos/_params.py @@ -0,0 +1,275 @@ +# -------------------------------------------------------------------------------------------- +# 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, + tags_type, +) +from azext_chaos._validators import ( + validate_scope, + validate_parameters_json, + validate_user_assigned, +) + + +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. 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', + 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) + 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-recommendation; + # register the same overrides so short flags resolve correctly. + for ctx_name in ('chaos workspace refresh-recommendation', + '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( + '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( + '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: + # ``--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 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: + 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( + '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}$ + # Consistent with 'run cancel' and 'run wait': --run-id is canonical, + # with -n/--name as aliases. + c.argument( + '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', '--name', '-n'], + 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( + '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..c015f1c6aab --- /dev/null +++ b/src/chaos/azext_chaos/_table_format.py @@ -0,0 +1,230 @@ +# -------------------------------------------------------------------------------------------- +# 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): + """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, + Error: _error + }""") + + +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. + + 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, """{ + ResourceName: properties.resourceName, + ResourceType: properties.resourceType, + Namespace: properties.namespace, + DiscoveredAt: properties.discoveredAt, + Id: name + }""") + + +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 new file mode 100644 index 00000000000..93cd5462fbb --- /dev/null +++ b/src/chaos/azext_chaos/_validators.py @@ -0,0 +1,147 @@ +# -------------------------------------------------------------------------------------------- +# 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 re + +from knack.util import CLIError + + +# 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. + + 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 _is_valid_scope(scope): + raise CLIError( + f"Invalid ARM resource ID: '{scope}'. Each scope must be a " + "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/'." + ) + + +_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 + 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: + return + + if value.startswith('@'): + file_path = value[1:] + if not os.path.isfile(file_path): + raise CLIError( + f"Parameters file not found: '{file_path}'. " + f"{_PARAMETERS_FORMAT_HINT}" + ) + with open(file_path, 'r', encoding='utf-8') as f: + try: + parsed = json.load(f) + except json.JSONDecodeError as exc: + raise CLIError( + 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: + parsed = json.loads(value) + except json.JSONDecodeError as exc: + raise CLIError( + 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/__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..783884507ae --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/__cmd_group.py @@ -0,0 +1,26 @@ +# -------------------------------------------------------------------------------------------- +# 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 Azure Chaos Studio resources. + + Create and manage Chaos Studio v2 workspaces, scenarios, scenario configurations, and runs for chaos engineering experiments. + """ + 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..528ade4e68e --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/discovered_resource/__cmd_group.py @@ -0,0 +1,26 @@ +# -------------------------------------------------------------------------------------------- +# 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): + """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 + + +__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..6473740bb1f --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/__cmd_group.py @@ -0,0 +1,26 @@ +# -------------------------------------------------------------------------------------------- +# 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 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 + + +__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..5c1c1f6785b --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/config/__cmd_group.py @@ -0,0 +1,26 @@ +# -------------------------------------------------------------------------------------------- +# 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 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 + + +__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..6d41bbb7075 --- /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 --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 --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..4e2d269f683 --- /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 --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..662125e1416 --- /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 scenario config execute --resource-group exampleRG --workspace-name exampleWorkspace --scenario-name ZoneDown-1.0 --name my-config + """ + + _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=["-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.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..518a2419178 --- /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 --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..0669cf3928b --- /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 --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..a3f8fb16f10 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/scenario/run/__cmd_group.py @@ -0,0 +1,26 @@ +# -------------------------------------------------------------------------------------------- +# 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 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 + + +__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..197d65118f0 --- /dev/null +++ b/src/chaos/azext_chaos/aaz/latest/chaos/workspace/__cmd_group.py @@ -0,0 +1,26 @@ +# -------------------------------------------------------------------------------------------- +# 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 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 + + +__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..a85eb17f31b --- /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 --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 + """ + + _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..93cc8048bc3 --- /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=["-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.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..0b3510ec883 --- /dev/null +++ b/src/chaos/azext_chaos/commands.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. +# -------------------------------------------------------------------------------------------- + +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, + setup_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_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, + ) + + # ── 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) + + +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, + 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). + 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 new file mode 100644 index 00000000000..74150ee811e --- /dev/null +++ b/src/chaos/azext_chaos/custom.py @@ -0,0 +1,1254 @@ +# -------------------------------------------------------------------------------------------- +# 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 +import uuid + +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 +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__) + +# 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, 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, timeout=timeout) + 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, timeout=_LRO_TIMEOUT_SECONDS): + """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, + timeout=timeout) + if location_url: + return _poll_location(cmd, location_url, retry_after, timeout=timeout) + return initial_response.json() if initial_response.text else None + + +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 _within_timeout(elapsed, timeout): + 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 {timeout}s.") + + +def _poll_location(cmd, poll_url, retry_after, timeout=_LRO_TIMEOUT_SECONDS): + """Poll Location URL until non-202 response.""" + elapsed = 0 + while _within_timeout(elapsed, timeout): + 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 {timeout}s.") + + +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(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-recommendation " + 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-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(cli_ctx, 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(cli_ctx, resource_group_name, workspace_name, path_suffix) + try: + 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: + 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-recommendation completed but the inner " + f"{operation_label} operation Failed ({detail}). " + f"{_INNER_LRO_FAILURE_HINT}" + ) + + +# ── 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 ───────────────────────────────────────────── + +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.cli_ctx, 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.cli_ctx, 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.cli_ctx, 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.cli_ctx, 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.cli_ctx, 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. 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): + 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.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 + + +def workspace_show_evaluation(cmd, resource_group_name, workspace_name): + """GET the latest workspace scenario-evaluation operation result.""" + url = _build_arm_url( + 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 + + +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.cli_ctx, 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.cli_ctx, 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.cli_ctx, 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.cli_ctx, 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 + + +# ── 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, 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. + + 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. + + ``--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 + propagation (retrying a few times); pass ``--skip-evaluation-wait`` to force + 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 ─────────────────────────────────────────────────────── + 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 _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 = ( + f"/subscriptions/{sub}/resourcegroups/{resource_group_name}" + f"?api-version={_RESOURCE_GROUP_API_VERSION}" + ) + try: + 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 + 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, + ) + 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 --workspace-name %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 --workspace-name {workspace_name} " + f"-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. +# 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}" + ) + + +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 _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) + 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 --workspace-name %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. + """ + + +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/custom_wait.py b/src/chaos/azext_chaos/custom_wait.py new file mode 100644 index 00000000000..1a692651dd2 --- /dev/null +++ b/src/chaos/azext_chaos/custom_wait.py @@ -0,0 +1,228 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""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() + + 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 + 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", "--name", "-n"], + 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..28dd3407df3 --- /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-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` | +| `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_command_registration.py b/src/chaos/azext_chaos/tests/latest/test_command_registration.py new file mode 100644 index 00000000000..b64ccb1ad8b --- /dev/null +++ b/src/chaos/azext_chaos/tests/latest/test_command_registration.py @@ -0,0 +1,285 @@ +# -------------------------------------------------------------------------------------------- +# 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_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( + 'Alias of `az chaos workspace refresh-recommendation`', + 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_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.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' + _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 config execute'), + 'sce-instance', + ) + self.assertEqual( + mock_loader.command_table.get('chaos scenario run wait'), + 'srw-instance', + ) + + +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..383ae030efc --- /dev/null +++ b/src/chaos/azext_chaos/tests/latest/test_custom_commands.py @@ -0,0 +1,1661 @@ +# -------------------------------------------------------------------------------------------- +# 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-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 ───────────────────────────────────────────── + +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-recommendation', 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-recommendation', 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-recommendation', 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-recommendation", 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_recommendation_help_exists(self): + from azext_chaos._help import helps + self.assertIn('chaos workspace refresh-recommendation', 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-recommendation`', + 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): + + # ``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') + 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.send_raw_request') + def test_inner_discovery_failed_raises(self, mock_send): + from azext_chaos.custom import _check_inner_lro + + mock_send.return_value = _make_response(json_body={ + "properties": { + "status": "Failed", + "errors": [{ + "errorCode": "ResourceDiscoveryPermissionError", + "errorMessage": "Status: 403 (Forbidden)", + }], + }, + }) + + with self.assertRaises(CLIError) as ctx: + _check_inner_lro(_make_cmd().cli_ctx, 'myRG', 'myWS', + "/discoveries/latest", "resource discovery") + + 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.send_raw_request') + def test_inner_evaluation_failed_raises(self, mock_send): + from azext_chaos.custom import _check_inner_lro + + mock_send.return_value = _make_response(json_body={ + "properties": { + "status": "Failed", + "errors": [{"errorCode": "X", "errorMessage": "boom"}], + } + }) + + with self.assertRaises(CLIError) as ctx: + _check_inner_lro(_make_cmd().cli_ctx, 'myRG', 'myWS', + "/evaluations/latest", "scenario evaluation") + + self.assertIn('scenario evaluation', str(ctx.exception)) + + @patch('azext_chaos.custom.send_raw_request') + 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 _check_inner_lro + + # 404 on /latest: empty text → silently treated as "no result yet". + mock_send.return_value = _make_response( + status_code=404, text="", json_body={}, + ) + # Should not raise. + _check_inner_lro(_make_cmd().cli_ctx, 'myRG', 'myWS', + "/discoveries/latest", "resource discovery") + + @patch('azext_chaos.custom.send_raw_request') + 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={"properties": {"status": "Succeeded"}}, + ) + # 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): + """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') + + +# ── 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_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 + 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): + """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', scopes, location='westus2', + ) + + 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', + ["/subscriptions/s/resourceGroups/rg"], + location='westus2', + 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', + ["/subscriptions/s/resourceGroups/rg"], + location='westus2') + + 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', + ["/subscriptions/s/resourceGroups/rg"], + location='westus2', + 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', 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.""" + + @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() + + +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 new file mode 100644 index 00000000000..236c38be1e8 --- /dev/null +++ b/src/chaos/azext_chaos/tests/latest/test_table_format.py @@ -0,0 +1,630 @@ +# -------------------------------------------------------------------------------------------- +# 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) + # 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 ( + 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]['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): + 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-recommendation', + '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-recommendation`' + 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) + + +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 new file mode 100644 index 00000000000..956bb0b2c73 --- /dev/null +++ b/src/chaos/azext_chaos/tests/latest/test_validators.py @@ -0,0 +1,180 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +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): + # 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", + "/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) + + 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() 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..49afeb84bb7 --- /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 = '1.0.0b1' + +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='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', + long_description=README + '\n\n' + HISTORY, + license='MIT', + classifiers=CLASSIFIERS, + packages=find_packages(), + install_requires=DEPENDENCIES, + package_data={'azext_chaos': ['azext_metadata.json']}, +) 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",