From 144e2df45d973cf5c60dcb6613c40d6e582dc534 Mon Sep 17 00:00:00 2001 From: krdhruva Date: Sat, 25 Jul 2026 17:28:05 +0530 Subject: [PATCH 1/9] Add az migrate runbook cmdlets - Initial Commit --- src/migrate/AGENTS.md | 73 + src/migrate/HISTORY.rst | 20 + src/migrate/azext_migrate/__init__.py | 5 + src/migrate/azext_migrate/_help.py | 1 + src/migrate/azext_migrate/_params.py | 2 +- src/migrate/azext_migrate/runbook/__init__.py | 4 + src/migrate/azext_migrate/runbook/_help.py | 450 +++++ .../azext_migrate/runbook/cmds/__init__.py | 4 + .../azext_migrate/runbook/cmds/definition.py | 151 ++ .../runbook/cmds/definition_step.py | 38 + .../runbook/cmds/definition_workstream.py | 27 + .../azext_migrate/runbook/cmds/execution.py | 240 +++ .../runbook/cmds/execution_step.py | 61 + .../azext_migrate/runbook/cmds/parameter.py | 56 + .../azext_migrate/runbook/cmds/runbook.py | 170 ++ src/migrate/azext_migrate/runbook/commands.py | 119 ++ .../azext_migrate/runbook/config_status.py | 119 ++ .../azext_migrate/runbook/constants.py | 79 + src/migrate/azext_migrate/runbook/deps.py | 43 + src/migrate/azext_migrate/runbook/models.py | 210 ++ src/migrate/azext_migrate/runbook/params.py | 302 +++ .../azext_migrate/runbook/transformers.py | 146 ++ .../azext_migrate/runbook/validators.py | 100 + .../runbook/visualize/__init__.py | 11 + .../azext_migrate/runbook/visualize/graph.py | 183 ++ .../runbook/visualize/renderer.py | 509 +++++ .../visualize/templates/runbook.html.tmpl | 436 +++++ .../runbook/visualize/viewmodel.py | 285 +++ src/migrate/azext_migrate/shared/__init__.py | 4 + .../azext_migrate/shared/arm_client.py | 212 ++ src/migrate/azext_migrate/shared/arm_ids.py | 34 + src/migrate/azext_migrate/shared/constants.py | 31 + src/migrate/azext_migrate/shared/errors.py | 59 + src/migrate/azext_migrate/shared/files.py | 287 +++ src/migrate/azext_migrate/shared/telemetry.py | 37 + .../tests/latest/runbook/__init__.py | 4 + .../test_runbook_show_and_list.yaml | 98 + .../latest/runbook/test_runbook_recording.py | 47 + .../latest/runbook/test_runbook_scenario.py | 136 ++ .../tests/latest/runbook/test_runbook_unit.py | 1723 +++++++++++++++++ src/migrate/docs/runbook-cli-design.md | 1118 +++++++++++ src/migrate/setup.py | 7 +- .../DownloadRunbookExecutionStatus.yml | 121 ++ .../GenerateRunbookExecutionDownloadUrl.yml | 19 + .../RunbookExecutions/GetRunbookExecution.yml | 19 + .../ListRunbookExecutions.yml | 19 + .../PatchRunbookExecution.yml | 25 + .../PerformRunbookExecutionAction.yml | 27 + .../ProvideRunbookExecutionStepApproval.yml | 27 + .../StartRunbookExecution.yml | 25 + .../UpdateRunbookExecutionStepStatus.yml | 27 + src/migrate/spec/Runbooks/AddRunbookStep.yml | 31 + src/migrate/spec/Runbooks/CreateRunbook.yml | 30 + src/migrate/spec/Runbooks/DeleteRunbook.yml | 19 + .../spec/Runbooks/DeleteRunbookStep.yml | 25 + .../spec/Runbooks/DownloadRunbookSpec.yml | 122 ++ .../spec/Runbooks/GenerateDownloadUrl.yml | 19 + src/migrate/spec/Runbooks/GetRunbook.yml | 19 + src/migrate/spec/Runbooks/ListRunbooks.yml | 19 + .../spec/Runbooks/MergeRunbookWorkstreams.yml | 29 + .../spec/Runbooks/RegenerateRunbook.yml | 19 + .../spec/Runbooks/SplitRunbookWorkstream.yml | 32 + .../spec/Runbooks/UpdateRunbookStep.yml | 27 + src/migrate/spec/Runbooks/runbookput.json | 8 + .../spec/az migrate runbooks cli - spec.md | 646 ++++++ .../spec/execution-tracking-example.json | 400 ++++ src/migrate/spec/runbook.json | 424 ++++ src/migrate/spec/user-inputs.json | 1680 ++++++++++++++++ 68 files changed, 11496 insertions(+), 3 deletions(-) create mode 100644 src/migrate/AGENTS.md create mode 100644 src/migrate/azext_migrate/runbook/__init__.py create mode 100644 src/migrate/azext_migrate/runbook/_help.py create mode 100644 src/migrate/azext_migrate/runbook/cmds/__init__.py create mode 100644 src/migrate/azext_migrate/runbook/cmds/definition.py create mode 100644 src/migrate/azext_migrate/runbook/cmds/definition_step.py create mode 100644 src/migrate/azext_migrate/runbook/cmds/definition_workstream.py create mode 100644 src/migrate/azext_migrate/runbook/cmds/execution.py create mode 100644 src/migrate/azext_migrate/runbook/cmds/execution_step.py create mode 100644 src/migrate/azext_migrate/runbook/cmds/parameter.py create mode 100644 src/migrate/azext_migrate/runbook/cmds/runbook.py create mode 100644 src/migrate/azext_migrate/runbook/commands.py create mode 100644 src/migrate/azext_migrate/runbook/config_status.py create mode 100644 src/migrate/azext_migrate/runbook/constants.py create mode 100644 src/migrate/azext_migrate/runbook/deps.py create mode 100644 src/migrate/azext_migrate/runbook/models.py create mode 100644 src/migrate/azext_migrate/runbook/params.py create mode 100644 src/migrate/azext_migrate/runbook/transformers.py create mode 100644 src/migrate/azext_migrate/runbook/validators.py create mode 100644 src/migrate/azext_migrate/runbook/visualize/__init__.py create mode 100644 src/migrate/azext_migrate/runbook/visualize/graph.py create mode 100644 src/migrate/azext_migrate/runbook/visualize/renderer.py create mode 100644 src/migrate/azext_migrate/runbook/visualize/templates/runbook.html.tmpl create mode 100644 src/migrate/azext_migrate/runbook/visualize/viewmodel.py create mode 100644 src/migrate/azext_migrate/shared/__init__.py create mode 100644 src/migrate/azext_migrate/shared/arm_client.py create mode 100644 src/migrate/azext_migrate/shared/arm_ids.py create mode 100644 src/migrate/azext_migrate/shared/constants.py create mode 100644 src/migrate/azext_migrate/shared/errors.py create mode 100644 src/migrate/azext_migrate/shared/files.py create mode 100644 src/migrate/azext_migrate/shared/telemetry.py create mode 100644 src/migrate/azext_migrate/tests/latest/runbook/__init__.py create mode 100644 src/migrate/azext_migrate/tests/latest/runbook/recordings/test_runbook_show_and_list.yaml create mode 100644 src/migrate/azext_migrate/tests/latest/runbook/test_runbook_recording.py create mode 100644 src/migrate/azext_migrate/tests/latest/runbook/test_runbook_scenario.py create mode 100644 src/migrate/azext_migrate/tests/latest/runbook/test_runbook_unit.py create mode 100644 src/migrate/docs/runbook-cli-design.md create mode 100644 src/migrate/spec/RunbookExecutions/DownloadRunbookExecutionStatus.yml create mode 100644 src/migrate/spec/RunbookExecutions/GenerateRunbookExecutionDownloadUrl.yml create mode 100644 src/migrate/spec/RunbookExecutions/GetRunbookExecution.yml create mode 100644 src/migrate/spec/RunbookExecutions/ListRunbookExecutions.yml create mode 100644 src/migrate/spec/RunbookExecutions/PatchRunbookExecution.yml create mode 100644 src/migrate/spec/RunbookExecutions/PerformRunbookExecutionAction.yml create mode 100644 src/migrate/spec/RunbookExecutions/ProvideRunbookExecutionStepApproval.yml create mode 100644 src/migrate/spec/RunbookExecutions/StartRunbookExecution.yml create mode 100644 src/migrate/spec/RunbookExecutions/UpdateRunbookExecutionStepStatus.yml create mode 100644 src/migrate/spec/Runbooks/AddRunbookStep.yml create mode 100644 src/migrate/spec/Runbooks/CreateRunbook.yml create mode 100644 src/migrate/spec/Runbooks/DeleteRunbook.yml create mode 100644 src/migrate/spec/Runbooks/DeleteRunbookStep.yml create mode 100644 src/migrate/spec/Runbooks/DownloadRunbookSpec.yml create mode 100644 src/migrate/spec/Runbooks/GenerateDownloadUrl.yml create mode 100644 src/migrate/spec/Runbooks/GetRunbook.yml create mode 100644 src/migrate/spec/Runbooks/ListRunbooks.yml create mode 100644 src/migrate/spec/Runbooks/MergeRunbookWorkstreams.yml create mode 100644 src/migrate/spec/Runbooks/RegenerateRunbook.yml create mode 100644 src/migrate/spec/Runbooks/SplitRunbookWorkstream.yml create mode 100644 src/migrate/spec/Runbooks/UpdateRunbookStep.yml create mode 100644 src/migrate/spec/Runbooks/runbookput.json create mode 100644 src/migrate/spec/az migrate runbooks cli - spec.md create mode 100644 src/migrate/spec/execution-tracking-example.json create mode 100644 src/migrate/spec/runbook.json create mode 100644 src/migrate/spec/user-inputs.json diff --git a/src/migrate/AGENTS.md b/src/migrate/AGENTS.md new file mode 100644 index 00000000000..00bfd9b780a --- /dev/null +++ b/src/migrate/AGENTS.md @@ -0,0 +1,73 @@ +# Principal Engineer Review Mode — migrate extension + +This file is a standing instruction for **any** agent doing coding work anywhere under +`src/migrate/`. Treat every change as if you are the **principal engineer** who must approve +the pull request. Do not merely make code work — make it the code a principal engineer would +sign off on. + +## Non-negotiable review discipline + +Before finishing ANY migrate task, rigorously self-review against these criteria and reject +your own work if it fails: + +1. **Simplicity** — Is this the simplest solution that fully solves the problem? Remove any + complexity that does not earn its place. +2. **Reuse first** — Prefer existing helpers, patterns, and abstractions over new ones. Search + before you write. (`shared/`, `runbook/`, existing `ArmClient`/`files` patterns.) +3. **Architecture fit** — The change must match the established structure (REST via `ArmClient`, + `shared/files.py` for archive/IO, `runbook/cmds/*` for command logic, `transformers.py` for + table shaping). No parallel or competing mechanisms. +4. **No speculative code** — Do not add constants, parameters, branches, or error handling for + cases that cannot occur or are unproven. Validate only at real system boundaries. +5. **No duplicate logic** — Collapse repeated iterate/parse/classify/format loops into a single + source of truth. Duplication is a defect. +6. **Root-cause fixes only** — Fix the underlying cause, never paper over a symptom. State the + root cause explicitly in your summary. +7. **Net code growth** — Prefer changes that remove more than they add. Justify every new + abstraction with a concrete, present-day need and a net-complexity benefit. +8. **Security by design** — Prefer designing hazards out (e.g. flatten to basename to eliminate + zip-slip) over runtime guards. Keep the OWASP Top 10 in mind for every I/O boundary. +9. **Maintenance score** — Rate the resulting code 1–10 for maintainability. Do not ship below + **9**. If below 9, keep simplifying. +10. **PR approval test** — Ask: "Would I approve this PR as principal engineer?" If not, revise. + +## Mandatory concluding deliverable + +Every non-trivial migrate change MUST end with a **10-point engineering review** covering: + +1. Selected design and why it won. +2. Alternatives considered and why they were rejected. +3. What existing code was reused. +4. What was refactored/consolidated. +5. Duplicate logic removed. +6. New abstractions introduced and their justification. +7. Net lines added vs. removed. +8. Remaining technical debt (with explicit `TODO(confirm)` where behavior is unverified). +9. Maintenance score (1–10) with rationale. +10. Why this is the simplest correct solution. + +## Verification gate (always run before declaring done) + +- `python -m pytest migrate/azext_migrate/tests/latest/runbook/test_runbook_unit.py -q` +- `python -m azdev style migrate` +- `python -m azdev linter migrate` (the trailing `ERROR: invalid git repo: None` is harmless) + +## Domain facts to preserve + +- Downloaded runbook archive members: + - `runbook.json` → the **definition** (`{"runbookSpec": {...}}`). + - `user-input(s).json` → the **parameters** (`{"runbookInputs": {...}}`). `definition download` + writes this alongside the definition (per-step `configurationStatus` is derived from it), but + table/CLI output (`show`, `visualize` grid) still renders the definition only. + - `derived-input(s).json` → same shape as user-inputs; **never downloaded/rendered** by any CLI. + It is distinguishable from user-inputs ONLY by filename, so it is excluded by name. +- Archive members are classified by **content**, not filename suffix (member naming varies across + services, e.g. `rb--spec.json` vs `runbook.json`). See `shared/files.py::_classify_archive` + as the single source of truth. +- **UpdateStep/AddStep `dependsOn` write contract (verified against live service):** each entry is a + System.Text.Json polymorphic `RunbookStepDependency`. The discriminator property is the verbatim + (non-camelCased) `"Mode"` whose value is the integer enum ordinal (`0` = step gate, + `1` = migration-entity gate), and it must appear first. A `--depends-on ` maps to + `{"Mode": 0, "stepId": ""}`. See `models.py::_depends_on_refs`. NOTE: the GET (read) model + differs — it emits `{"step": "", "mode": "migrationEntity"}` (property `step`, string `mode`). + Read/write are NOT symmetric; do not assume round-trip. diff --git a/src/migrate/HISTORY.rst b/src/migrate/HISTORY.rst index 19d5e0d478a..77b3172075e 100644 --- a/src/migrate/HISTORY.rst +++ b/src/migrate/HISTORY.rst @@ -2,6 +2,26 @@ Release History =============== +3.0.0b5 ++++++++++++++++ +* Add ``az migrate runbook`` commands (generate, show, list, update, + regenerate, delete, wait). +* Add ``az migrate runbook definition`` commands (show, download). +* Add ``az migrate runbook definition step`` commands (add, update, + remove) and ``az migrate runbook definition workstream`` commands + (split, merge). +* Add ``az migrate runbook execution`` commands (start, show, list, + pause, resume, cancel). +* Add ``az migrate runbook execution step`` commands (retry, approve, + complete). +* Add ``az migrate runbook parameter`` command (download). +* Add ``az migrate runbook definition visualize`` and + ``az migrate runbook execution visualize`` commands (self-contained, + offline HTML dependency graph). +* ``az migrate runbook execution show`` and ``execution visualize`` now + retrieve the per-execution ``status.json`` via a generated SAS URL + (``GenerateDownloadUrl``) instead of a direct ARM read. + 3.0.0b4 +++++++++++++++ * Fix edge case bugs in az migrate local replication init & new commands. diff --git a/src/migrate/azext_migrate/__init__.py b/src/migrate/azext_migrate/__init__.py index 65abfbf53f6..bf5e7a56370 100644 --- a/src/migrate/azext_migrate/__init__.py +++ b/src/migrate/azext_migrate/__init__.py @@ -36,11 +36,16 @@ def load_command_table(self, args): args=args ) load_command_table(self, args) + from azext_migrate.runbook.commands import ( + load_runbook_command_table) + load_runbook_command_table(self) return self.command_table def load_arguments(self, command): from azext_migrate._params import load_arguments load_arguments(self, command) + from azext_migrate.runbook.params import load_runbook_arguments + load_runbook_arguments(self, command) COMMAND_LOADER_CLS = MigrateCommandsLoader diff --git a/src/migrate/azext_migrate/_help.py b/src/migrate/azext_migrate/_help.py index d0b02d6d962..d17a77682b2 100644 --- a/src/migrate/azext_migrate/_help.py +++ b/src/migrate/azext_migrate/_help.py @@ -4,6 +4,7 @@ # -------------------------------------------------------------------------------------------- # coding=utf-8 from knack.help_files import helps # pylint: disable=unused-import +from azext_migrate.runbook import _help as _runbook_help # noqa: F401 helps['migrate'] = """ diff --git a/src/migrate/azext_migrate/_params.py b/src/migrate/azext_migrate/_params.py index 8f9dfbd3283..cfa0cbfeaa4 100644 --- a/src/migrate/azext_migrate/_params.py +++ b/src/migrate/azext_migrate/_params.py @@ -12,7 +12,7 @@ def load_arguments(self, _): project_name_type = CLIArgumentType( - options_list=['--project-name'], + options_list=['--project-name', '-p'], help='Name of the Azure Migrate project.', id_part='name' ) diff --git a/src/migrate/azext_migrate/runbook/__init__.py b/src/migrate/azext_migrate/runbook/__init__.py new file mode 100644 index 00000000000..34913fb394d --- /dev/null +++ b/src/migrate/azext_migrate/runbook/__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/migrate/azext_migrate/runbook/_help.py b/src/migrate/azext_migrate/runbook/_help.py new file mode 100644 index 00000000000..682ef788507 --- /dev/null +++ b/src/migrate/azext_migrate/runbook/_help.py @@ -0,0 +1,450 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +# coding=utf-8 +from knack.help_files import helps # pylint: disable=unused-import + + +helps['migrate runbook'] = """ + type: group + short-summary: Manage Azure Migrate runbooks. + long-summary: | + Commands to generate and manage Azure Migrate runbooks and their + executions. This command group is in preview and under active + development; additional subgroups and commands are added + incrementally. +""" + + +helps['migrate runbook show'] = """ + type: command + short-summary: Get the details of a runbook. + examples: + - name: Show the details of a runbook. + text: | + az migrate runbook show -g myRg --project-name myProject \\ + -n myRunbook +""" + + +helps['migrate runbook generate'] = """ + type: command + short-summary: Generate a runbook for a migration wave. + examples: + - name: Generate a runbook scoped to a wave. + text: | + az migrate runbook generate -g myRg --project-name myProject \\ + -n myRunbook --wave-name myWave + - name: Generate a runbook and return immediately. + text: | + az migrate runbook generate -g myRg --project-name myProject \\ + -n myRunbook --wave-name myWave --no-wait +""" + + +helps['migrate runbook list'] = """ + type: command + short-summary: List runbooks in a migrate project. + examples: + - name: List all runbooks in a project. + text: | + az migrate runbook list -g myRg --project-name myProject + - name: List runbooks filtered by wave and status. + text: | + az migrate runbook list -g myRg --project-name myProject \\ + --wave-name myWave --status InExecution +""" + + +helps['migrate runbook delete'] = """ + type: command + short-summary: Delete a runbook. + examples: + - name: Delete a runbook. + text: | + az migrate runbook delete -g myRg --project-name myProject \\ + -n myRunbook +""" + + +helps['migrate runbook update'] = """ + type: command + short-summary: Update editable runbook metadata. + examples: + - name: Update a runbook description. + text: | + az migrate runbook update -g myRg --project-name myProject \\ + -n myRunbook --description "Wave 1 cutover runbook" +""" + + +helps['migrate runbook regenerate'] = """ + type: command + short-summary: Regenerate a runbook from its current scope. + examples: + - name: Regenerate a runbook. + text: | + az migrate runbook regenerate -g myRg \\ + --project-name myProject -n myRunbook + - name: Regenerate a runbook and return immediately. + text: | + az migrate runbook regenerate -g myRg \\ + --project-name myProject -n myRunbook --no-wait +""" + + +helps['migrate runbook definition'] = """ + type: group + short-summary: View and download the contents of a runbook definition. +""" + + +helps['migrate runbook definition show'] = """ + type: command + short-summary: Show the definition (contents) of a runbook. + examples: + - name: Show a runbook definition. + text: | + az migrate runbook definition show -g myRg \\ + --project-name myProject -n myRunbook + - name: Show a single workstream in a runbook definition. + text: | + az migrate runbook definition show -g myRg \\ + --project-name myProject -n myRunbook \\ + --workstream-id myWorkstream +""" + + +helps['migrate runbook definition download'] = """ + type: command + short-summary: Download the runbook definition and documentation files. + examples: + - name: Download a runbook definition to the current directory. + text: | + az migrate runbook definition download -g myRg \\ + --project-name myProject -n myRunbook + - name: Download a runbook definition to a specific directory. + text: | + az migrate runbook definition download -g myRg \\ + --project-name myProject -n myRunbook \\ + --destination ./runbooks +""" + + +helps['migrate runbook definition visualize'] = """ + type: command + short-summary: Render the runbook definition as a self-contained HTML graph. + long-summary: > + Produces a single, offline HTML file (no external/CDN references) + showing the workstreams, steps and their dependency DAG. Step and + workstream names are HTML-encoded to prevent script injection. + examples: + - name: Visualize a runbook definition into the current directory. + text: | + az migrate runbook definition visualize -g myRg \\ + --project-name myProject -n myRunbook + - name: Visualize to a specific file and open it in the browser. + text: | + az migrate runbook definition visualize -g myRg \\ + --project-name myProject -n myRunbook \\ + --file ./runbook.html --open +""" + + +helps['migrate runbook definition step'] = """ + type: group + short-summary: Manage individual steps in a runbook definition. +""" + + +helps['migrate runbook definition step add'] = """ + type: command + short-summary: Add a step to the runbook definition. + examples: + - name: Add a manual step. + text: | + az migrate runbook definition step add -g myRg \\ + --project-name myProject -n myRunbook \\ + --step-type Manual --step-name "Verify cutover" + - name: Add an approval step. + text: | + az migrate runbook definition step add -g myRg \\ + --project-name myProject -n myRunbook \\ + --step-type Approval --step-name "Change approval" \\ + --approval-type Full + - name: Add a custom-script step that runs once. + text: | + az migrate runbook definition step add -g myRg \\ + --project-name myProject -n myRunbook \\ + --step-type CustomScript --step-name "Run script" \\ + --run-mode Once --execution-target Appliance +""" + + +helps['migrate runbook definition step update'] = """ + type: command + short-summary: Update a step in the runbook definition. + examples: + - name: Rename a step and change its dependencies. + text: | + az migrate runbook definition step update -g myRg \\ + --project-name myProject -n myRunbook \\ + --step-id step1 --step-name "New name" \\ + --depends-on step0 +""" + + +helps['migrate runbook definition step remove'] = """ + type: command + short-summary: Remove a step from the runbook definition. + examples: + - name: Remove a step by id. + text: | + az migrate runbook definition step remove -g myRg \\ + --project-name myProject -n myRunbook --step-id step1 +""" + + +helps['migrate runbook definition workstream'] = """ + type: group + short-summary: Manage workstreams in a runbook definition. +""" + + +helps['migrate runbook definition workstream split'] = """ + type: command + short-summary: Split a workstream into two workstreams. + examples: + - name: Move entities into a new workstream. + text: | + az migrate runbook definition workstream split -g myRg \\ + --project-name myProject -n myRunbook \\ + --source-workstream-id ws1 \\ + --new-workstream-name "Database tier" \\ + --entities-to-move entity1 entity2 +""" + + +helps['migrate runbook definition workstream merge'] = """ + type: command + short-summary: Merge two or more workstreams into a single workstream. + examples: + - name: Merge two workstreams. + text: | + az migrate runbook definition workstream merge -g myRg \\ + --project-name myProject -n myRunbook \\ + --source-workstream-ids ws1 ws2 \\ + --new-workstream-name "Combined tier" +""" + + +helps['migrate runbook execution'] = """ + type: group + short-summary: Manage runbook executions. +""" + + +helps['migrate runbook execution start'] = """ + type: command + short-summary: Start a new execution of a runbook. + examples: + - name: Start a runbook execution. + text: | + az migrate runbook execution start -g myRg \\ + --project-name myProject --runbook-name myRunbook + - name: Start without waiting for completion. + text: | + az migrate runbook execution start -g myRg \\ + --project-name myProject --runbook-name myRunbook --no-wait +""" + + +helps['migrate runbook execution show'] = """ + type: command + short-summary: Show (or watch) the status of a runbook execution. + examples: + - name: Show an execution's status. + text: | + az migrate runbook execution show -g myRg \\ + --project-name myProject --runbook-name myRunbook \\ + --execution-id myExecution + - name: Show the status of a single step. + text: | + az migrate runbook execution show -g myRg \\ + --project-name myProject --runbook-name myRunbook \\ + --execution-id myExecution --step-id step1 + - name: Auto-refresh the status table until it completes. + text: | + az migrate runbook execution show -g myRg \\ + --project-name myProject --runbook-name myRunbook \\ + --execution-id myExecution --watch +""" + + +helps['migrate runbook execution list'] = """ + type: command + short-summary: List the executions of a runbook. + examples: + - name: List a runbook's executions. + text: | + az migrate runbook execution list -g myRg \\ + --project-name myProject --runbook-name myRunbook +""" + + +helps['migrate runbook execution pause'] = """ + type: command + short-summary: Pause an in-progress runbook execution. + examples: + - name: Pause an execution. + text: | + az migrate runbook execution pause -g myRg \\ + --project-name myProject --runbook-name myRunbook \\ + --execution-id myExecution +""" + + +helps['migrate runbook execution resume'] = """ + type: command + short-summary: Resume a paused runbook execution. + examples: + - name: Resume an execution. + text: | + az migrate runbook execution resume -g myRg \\ + --project-name myProject --runbook-name myRunbook \\ + --execution-id myExecution +""" + + +helps['migrate runbook execution cancel'] = """ + type: command + short-summary: Cancel an in-progress or paused runbook execution. + long-summary: > + Cancellation is terminal; a cancelled execution cannot be + resumed. Start a new execution instead. + examples: + - name: Cancel an execution. + text: | + az migrate runbook execution cancel -g myRg \\ + --project-name myProject --runbook-name myRunbook \\ + --execution-id myExecution +""" + + +helps['migrate runbook execution visualize'] = """ + type: command + short-summary: Render an execution's status as a self-contained HTML graph. + long-summary: > + Produces a single, offline HTML file (no external/CDN references) + showing the dependency DAG annotated with per-step status. Step and + workstream names are HTML-encoded to prevent script injection. Use + --watch to regenerate the snapshot on an interval until the execution + reaches a terminal state. + examples: + - name: Visualize an execution's current status. + text: | + az migrate runbook execution visualize -g myRg \\ + --project-name myProject --runbook-name myRunbook \\ + --execution-id myExecution + - name: Visualize to a file, open it, and refresh until complete. + text: | + az migrate runbook execution visualize -g myRg \\ + --project-name myProject --runbook-name myRunbook \\ + --execution-id myExecution --file ./exec.html --open --watch +""" + + +helps['migrate runbook execution step'] = """ + type: group + short-summary: Act on individual steps within a runbook execution. +""" + + +helps['migrate runbook execution step retry'] = """ + type: command + short-summary: Retry a failed step in a runbook execution. + examples: + - name: Retry a failed step. + text: | + az migrate runbook execution step retry -g myRg \\ + --project-name myProject --runbook-name myRunbook \\ + --execution-id myExecution --step-id step1 +""" + + +helps['migrate runbook execution step approve'] = """ + type: command + short-summary: Approve an approval-type step during execution. + long-summary: > + For a Full approval step the whole step is approved. For a Partial + approval step, approve specific entities with --entities or every + ready entity with --all-ready. + examples: + - name: Approve a Full approval step. + text: | + az migrate runbook execution step approve -g myRg \\ + --project-name myProject --runbook-name myRunbook \\ + --execution-id myExecution --step-id step1 + - name: Approve specific entities of a Partial approval step. + text: | + az migrate runbook execution step approve -g myRg \\ + --project-name myProject --runbook-name myRunbook \\ + --execution-id myExecution --step-id step1 \\ + --entities entity1 entity2 + - name: Approve every ready entity of a Partial approval step. + text: | + az migrate runbook execution step approve -g myRg \\ + --project-name myProject --runbook-name myRunbook \\ + --execution-id myExecution --step-id step1 --all-ready +""" + + +helps['migrate runbook execution step complete'] = """ + type: command + short-summary: Mark a manual step as complete during execution. + examples: + - name: Complete a manual step with a comment. + text: | + az migrate runbook execution step complete -g myRg \\ + --project-name myProject --runbook-name myRunbook \\ + --execution-id myExecution --step-id step1 \\ + --comment "Verified cutover manually" +""" + + +helps['migrate runbook parameter'] = """ + type: group + short-summary: Manage the parameters file stored with a runbook. +""" + + +helps['migrate runbook parameter download'] = """ + type: command + short-summary: Download the runbook parameters file. + examples: + - name: Download the parameters file to the current directory. + text: | + az migrate runbook parameter download -g myRg \\ + --project-name myProject --runbook-name myRunbook + - name: Download the parameters file to a specific path. + text: | + az migrate runbook parameter download -g myRg \\ + --project-name myProject --runbook-name myRunbook \\ + --file ./params.json +""" + + +helps['migrate runbook wait'] = """ + type: command + short-summary: Wait until a runbook reaches a desired state. + examples: + - name: Wait until execution completes. + text: | + az migrate runbook wait -g myRg --project-name myProject \\ + -n myRunbook --custom "properties.state=='ExecutionSucceeded'" + - name: Wait until the runbook exists. + text: | + az migrate runbook wait -g myRg --project-name myProject \\ + -n myRunbook --created +""" diff --git a/src/migrate/azext_migrate/runbook/cmds/__init__.py b/src/migrate/azext_migrate/runbook/cmds/__init__.py new file mode 100644 index 00000000000..34913fb394d --- /dev/null +++ b/src/migrate/azext_migrate/runbook/cmds/__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/migrate/azext_migrate/runbook/cmds/definition.py b/src/migrate/azext_migrate/runbook/cmds/definition.py new file mode 100644 index 00000000000..25a4400fcfb --- /dev/null +++ b/src/migrate/azext_migrate/runbook/cmds/definition.py @@ -0,0 +1,151 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Runbook definition command implementations (show/download).""" + +import os + +from knack.log import get_logger +from azure.cli.core.azclierror import CLIInternalError +from azure.cli.core.commands.client_factory import get_subscription_id + +from azext_migrate.shared import arm_ids, files +from azext_migrate.shared.arm_client import ArmClient +from azext_migrate.runbook import config_status +from azext_migrate.runbook.visualize import graph as graph_mod +from azext_migrate.runbook.visualize import renderer +from azext_migrate.runbook.visualize import viewmodel + +logger = get_logger(__name__) + + +def _runbook_id(cmd, resource_group_name, project_name, runbook_name): + subscription_id = get_subscription_id(cmd.cli_ctx) + project = arm_ids.migrate_project_id( + subscription_id, resource_group_name, project_name) + return arm_ids.runbook_id(project, runbook_name) + + +def _download_url(cmd, resource_id): + body = ArmClient(cmd).post_action(resource_id, 'GenerateDownloadUrl') + url = files.extract_sas_url(body) + if not url: + raise CLIInternalError( + 'The service did not return a runbook download URL.') + return url + + +def _project_definition(definition, workstream_id, step_id): + """Filter the definition to a workstream and/or a single step.""" + workstreams = definition.get('workstreams', []) or [] + if workstream_id: + workstreams = [ + w for w in workstreams if w.get('id') == workstream_id] + if step_id: + for workstream in workstreams: + for step in workstream.get('steps', []) or []: + if step_id in (step.get('id'), step.get('stepId')): + return step + return {} + if workstream_id: + return workstreams[0] if workstreams else {} + return definition + + +def _load_definition(cmd, resource_id): + """Download the runbook archive and return an annotated definition. + + The archive holds both the definition (``runbookSpec``) and the + parameters (``runbookInputs``); the latter is used to stamp each step + with its computed ``configurationStatus`` so downstream table/grid/graph + rendering can show configuration readiness without re-fetching. + """ + zip_bytes = files.download_bytes(_download_url(cmd, resource_id)) + spec = files.read_spec_json(zip_bytes) or {} + definition = spec.get('runbookSpec', spec) + runbook_inputs = files.read_parameters_json(zip_bytes) + config_status.annotate(definition, runbook_inputs) + return definition + + +def _load_definition_from_file(spec_file, parameters_file=None): + """Load and annotate a runbook definition from local JSON files. + + ``spec_file`` is a runbook spec JSON (optionally wrapping the definition + under ``runbookSpec``). ``parameters_file`` is an optional parameters + JSON (a ``runbookInputs`` body, or a document that wraps it) used to + compute each step's ``configurationStatus``. Enables offline + rendering/testing without contacting the service. + """ + spec = files.read_json_file(spec_file) or {} + definition = spec.get('runbookSpec', spec) + runbook_inputs = None + if parameters_file: + params = files.read_json_file(parameters_file) + if isinstance(params, dict) and isinstance( + params.get('runbookInputs'), dict): + runbook_inputs = params['runbookInputs'] + else: + runbook_inputs = params + config_status.annotate(definition, runbook_inputs) + return definition + + +def show(cmd, resource_group_name, project_name, runbook_name, + workstream_id=None, step_id=None): + """Show the definition (contents) of a runbook.""" + resource_id = _runbook_id( + cmd, resource_group_name, project_name, runbook_name) + definition = _load_definition(cmd, resource_id) + return _project_definition(definition, workstream_id, step_id) + + +def download(cmd, resource_group_name, project_name, runbook_name, + destination=None): + """Download the runbook definition/documentation files to disk.""" + destination = destination or os.getcwd() + resource_id = _runbook_id( + cmd, resource_group_name, project_name, runbook_name) + zip_bytes = files.download_bytes(_download_url(cmd, resource_id)) + paths = files.extract_definition_files(zip_bytes, destination) + result = [] + for path in paths: + lower = os.path.basename(path).lower() + if lower.endswith('.md'): + kind = 'documentation' + elif 'input' in lower: + kind = 'parameters' + else: + kind = 'definition' + logger.warning( + 'Runbook %s file downloaded and saved to %s', kind, path) + result.append({'kind': kind, 'path': path}) + return result + + +def visualize(cmd, resource_group_name=None, project_name=None, + runbook_name=None, file=None, open_file=False, + from_file=None, parameters_file=None): + """Render the runbook definition as a self-contained HTML page.""" + if from_file: + definition = _load_definition_from_file(from_file, parameters_file) + name = runbook_name or os.path.splitext( + os.path.basename(from_file))[0] + else: + resource_id = _runbook_id( + cmd, resource_group_name, project_name, runbook_name) + definition = _load_definition(cmd, resource_id) + name = runbook_name + title = 'Runbook definition: %s' % name + dag = graph_mod.build_definition_graph(definition, title=title) + view = viewmodel.build_definition_view(definition, title=title) + html_text = renderer.render(dag, view=view) + target = files.resolve_output_path( + file, 'runbook-%s-definition.html' % name) + path = files.write_text(target, html_text) + logger.warning( + 'Runbook definition visualization saved to %s', path) + if open_file: + files.open_in_browser(path) + return {'path': path} diff --git a/src/migrate/azext_migrate/runbook/cmds/definition_step.py b/src/migrate/azext_migrate/runbook/cmds/definition_step.py new file mode 100644 index 00000000000..9df96312c51 --- /dev/null +++ b/src/migrate/azext_migrate/runbook/cmds/definition_step.py @@ -0,0 +1,38 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Runbook definition step commands (add/update/remove).""" + +from azext_migrate.runbook import models +from azext_migrate.runbook.cmds.definition import _runbook_id +from azext_migrate.shared.arm_client import ArmClient + + +def add(cmd, resource_group_name, project_name, runbook_name, step_type, + step_name, step_description=None, depends_on=None, + approval_type=None, run_mode=None, execution_target=None): + resource_id = _runbook_id( + cmd, resource_group_name, project_name, runbook_name) + body = models.build_add_step_body( + step_type, step_name, step_description=step_description, + depends_on=depends_on, approval_type=approval_type, + run_mode=run_mode, execution_target=execution_target) + return ArmClient(cmd).post_action(resource_id, 'AddStep', body) + + +def update(cmd, resource_group_name, project_name, runbook_name, step_id, + step_name=None, step_description=None, depends_on=None): + resource_id = _runbook_id( + cmd, resource_group_name, project_name, runbook_name) + body = models.build_update_step_body( + step_id, step_name=step_name, step_description=step_description, + depends_on=depends_on) + return ArmClient(cmd).post_action(resource_id, 'UpdateStep', body) + + +def remove(cmd, resource_group_name, project_name, runbook_name, step_id): + resource_id = _runbook_id( + cmd, resource_group_name, project_name, runbook_name) + body = models.build_delete_step_body(step_id) + return ArmClient(cmd).post_action(resource_id, 'DeleteStep', body) diff --git a/src/migrate/azext_migrate/runbook/cmds/definition_workstream.py b/src/migrate/azext_migrate/runbook/cmds/definition_workstream.py new file mode 100644 index 00000000000..bfa5319d382 --- /dev/null +++ b/src/migrate/azext_migrate/runbook/cmds/definition_workstream.py @@ -0,0 +1,27 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Runbook definition workstream commands (split/merge).""" + +from azext_migrate.runbook import models +from azext_migrate.runbook.cmds.definition import _runbook_id +from azext_migrate.shared.arm_client import ArmClient + + +def split(cmd, resource_group_name, project_name, runbook_name, + source_workstream_id, new_workstream_name, entities_to_move): + resource_id = _runbook_id( + cmd, resource_group_name, project_name, runbook_name) + body = models.build_split_workstream_body( + source_workstream_id, new_workstream_name, entities_to_move) + return ArmClient(cmd).post_action(resource_id, 'SplitWorkstream', body) + + +def merge(cmd, resource_group_name, project_name, runbook_name, + source_workstream_ids, new_workstream_name): + resource_id = _runbook_id( + cmd, resource_group_name, project_name, runbook_name) + body = models.build_merge_workstreams_body( + source_workstream_ids, new_workstream_name) + return ArmClient(cmd).post_action(resource_id, 'MergeWorkstreams', body) diff --git a/src/migrate/azext_migrate/runbook/cmds/execution.py b/src/migrate/azext_migrate/runbook/cmds/execution.py new file mode 100644 index 00000000000..7a864a32995 --- /dev/null +++ b/src/migrate/azext_migrate/runbook/cmds/execution.py @@ -0,0 +1,240 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Runbook execution command implementations (start/show/list + +pause/resume/cancel).""" + +import time +import uuid + +from knack.log import get_logger +from azure.cli.core.azclierror import CLIInternalError, ManualInterrupt +from azure.cli.core.commands.client_factory import get_subscription_id + +from azext_migrate.shared import arm_ids +from azext_migrate.shared.arm_client import ArmClient +from azext_migrate.shared import files +from azext_migrate.runbook import models, transformers +from azext_migrate.runbook.models import ExecutionAction +from azext_migrate.runbook.constants import EXECUTION_TERMINAL_STATES +from azext_migrate.runbook.visualize import graph as graph_mod +from azext_migrate.runbook.visualize import renderer +from azext_migrate.runbook.visualize import viewmodel + +logger = get_logger(__name__) + + +def _runbook_id(cmd, resource_group_name, project_name, runbook_name): + subscription_id = get_subscription_id(cmd.cli_ctx) + project = arm_ids.migrate_project_id( + subscription_id, resource_group_name, project_name) + return arm_ids.runbook_id(project, runbook_name) + + +def _execution_resource_id(cmd, resource_group_name, project_name, + runbook_name, execution): + runbook = _runbook_id( + cmd, resource_group_name, project_name, runbook_name) + return arm_ids.execution_id(runbook, execution) + + +def _status_download_url(cmd, resource_id): + body = ArmClient(cmd).post_action(resource_id, 'GenerateDownloadUrl') + url = files.extract_sas_url(body) + if not url: + raise CLIInternalError( + 'The service did not return an execution status download URL.') + return url + + +def _fetch_status(cmd, resource_id): + """Download and parse the per-execution ``status.json`` via SAS.""" + return files.read_status_json( + files.download_bytes(_status_download_url(cmd, resource_id))) + + +def start(cmd, resource_group_name, project_name, runbook_name, + no_wait=False): + """Start a new execution of a runbook.""" + execution = str(uuid.uuid4()) + resource_id = _execution_resource_id( + cmd, resource_group_name, project_name, runbook_name, execution) + body = models.build_start_execution_body() + logger.warning( + "Runbook execution started. Execution id: %s", execution) + return ArmClient(cmd).put(resource_id, body, no_wait=no_wait) + + +def list_(cmd, resource_group_name, project_name, runbook_name): + """List the executions of a runbook.""" + collection_id = _runbook_id( + cmd, resource_group_name, project_name, runbook_name) + '/executions' + return ArmClient(cmd).list(collection_id) + + +def show(cmd, resource_group_name, project_name, runbook_name, + execution_id, step_id=None, watch=False, interval=5): + """Show (optionally watch) a runbook execution's status.""" + resource_id = _execution_resource_id( + cmd, resource_group_name, project_name, runbook_name, execution_id) + if watch: + return _watch(cmd, resource_id, execution_id, step_id, interval) + return _project(_fetch_status(cmd, resource_id), step_id) + + +def pause(cmd, resource_group_name, project_name, runbook_name, + execution_id): + """Pause an in-progress execution.""" + return _perform( + cmd, resource_group_name, project_name, runbook_name, + execution_id, ExecutionAction.PAUSE) + + +def resume(cmd, resource_group_name, project_name, runbook_name, + execution_id): + """Resume a paused execution.""" + return _perform( + cmd, resource_group_name, project_name, runbook_name, + execution_id, ExecutionAction.RESUME) + + +def cancel(cmd, resource_group_name, project_name, runbook_name, + execution_id): + """Cancel an in-progress or paused execution.""" + return _perform( + cmd, resource_group_name, project_name, runbook_name, + execution_id, ExecutionAction.CANCEL) + + +def _perform(cmd, resource_group_name, project_name, runbook_name, + execution_id, action): + resource_id = _execution_resource_id( + cmd, resource_group_name, project_name, runbook_name, execution_id) + body = models.build_perform_action_body(action) + return ArmClient(cmd).post_action(resource_id, 'PerformAction', body) + + +def _project(execution, step_id): + """Filter an execution status to a single step when requested.""" + if not step_id or not isinstance(execution, dict): + return execution + status = execution.get('properties', execution) or {} + workstreams = status.get('workstreams') or [] + for workstream in workstreams: + for step in workstream.get('steps', []) or []: + if step_id in (step.get('id'), step.get('stepId')): + return step + for step in status.get('steps', []) or []: + if step_id in (step.get('id'), step.get('stepId')): + return step + return execution + + +def _terminal(execution): + status = execution.get('properties', execution) or {} + state = status.get('state') or status.get('status') + return bool(state) and state.lower() in EXECUTION_TERMINAL_STATES + + +def _watch(cmd, resource_id, execution_id, step_id, interval): + """Re-render the execution status table until a terminal state.""" + logger.warning( + "Watching execution '%s' (interval: %ss). Press Ctrl+C to stop.", + execution_id, interval) + try: + while True: + execution = _fetch_status(cmd, resource_id) + _render(execution) + if _terminal(execution): + logger.warning( + "Execution '%s' reached a terminal state.", + execution_id) + return _project(execution, step_id) + time.sleep(interval) + except KeyboardInterrupt: + raise ManualInterrupt('Watch cancelled by user.') + + +def _render(execution): + rows = transformers.execution_table(execution) + if not rows: + logger.warning("No step status available yet.") + return + for row in rows: + logger.warning( + "%s | %s | %s | %s | %s", + row.get('Id'), row.get('Step Name'), row.get('Step Status'), + row.get('Depends On'), row.get('Workload Progress')) + + +def visualize(cmd, resource_group_name=None, project_name=None, + runbook_name=None, execution_id=None, file=None, + open_file=False, watch=False, interval=5, from_file=None): + """Render an execution's status as a self-contained HTML graph.""" + name = runbook_name or 'runbook' + exec_label = execution_id or 'local' + target = files.resolve_output_path( + file, 'runbook-%s-execution-%s.html' % (name, exec_label)) + if from_file: + path = _write_visualization( + files.read_json_file(from_file), name, exec_label, target) + logger.warning( + 'Runbook execution visualization saved to %s', path) + if open_file: + files.open_in_browser(path) + return {'path': path} + resource_id = _execution_resource_id( + cmd, resource_group_name, project_name, runbook_name, execution_id) + if watch: + return _watch_visualize( + cmd, resource_id, runbook_name, execution_id, target, + interval, open_file) + path = _write_visualization( + _fetch_status(cmd, resource_id), name, exec_label, target) + logger.warning( + 'Runbook execution visualization saved to %s', path) + if open_file: + files.open_in_browser(path) + return {'path': path} + + +def _write_visualization(execution, runbook_name, execution_id, target, + refresh_interval=None): + title = 'Runbook execution: %s / %s' % (runbook_name, execution_id) + dag = graph_mod.build_execution_graph(execution, title=title) + view = viewmodel.build_execution_view(execution, title=title) + return files.write_text( + target, + renderer.render(dag, view=view, refresh_interval=refresh_interval)) + + +def _watch_visualize(cmd, resource_id, runbook_name, execution_id, target, + interval, open_file): + """Regenerate the HTML snapshot on an interval until a terminal state.""" + logger.warning( + "Watching execution '%s' (interval: %ss). Press Ctrl+C to stop.", + execution_id, interval) + opened = False + try: + while True: + execution = _fetch_status(cmd, resource_id) + terminal = _terminal(execution) + # While running, bake an auto-reload tag so the browser refreshes + # itself; on the final (terminal) snapshot omit it so it stops. + path = _write_visualization( + execution, runbook_name, execution_id, target, + refresh_interval=None if terminal else interval) + logger.warning( + 'Runbook execution visualization saved to %s', path) + if open_file and not opened: + files.open_in_browser(path) + opened = True + if terminal: + logger.warning( + "Execution '%s' reached a terminal state.", + execution_id) + return {'path': path} + time.sleep(interval) + except KeyboardInterrupt: + raise ManualInterrupt('Watch cancelled by user.') diff --git a/src/migrate/azext_migrate/runbook/cmds/execution_step.py b/src/migrate/azext_migrate/runbook/cmds/execution_step.py new file mode 100644 index 00000000000..698c692aef9 --- /dev/null +++ b/src/migrate/azext_migrate/runbook/cmds/execution_step.py @@ -0,0 +1,61 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Runbook execution step action commands (retry/approve/complete). + +Each command acts on a single step within an in-progress execution: + +* ``retry`` -> ``PerformAction`` with the integer ``RETRY`` (4) code. +* ``approve`` -> ``ProvideApproval`` with the ``"Approve"`` action string. +* ``complete`` -> ``UpdateStepStatus`` with the ``"Complete"`` action + string (a comment is required). +""" + +from knack.log import get_logger + +from azext_migrate.runbook import models +from azext_migrate.runbook.cmds.execution import _execution_resource_id +from azext_migrate.shared.arm_client import ArmClient + +logger = get_logger(__name__) + + +def retry(cmd, resource_group_name, project_name, runbook_name, + execution_id, step_id): + """Restart the execution of a failed step.""" + resource_id = _execution_resource_id( + cmd, resource_group_name, project_name, runbook_name, execution_id) + body = models.build_retry_step_body(step_id) + logger.warning("Step retry started.") + return ArmClient(cmd).post_action(resource_id, 'PerformAction', body) + + +def approve(cmd, resource_group_name, project_name, runbook_name, + execution_id, step_id, entities=None, all_ready=False): + """Provide approval for an approval-type step during execution. + + A Full approval step approves the whole step (no entities). A Partial + approval step approves either the supplied ``entities`` or, when + ``all_ready`` is set, every currently ready entity (an empty entity + list, which the service treats as approve-all-ready for the step). + """ + resource_id = _execution_resource_id( + cmd, resource_group_name, project_name, runbook_name, execution_id) + if all_ready: + entities = None + logger.info( + "Approving every ready entity for step '%s'.", step_id) + body = models.build_approve_step_body(step_id, entity_ids=entities) + logger.warning("Step approval recorded.") + return ArmClient(cmd).post_action(resource_id, 'ProvideApproval', body) + + +def complete(cmd, resource_group_name, project_name, runbook_name, + execution_id, step_id, comment): + """Mark a manual step as complete during execution.""" + resource_id = _execution_resource_id( + cmd, resource_group_name, project_name, runbook_name, execution_id) + body = models.build_complete_step_body(step_id, comment) + logger.warning("Step marked as complete.") + return ArmClient(cmd).post_action(resource_id, 'UpdateStepStatus', body) diff --git a/src/migrate/azext_migrate/runbook/cmds/parameter.py b/src/migrate/azext_migrate/runbook/cmds/parameter.py new file mode 100644 index 00000000000..6010f4b4476 --- /dev/null +++ b/src/migrate/azext_migrate/runbook/cmds/parameter.py @@ -0,0 +1,56 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Runbook parameters-file commands (download). + +The parameters file is delivered inside the same SAS-protected ZIP that +``GenerateDownloadUrl`` returns for the runbook definition; the archive +contains both the definition/spec document and the parameters document. +""" + +import os + +from knack.log import get_logger +from azure.cli.core.azclierror import CLIInternalError + +from azext_migrate.shared import files +from azext_migrate.runbook.cmds.definition import _download_url, _runbook_id + +logger = get_logger(__name__) + + +def _resolve_target(file, default_name): + """Resolve the ``--file`` argument to an absolute output path. + + ``--file`` may be omitted (write ``default_name`` into the current + directory), an existing directory (write ``default_name`` into it), or + a full file path. + """ + if not file: + return os.path.join(os.getcwd(), default_name) + target = os.path.abspath(file) + if os.path.isdir(target): + return os.path.join(target, default_name) + return target + + +def download(cmd, resource_group_name, project_name, runbook_name, + file=None): + """Download the runbook parameters file to disk.""" + resource_id = _runbook_id( + cmd, resource_group_name, project_name, runbook_name) + zip_bytes = files.download_bytes(_download_url(cmd, resource_id)) + found = files.extract_parameters_file(zip_bytes) + if not found: + raise CLIInternalError( + 'The downloaded archive did not contain a parameters file.') + default_name, data = found + target = _resolve_target(file, default_name) + parent = os.path.dirname(target) + if parent: + os.makedirs(parent, exist_ok=True) + with open(target, 'wb') as handle: + handle.write(data) + logger.warning('Parameters file downloaded and saved to %s', target) + return {'path': target} diff --git a/src/migrate/azext_migrate/runbook/cmds/runbook.py b/src/migrate/azext_migrate/runbook/cmds/runbook.py new file mode 100644 index 00000000000..4d01be69142 --- /dev/null +++ b/src/migrate/azext_migrate/runbook/cmds/runbook.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. +# -------------------------------------------------------------------------------------------- +"""Runbook command implementations (generate/show/list/update/ +regenerate/delete/wait).""" + +import time + +from knack.log import get_logger +from knack.util import CLIError +from azure.cli.core.commands.client_factory import get_subscription_id +from azext_migrate.shared import arm_ids +from azext_migrate.shared.arm_client import ArmClient +from azext_migrate.runbook import models + +logger = get_logger(__name__) + + +def _project_id(cmd, resource_group_name, project_name): + subscription_id = get_subscription_id(cmd.cli_ctx) + return arm_ids.migrate_project_id( + subscription_id, resource_group_name, project_name) + + +def _matches(runbook, wave_name, status): + props = runbook.get('properties', {}) or {} + if status and props.get('status') != status: + return False + if wave_name: + scope = props.get('scope', {}) or {} + wid = (scope.get('waveId', '') or '').rstrip('/') + if not wid.endswith('/waves/' + wave_name): + return False + return True + + +def generate(cmd, resource_group_name, project_name, runbook_name, + wave_name, no_wait=False): + """Generate (create) a runbook scoped to a wave.""" + project = _project_id(cmd, resource_group_name, project_name) + resource_id = arm_ids.runbook_id(project, runbook_name) + body = models.build_generate_body(models.wave_id(project, wave_name)) + return ArmClient(cmd).put(resource_id, body, no_wait=no_wait) + + +def show(cmd, resource_group_name, project_name, runbook_name): + """Get a single runbook.""" + project = _project_id(cmd, resource_group_name, project_name) + resource_id = arm_ids.runbook_id(project, runbook_name) + return ArmClient(cmd).get(resource_id) + + +def list_(cmd, resource_group_name, project_name, wave_name=None, + status=None): + """List runbooks, optionally filtered by wave and/or status.""" + project = _project_id(cmd, resource_group_name, project_name) + collection_id = project + '/runbooks' + items = ArmClient(cmd).list(collection_id) + return [rb for rb in items if _matches(rb, wave_name, status)] + + +def delete(cmd, resource_group_name, project_name, runbook_name, + no_wait=False): + """Delete a runbook.""" + project = _project_id(cmd, resource_group_name, project_name) + resource_id = arm_ids.runbook_id(project, runbook_name) + return ArmClient(cmd).delete(resource_id, no_wait=no_wait) + + +def update(cmd, resource_group_name, project_name, runbook_name, + description=None): + """Update editable runbook metadata (e.g. description).""" + project = _project_id(cmd, resource_group_name, project_name) + resource_id = arm_ids.runbook_id(project, runbook_name) + body = models.build_update_body(description) + return ArmClient(cmd).patch(resource_id, body) + + +def regenerate(cmd, resource_group_name, project_name, runbook_name, + no_wait=False): + """Regenerate a runbook from its current scope.""" + project = _project_id(cmd, resource_group_name, project_name) + resource_id = arm_ids.runbook_id(project, runbook_name) + return ArmClient(cmd).post_action( + resource_id, 'Regenerate', no_wait=no_wait) + + +def _provisioning_state(runbook): + props = (runbook or {}).get('properties', {}) or {} + return runbook.get('provisioningState') or \ + props.get('provisioningState') + + +def wait(cmd, resource_group_name, project_name, runbook_name, + created=False, updated=False, deleted=False, exists=False, + custom=None, interval=30, timeout=3600): + """Poll a runbook until a wait condition is met, logging progress.""" + from azure.cli.core.commands.arm import verify_property + from azure.cli.core.azclierror import ( + InvalidArgumentValueError, AzureResponseError) + + if not any([created, updated, deleted, exists, custom]): + raise InvalidArgumentValueError( + 'incorrect usage: --created | --updated | --deleted | ' + '--exists | --custom JMESPATH') + + project = _project_id(cmd, resource_group_name, project_name) + resource_id = arm_ids.runbook_id(project, runbook_name) + client = ArmClient(cmd) + + active = ', '.join(name for name, on in ( + ('created', created), ('updated', updated), ('deleted', deleted), + ('exists', exists), ('custom', custom)) if on) + logger.warning( + "Waiting for runbook '%s' [condition: %s, interval: %ss, " + "timeout: %ss].", runbook_name, active, interval, timeout) + + start = time.monotonic() + attempt = 0 + for _ in range(0, timeout, interval): + attempt += 1 + elapsed = int(time.monotonic() - start) + instance = client.get_or_none(resource_id) + + if instance is None: + if deleted: + logger.warning( + "Runbook '%s' is deleted (elapsed %ss, %s poll(s)).", + runbook_name, elapsed, attempt) + return None + logger.warning( + "Runbook '%s' not found yet (elapsed %ss, poll #%s, " + "next check in %ss).", + runbook_name, elapsed, attempt, interval) + time.sleep(interval) + continue + + if exists: + logger.warning( + "Runbook '%s' exists (elapsed %ss, %s poll(s)).", + runbook_name, elapsed, attempt) + return None + + state = _provisioning_state(instance) + norm = state.lower() if state else None + if norm == 'failed': + raise AzureResponseError( + "Runbook '%s' provisioning failed " + "(provisioningState=Failed)." % runbook_name) + if custom and bool(verify_property(instance, custom)): + logger.warning( + "Custom condition '%s' met (elapsed %ss, %s poll(s)).", + custom, elapsed, attempt) + return None + if (created or updated) and norm == 'succeeded': + logger.warning( + "Runbook '%s' provisioningState=Succeeded " + "(elapsed %ss, %s poll(s)).", + runbook_name, elapsed, attempt) + return None + + logger.warning( + "Still waiting for runbook '%s': provisioningState=%s " + "(elapsed %ss, poll #%s, next check in %ss).", + runbook_name, state or '(none)', elapsed, attempt, interval) + time.sleep(interval) + + raise CLIError( + 'Wait operation timed out after %ss.' % timeout) diff --git a/src/migrate/azext_migrate/runbook/commands.py b/src/migrate/azext_migrate/runbook/commands.py new file mode 100644 index 00000000000..a1b4106d192 --- /dev/null +++ b/src/migrate/azext_migrate/runbook/commands.py @@ -0,0 +1,119 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Command registration for the ``migrate runbook`` feature package.""" + +from azure.cli.core.commands import CliCommandType +from azext_migrate.runbook.transformers import ( + runbook_table, + definition_table, + execution_table, +) + + +def load_runbook_command_table(self): + runbook_cmds = CliCommandType( + operations_tmpl='azext_migrate.runbook.cmds.{}') + + with self.command_group( + 'migrate runbook', + custom_command_type=runbook_cmds, + is_preview=True) as g: + g.custom_command( + 'generate', 'runbook#generate', + supports_no_wait=True, + table_transformer=runbook_table) + g.custom_show_command( + 'show', 'runbook#show', + table_transformer=runbook_table) + g.custom_command( + 'list', 'runbook#list_', + table_transformer=runbook_table) + g.custom_command( + 'update', 'runbook#update', + table_transformer=runbook_table) + g.custom_command( + 'regenerate', 'runbook#regenerate', + supports_no_wait=True, + table_transformer=runbook_table) + g.custom_command( + 'delete', 'runbook#delete', + supports_no_wait=True, + confirmation=True) + g.custom_command('wait', 'runbook#wait') + + with self.command_group( + 'migrate runbook definition', + custom_command_type=runbook_cmds, + is_preview=True) as g: + g.custom_show_command( + 'show', 'definition#show', + table_transformer=definition_table) + g.custom_command('download', 'definition#download') + g.custom_command('visualize', 'definition#visualize') + + with self.command_group( + 'migrate runbook definition step', + custom_command_type=runbook_cmds, + is_preview=True) as g: + g.custom_command( + 'add', 'definition_step#add', + table_transformer=definition_table) + g.custom_command( + 'update', 'definition_step#update', + table_transformer=definition_table) + g.custom_command( + 'remove', 'definition_step#remove', + confirmation=True) + + with self.command_group( + 'migrate runbook definition workstream', + custom_command_type=runbook_cmds, + is_preview=True) as g: + g.custom_command( + 'split', 'definition_workstream#split', + table_transformer=definition_table) + g.custom_command( + 'merge', 'definition_workstream#merge', + table_transformer=definition_table) + + with self.command_group( + 'migrate runbook execution', + custom_command_type=runbook_cmds, + is_preview=True) as g: + g.custom_command( + 'start', 'execution#start', + supports_no_wait=True) + g.custom_show_command( + 'show', 'execution#show', + table_transformer=execution_table) + g.custom_command( + 'list', 'execution#list_', + table_transformer=execution_table) + g.custom_command('pause', 'execution#pause') + g.custom_command('resume', 'execution#resume') + g.custom_command( + 'cancel', 'execution#cancel', + confirmation=True) + g.custom_command('visualize', 'execution#visualize') + + with self.command_group( + 'migrate runbook execution step', + custom_command_type=runbook_cmds, + is_preview=True) as g: + g.custom_command( + 'retry', 'execution_step#retry', + table_transformer=execution_table) + g.custom_command( + 'approve', 'execution_step#approve', + table_transformer=execution_table) + g.custom_command( + 'complete', 'execution_step#complete', + table_transformer=execution_table) + + with self.command_group( + 'migrate runbook parameter', + custom_command_type=runbook_cmds, + is_preview=True) as g: + g.custom_command('download', 'parameter#download') diff --git a/src/migrate/azext_migrate/runbook/config_status.py b/src/migrate/azext_migrate/runbook/config_status.py new file mode 100644 index 00000000000..4d5b2ba9bd0 --- /dev/null +++ b/src/migrate/azext_migrate/runbook/config_status.py @@ -0,0 +1,119 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Compute the configuration status of a runbook definition step. + +A runbook is *shipped* as two documents inside the same download archive: + +* the **definition** (``runbookSpec``) — the immutable step graph, and +* the **parameters** (``runbookInputs``) — the per-step input *schema* plus + the customer-supplied *values*. + +A step is only runnable once every *required* input has a value. Required +inputs come in two scopes: + +* ``Appliance`` — one shared value stored at ``stepInputs[stepId][field]``. +* ``Entity`` — one value **per migration entity**, stored at + ``stepInputs[stepId].workloadOverrides[entityId][field]``. An entity-scope + field is only "set" when *every* entity on the step has a value for it. + +The status is one of: + +* ``Configured`` — all required inputs have values (or none are required), +* ``Partial (n/m)`` — some but not all required inputs have values, +* ``NotConfigured`` — no required input has a value, and +* ``Unknown`` — no parameters/schema available for the step (defensive). +""" + +CONFIGURED = 'Configured' +NOT_CONFIGURED = 'NotConfigured' +UNKNOWN = 'Unknown' + + +def _is_empty(value): + """Return True when ``value`` counts as "not provided".""" + if value is None: + return True + if isinstance(value, str): + return value.strip() == '' + if isinstance(value, (list, dict, tuple, set)): + return len(value) == 0 + return False + + +def _field_is_set(field, meta, step, step_inputs): + """Return True when a required input ``field`` has a value on the step.""" + scope = (meta.get('scope') if isinstance(meta, dict) else None) or \ + 'Appliance' + if scope == 'Entity': + overrides = step_inputs.get('workloadOverrides') or {} + entities = step.get('entities') or [] + if not entities: + return False + for entity_id in entities: + entity_values = overrides.get(entity_id) or {} + if _is_empty(entity_values.get(field)): + return False + return True + return not _is_empty(step_inputs.get(field)) + + +def compute(step, runbook_inputs): + """Return the configuration status string for a definition ``step``. + + ``runbook_inputs`` is the ``runbookInputs`` object from the parameters + document (with ``schema`` and ``stepInputs``). Returns :data:`UNKNOWN` + when the schema or inputs for the step are unavailable. + """ + step = step or {} + if not isinstance(runbook_inputs, dict): + return UNKNOWN + step_ref = step.get('stepRef') + step_id = step.get('stepId') or step.get('id') + schema = (runbook_inputs.get('schema') or {}).get(step_ref) + step_inputs = (runbook_inputs.get('stepInputs') or {}).get(step_id) + if not isinstance(schema, dict) or not isinstance(step_inputs, dict): + return UNKNOWN + + required = [ + (field, meta) for field, meta in schema.items() + if isinstance(meta, dict) and meta.get('required')] + if not required: + return CONFIGURED + + set_count = sum( + 1 for field, meta in required + if _field_is_set(field, meta, step, step_inputs)) + total = len(required) + if set_count == 0: + return NOT_CONFIGURED + if set_count == total: + return CONFIGURED + return 'Partial (%d/%d)' % (set_count, total) + + +def annotate(definition, runbook_inputs): + """Stamp ``configurationStatus`` onto every step of ``definition``. + + Mutates and returns ``definition`` in place so the table transformer and + the graph/grid can read ``step['configurationStatus']`` without needing + the parameters document threaded through them. + """ + if not isinstance(definition, dict): + return definition + workstreams = definition.get('workstreams') + if isinstance(workstreams, list): + for workstream in workstreams: + if isinstance(workstream, dict): + _annotate_steps(workstream.get('steps'), runbook_inputs) + _annotate_steps(definition.get('steps'), runbook_inputs) + return definition + + +def _annotate_steps(steps, runbook_inputs): + if not isinstance(steps, list): + return + for step in steps: + if isinstance(step, dict): + step['configurationStatus'] = compute(step, runbook_inputs) diff --git a/src/migrate/azext_migrate/runbook/constants.py b/src/migrate/azext_migrate/runbook/constants.py new file mode 100644 index 00000000000..f3a8c863007 --- /dev/null +++ b/src/migrate/azext_migrate/runbook/constants.py @@ -0,0 +1,79 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Runbook-feature constants (extends the shared api-version registry).""" + +from enum import Enum + +# Scope type used by CreateRunbook (generate). +SCOPE_TYPE_WAVE = "Wave" + +# Wave ARM id template (relative to the migrate project id). +WAVE_ID_TEMPLATE = "{project_id}/waves/{wave_name}" + +# Step types accepted by ``definition step add``. +STEP_TYPE_MANUAL = "Manual" +STEP_TYPE_APPROVAL = "Approval" +STEP_TYPE_CUSTOM_SCRIPT = "CustomScript" +STEP_TYPE_VALUES = [ + STEP_TYPE_MANUAL, + STEP_TYPE_APPROVAL, + STEP_TYPE_CUSTOM_SCRIPT, +] + +# Enumerations for the step parameter-sets. +APPROVAL_TYPE_VALUES = ["Partial", "Full"] +RUN_MODE_VALUES = ["Once", "PerEntity"] +EXECUTION_TARGET_VALUES = ["Appliance", "SourceVm", "TargetVm"] + +# String action codes sent by the execution-step action endpoints. +# ``PerformAction`` (retry) sends the integer ``ExecutionAction`` code, +# but ``ProvideApproval`` / ``UpdateStepStatus`` send these PascalCase +# strings. +STEP_ACTION_APPROVE = "Approve" +STEP_ACTION_COMPLETE = "Complete" + +# Opaque ``stepRef`` value the AddStep body binds per step type. +# TODO(confirm): replace with the authoritative per-type refs from the +# service spec; the current values mirror the step type as a stable stub. +STEP_REF_BY_TYPE = { + STEP_TYPE_MANUAL: STEP_TYPE_MANUAL, + STEP_TYPE_APPROVAL: STEP_TYPE_APPROVAL, + STEP_TYPE_CUSTOM_SCRIPT: STEP_TYPE_CUSTOM_SCRIPT, +} + + +class RunbookStatus(str, Enum): + """Runbook lifecycle status values (GetRunbook properties.status).""" + + GENERATING = "Generating" + NEW = "New" + READY_TO_START = "ReadyToStart" + IN_EXECUTION = "InExecution" + PAUSED = "Paused" + COMPLETED = "Completed" + FAILED = "Failed" + + +# Ordered choices for the ``--status`` filter on ``runbook list``. +RUNBOOK_STATUS_VALUES = [member.value for member in RunbookStatus] + + +# Telemetry fault types for this feature. +RUNBOOK_ARM_ERROR = "RUNBOOK_ARM_ERROR" +RUNBOOK_VALIDATION_ERROR = "RUNBOOK_VALIDATION_ERROR" +RUNBOOK_FILE_ERROR = "RUNBOOK_FILE_ERROR" +RUNBOOK_VISUALIZE_ERROR = "RUNBOOK_VISUALIZE_ERROR" + +# Terminal execution states that stop a ``--watch`` polling loop. +# TODO(confirm): reconcile with the service status.json enum; these +# cover the observed/expected terminal states case-insensitively. +EXECUTION_TERMINAL_STATES = frozenset({ + "succeeded", + "executionsucceeded", + "completed", + "failed", + "canceled", + "cancelled", +}) diff --git a/src/migrate/azext_migrate/runbook/deps.py b/src/migrate/azext_migrate/runbook/deps.py new file mode 100644 index 00000000000..30b75bc26d1 --- /dev/null +++ b/src/migrate/azext_migrate/runbook/deps.py @@ -0,0 +1,43 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Shared dependency-resolution helpers for runbook definition/execution. + +A runbook step declares its upstream steps through two sibling lists: + +* ``prerequisite`` — hard ordering constraints, and +* ``dependsOn`` — soft/gate dependencies (e.g. approval gates). + +Both are lists of objects shaped like ``{"step": "", "mode": ...}`` +(and, defensively, plain id strings). The dependency graph and the table +projection must consider *both* lists, so the merge logic lives here in one +place rather than being duplicated (and drifting) across modules. +""" + + +def _dep_id(dep): + """Extract the referenced step id from one dependency entry.""" + if isinstance(dep, dict): + return dep.get('step') or dep.get('stepId') + if dep: + return str(dep) + return None + + +def merged_dep_ids(step): + """Return the ordered, de-duplicated upstream step ids for ``step``. + + Merges the ``prerequisite`` and ``dependsOn`` lists (in that order), + dropping blanks and duplicates while preserving first-seen order. + """ + step = step or {} + ids = [] + seen = set() + for key in ('prerequisite', 'dependsOn'): + for dep in step.get(key) or []: + dep_id = _dep_id(dep) + if dep_id and dep_id not in seen: + seen.add(dep_id) + ids.append(dep_id) + return ids diff --git a/src/migrate/azext_migrate/runbook/models.py b/src/migrate/azext_migrate/runbook/models.py new file mode 100644 index 00000000000..6b8a1a208ba --- /dev/null +++ b/src/migrate/azext_migrate/runbook/models.py @@ -0,0 +1,210 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Request/response body builders for the runbook feature.""" + +from enum import IntEnum + +from azext_migrate.runbook.constants import ( + SCOPE_TYPE_WAVE, + WAVE_ID_TEMPLATE, + STEP_REF_BY_TYPE, + STEP_ACTION_APPROVE, + STEP_ACTION_COMPLETE, +) + + +class ExecutionAction(IntEnum): + """Service ``RunbookExecutionAction`` enum (0-based ordinal). + + ``PerformAction`` sends the integer code; ``ProvideApproval`` / + ``UpdateStepStatus`` send the string member name. + """ + + START = 0 + PAUSE = 1 + RESUME = 2 + CANCEL = 3 + RETRY = 4 + COMPLETE = 5 + FAIL = 6 + SKIP = 7 + APPROVE = 8 + REJECT = 9 + + +def wave_id(project_id, wave_name): + """Build a wave ARM id relative to the migrate project id.""" + return WAVE_ID_TEMPLATE.format( + project_id=project_id, wave_name=wave_name) + + +def build_generate_body(wave_resource_id): + """Build the CreateRunbook (PUT) body scoped to a wave. + + The CreateRunbook write model binds the scope in PascalCase + (``ScopeType``/``WaveId``). The GET read model echoes the same + values in camelCase, but the create payload must use PascalCase. + """ + return { + "properties": { + "scope": { + "ScopeType": SCOPE_TYPE_WAVE, + "WaveId": wave_resource_id, + } + } + } + + +def build_update_body(description=None): + """Build the runbook update (PATCH) body for editable metadata.""" + properties = {} + if description is not None: + properties["description"] = description + return {"properties": properties} + + +# Service ``RunbookStepDependencyMode`` ordinal for a step-gate dependency +# (enum member ``Step`` == 0), used as the System.Text.Json ``"Mode"`` +# polymorphic discriminator value on each ``dependsOn`` entry. +_DEPENDENCY_MODE_STEP = 0 + + +def _depends_on_refs(depends_on): + """Normalize ``--depends-on`` step ids into service dependency objects. + + The service models each ``dependsOn`` entry as a polymorphic + ``RunbookStepDependency`` that System.Text.Json discriminates on a + verbatim ``"Mode"`` property whose value is the integer enum ordinal + (``0`` = step gate). A ``--depends-on`` step id is a step-gate + dependency, serialized as ``{"Mode": 0, "stepId": ""}`` with the + discriminator first. Entries already shaped as dicts pass through. + """ + refs = [] + for dep in depends_on or []: + if isinstance(dep, dict): + refs.append(dep) + elif dep: + refs.append({"Mode": _DEPENDENCY_MODE_STEP, "stepId": dep}) + return refs + + +def build_add_step_body(step_type, step_name, step_description=None, + depends_on=None, approval_type=None, + run_mode=None, execution_target=None): + """Build the AddStep POST body for a single definition step. + + ``step_type`` selects the ``stepRef`` binding; the approval and + custom-script parameter-sets are absorbed as optional properties so + the same builder serves every step kind. + """ + body = { + "stepName": step_name, + "displayName": step_name, + "stepRef": STEP_REF_BY_TYPE.get(step_type, step_type), + "migrationEntityIds": [], + "dependsOn": _depends_on_refs(depends_on), + } + if step_description is not None: + body["description"] = step_description + if approval_type is not None: + body["approvalType"] = approval_type + if run_mode is not None: + body["runMode"] = run_mode + if execution_target is not None: + body["executionTarget"] = execution_target + return body + + +def build_update_step_body(step_id, step_name=None, step_description=None, + depends_on=None): + """Build the UpdateStep POST body; only provided fields are sent.""" + body = {"stepId": step_id} + if step_name is not None: + body["displayName"] = step_name + if step_description is not None: + body["description"] = step_description + if depends_on is not None: + body["dependsOn"] = _depends_on_refs(depends_on) + return body + + +def build_delete_step_body(step_id): + """Build the DeleteStep POST body.""" + return {"stepId": step_id} + + +def build_split_workstream_body(source_workstream_id, new_workstream_name, + entities_to_move): + """Build the SplitWorkstream POST body.""" + return { + "sourceWorkstreamId": source_workstream_id, + "stepIds": [], + "migrationEntityIds": entities_to_move or [], + "newWorkstreamName": new_workstream_name, + } + + +def build_merge_workstreams_body(source_workstream_ids, new_workstream_name): + """Build the MergeWorkstreams POST body. + + ``source_workstream_ids`` serializes as the ``workstreamId`` array. + """ + return { + "workstreamId": source_workstream_ids or [], + "newWorkstreamName": new_workstream_name, + } + + +def build_start_execution_body(): + """Build the StartRunbookExecution (PUT) body.""" + return {"properties": {}} + + +def build_perform_action_body(action, target_id=None, entity_ids=None): + """Build the PerformAction POST body (integer action code).""" + return { + "action": int(action), + "targetId": target_id or "", + "migrationEntityIds": entity_ids or [], + } + + +def build_retry_step_body(step_id, entity_ids=None): + """Build the PerformAction POST body to retry a failed step. + + Retry reuses ``PerformAction`` with the integer ``RETRY`` (4) code and + the step id as the ``targetId``. + """ + return build_perform_action_body( + ExecutionAction.RETRY, target_id=step_id, entity_ids=entity_ids) + + +def build_approve_step_body(step_id, entity_ids=None): + """Build the ProvideApproval POST body for an approval step. + + ``ProvideApproval`` sends the PascalCase ``"Approve"`` action string. + ``migrationEntityIds`` carries the per-entity approvals for a Partial + step; a Full step (or ``--all-ready``) sends an empty list. + """ + return { + "action": STEP_ACTION_APPROVE, + "targetId": step_id, + "migrationEntityIds": entity_ids or [], + } + + +def build_complete_step_body(step_id, comment, entity_ids=None): + """Build the UpdateStepStatus POST body to complete a manual step. + + ``UpdateStepStatus`` sends the PascalCase ``"Complete"`` action string. + ``comment`` is required by the service to record who/why the step was + completed (captured in ``status.json``). + """ + return { + "action": STEP_ACTION_COMPLETE, + "targetId": step_id, + "migrationEntityIds": entity_ids or [], + "comment": comment, + } diff --git a/src/migrate/azext_migrate/runbook/params.py b/src/migrate/azext_migrate/runbook/params.py new file mode 100644 index 00000000000..376f89576a5 --- /dev/null +++ b/src/migrate/azext_migrate/runbook/params.py @@ -0,0 +1,302 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Argument declarations for the ``migrate runbook`` command group.""" + +from azure.cli.core.commands.parameters import ( + resource_group_name_type, + get_enum_type, +) +from azext_migrate.runbook.constants import ( + RUNBOOK_STATUS_VALUES, + STEP_TYPE_VALUES, + APPROVAL_TYPE_VALUES, + RUN_MODE_VALUES, + EXECUTION_TARGET_VALUES, +) +from azext_migrate.runbook.validators import ( + validate_generate, + validate_step_add, + validate_step_approve, + validate_step_complete, + validate_definition_visualize, + validate_execution_visualize, +) + + +def load_runbook_arguments(self, _): + with self.argument_context('migrate runbook') as c: + c.argument('resource_group_name', resource_group_name_type) + c.argument( + 'project_name', + options_list=['--project-name', '-p'], + help='Name of the Azure Migrate project.') + c.argument( + 'runbook_name', + options_list=['--name', '-n', '--runbook-name'], + help='Name of the runbook.') + + with self.argument_context('migrate runbook generate') as c: + c.argument( + 'wave_name', + options_list=['--wave-name'], + validator=validate_generate, + help='Name of the wave to generate the runbook from ' + '(required).') + + with self.argument_context('migrate runbook update') as c: + c.argument( + 'description', + options_list=['--description'], + help='Updated description for the runbook.') + + with self.argument_context('migrate runbook list') as c: + c.argument( + 'wave_name', + options_list=['--wave-name'], + help='Filter runbooks by wave name.') + c.argument( + 'status', + options_list=['--status'], + arg_type=get_enum_type(RUNBOOK_STATUS_VALUES), + help='Filter runbooks by lifecycle status.') + + with self.argument_context('migrate runbook definition show') as c: + c.argument( + 'workstream_id', + options_list=['--workstream-id'], + help='Limit the output to a single workstream.') + c.argument( + 'step_id', + options_list=['--step-id'], + help='Limit the output to a single step.') + + with self.argument_context('migrate runbook definition download') as c: + c.argument( + 'destination', + options_list=['--destination'], + help='Directory to save the downloaded files to ' + '(default: current directory).') + + with self.argument_context( + 'migrate runbook definition visualize') as c: + c.argument( + 'file', options_list=['--file'], + help='Path to write the HTML file to. May be a file path or a ' + 'directory (default: current directory).') + c.argument( + 'open_file', options_list=['--open'], action='store_true', + help='Open the generated HTML file in the default browser.') + c.argument( + 'from_file', options_list=['--from-file'], + validator=validate_definition_visualize, + help='Render a local runbook spec JSON file instead of ' + 'downloading from the service (offline testing). When ' + 'set, the resource group/project/runbook name are ' + 'optional.') + c.argument( + 'parameters_file', options_list=['--parameters-file'], + help='Optional local parameters JSON file used with ' + '--from-file to compute per-step configuration status.') + + with self.argument_context( + 'migrate runbook definition step add') as c: + c.argument( + 'step_type', options_list=['--step-type'], required=True, + arg_type=get_enum_type(STEP_TYPE_VALUES), + validator=validate_step_add, + help='Kind of step to add.') + c.argument( + 'step_name', options_list=['--step-name'], required=True, + help='Display name for the step.') + c.argument( + 'step_description', options_list=['--step-description'], + help='Optional description for the step.') + c.argument( + 'depends_on', options_list=['--depends-on'], nargs='*', + help='Space-separated step ids this step depends on.') + c.argument( + 'approval_type', options_list=['--approval-type'], + arg_type=get_enum_type(APPROVAL_TYPE_VALUES), + help='Approval mode (required when --step-type is Approval).') + c.argument( + 'run_mode', options_list=['--run-mode'], + arg_type=get_enum_type(RUN_MODE_VALUES), + help='Run mode (only valid when --step-type is CustomScript).') + c.argument( + 'execution_target', options_list=['--execution-target'], + arg_type=get_enum_type(EXECUTION_TARGET_VALUES), + help='Execution target (only valid when --step-type is ' + 'CustomScript).') + + with self.argument_context( + 'migrate runbook definition step update') as c: + c.argument( + 'step_id', options_list=['--step-id'], required=True, + help='Id of the step to update.') + c.argument( + 'step_name', options_list=['--step-name'], + help='Updated display name for the step.') + c.argument( + 'step_description', options_list=['--step-description'], + help='Updated description for the step.') + c.argument( + 'depends_on', options_list=['--depends-on'], nargs='*', + help='Space-separated step ids this step depends on.') + + with self.argument_context( + 'migrate runbook definition step remove') as c: + c.argument( + 'step_id', options_list=['--step-id'], required=True, + help='Id of the step to remove.') + + with self.argument_context( + 'migrate runbook definition workstream split') as c: + c.argument( + 'source_workstream_id', + options_list=['--source-workstream-id'], required=True, + help='Id of the workstream to split.') + c.argument( + 'new_workstream_name', + options_list=['--new-workstream-name'], required=True, + help='Display name for the new workstream.') + c.argument( + 'entities_to_move', options_list=['--entities-to-move'], + nargs='+', required=True, + help='Space-separated migration entity ids to move into the ' + 'new workstream.') + + with self.argument_context( + 'migrate runbook definition workstream merge') as c: + c.argument( + 'source_workstream_ids', + options_list=['--source-workstream-ids'], nargs='+', + required=True, + help='Space-separated ids of the workstreams to merge.') + c.argument( + 'new_workstream_name', + options_list=['--new-workstream-name'], required=True, + help='Display name for the merged workstream.') + + with self.argument_context( + 'migrate runbook execution show') as c: + c.argument( + 'execution_id', options_list=['--execution-id'], required=True, + help='Id of the runbook execution.') + c.argument( + 'step_id', options_list=['--step-id'], + help='Limit the output to a single step.') + c.argument( + 'watch', options_list=['--watch'], action='store_true', + help='Re-render the status table on an interval until the ' + 'execution reaches a terminal state.') + c.argument( + 'interval', options_list=['--interval'], type=int, + help='Refresh interval in seconds for --watch (default: 5).') + + with self.argument_context( + 'migrate runbook execution pause') as c: + c.argument( + 'execution_id', options_list=['--execution-id'], required=True, + help='Id of the runbook execution.') + + with self.argument_context( + 'migrate runbook execution resume') as c: + c.argument( + 'execution_id', options_list=['--execution-id'], required=True, + help='Id of the runbook execution.') + + with self.argument_context( + 'migrate runbook execution cancel') as c: + c.argument( + 'execution_id', options_list=['--execution-id'], required=True, + help='Id of the runbook execution.') + + with self.argument_context( + 'migrate runbook execution visualize') as c: + c.argument( + 'execution_id', options_list=['--execution-id'], + validator=validate_execution_visualize, + help='Id of the runbook execution.') + c.argument( + 'file', options_list=['--file'], + help='Path to write the HTML file to. May be a file path or a ' + 'directory (default: current directory).') + c.argument( + 'open_file', options_list=['--open'], action='store_true', + help='Open the generated HTML file in the default browser.') + c.argument( + 'watch', options_list=['--watch'], action='store_true', + help='Regenerate the HTML snapshot on an interval until the ' + 'execution reaches a terminal state.') + c.argument( + 'interval', options_list=['--interval'], type=int, + help='Refresh interval in seconds for --watch (default: 5).') + c.argument( + 'from_file', options_list=['--from-file'], + help='Render a local execution status JSON file instead of ' + 'downloading from the service (offline testing). When ' + 'set, the resource group/project/runbook/execution id ' + 'are optional.') + + with self.argument_context( + 'migrate runbook execution step') as c: + c.argument( + 'execution_id', options_list=['--execution-id'], required=True, + help='Id of the runbook execution.') + c.argument( + 'step_id', options_list=['--step-id'], required=True, + help='Id of the step to act on.') + + with self.argument_context( + 'migrate runbook execution step approve') as c: + c.argument( + 'entities', options_list=['--entities'], nargs='+', + validator=validate_step_approve, + help='Space-separated migration entity ids to approve ' + '(Partial approval steps only).') + c.argument( + 'all_ready', options_list=['--all-ready'], + action='store_true', + help='Approve every currently ready entity (Partial approval ' + 'steps only). Mutually exclusive with --entities.') + + with self.argument_context( + 'migrate runbook execution step complete') as c: + c.argument( + 'comment', options_list=['--comment'], required=True, + validator=validate_step_complete, + help='Comment recording who/why the manual step was ' + 'completed (required).') + + with self.argument_context('migrate runbook parameter download') as c: + c.argument( + 'file', options_list=['--file'], + help='Path to write the parameters file to. May be a file ' + 'path or a directory (default: current directory).') + + with self.argument_context('migrate runbook wait') as c: + c.argument( + 'created', options_list=['--created'], action='store_true', + help='Wait until provisioningState reaches Succeeded.') + c.argument( + 'updated', options_list=['--updated'], action='store_true', + help='Wait until provisioningState reaches Succeeded after ' + 'an update.') + c.argument( + 'deleted', options_list=['--deleted'], action='store_true', + help='Wait until the runbook no longer exists.') + c.argument( + 'exists', options_list=['--exists'], action='store_true', + help='Wait until the runbook exists.') + c.argument( + 'custom', options_list=['--custom'], + help="Wait until a JMESPath condition is met, e.g. " + "\"properties.state=='ExecutionSucceeded'\".") + c.argument( + 'interval', options_list=['--interval'], type=int, + help='Polling interval in seconds (default: 30).') + c.argument( + 'timeout', options_list=['--timeout'], type=int, + help='Maximum wait time in seconds (default: 3600).') diff --git a/src/migrate/azext_migrate/runbook/transformers.py b/src/migrate/azext_migrate/runbook/transformers.py new file mode 100644 index 00000000000..7062a4988cc --- /dev/null +++ b/src/migrate/azext_migrate/runbook/transformers.py @@ -0,0 +1,146 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Table (``--output table``) transformers for runbook commands.""" + +from collections import OrderedDict + +from azext_migrate.runbook import deps as dep_utils + +# Placeholder shown for the "Applications" column, which the runbook +# definition does not currently model (steps carry entities, not apps). +_APPLICATIONS_PLACEHOLDER = '-' + + +def runbook_table(result): + """Project a runbook (or a list of runbooks) into table rows.""" + if isinstance(result, list): + return [_runbook_row(item) for item in result] + return _runbook_row(result) + + +def _runbook_row(item): + item = item or {} + props = item.get('properties', {}) if isinstance(item, dict) else {} + return OrderedDict([ + ('Name', item.get('name')), + ('State', props.get('state')), + ('ProvisioningState', props.get('provisioningState')), + ]) + + +def definition_table(result): + """Project a runbook definition into one step row per definition step.""" + rows = [] + if isinstance(result, dict) and result.get('workstreams') is not None: + for workstream in result.get('workstreams') or []: + rows.extend(_step_rows(workstream)) + elif isinstance(result, dict) and result.get('steps') is not None: + rows.extend(_step_rows(result)) + elif isinstance(result, dict) and _looks_like_step(result): + rows.append(_step_row(result)) + return rows + + +def _looks_like_step(step): + """True when a dict carries step-identifying keys (single-step show).""" + return any(step.get(key) for key in + ('stepId', 'id', 'displayName', 'stepName')) + + +def _step_rows(workstream): + workstream = workstream or {} + workstream_id = workstream.get('id') + return [_step_row(step, workstream_id) + for step in workstream.get('steps', []) or []] + + +def _step_row(step, workstream_id=None): + step = step or {} + return OrderedDict([ + ('Workstream Id', workstream_id), + ('Step Id', step.get('stepId') or step.get('id')), + ('Step Name', step.get('displayName') or step.get('stepName')), + ('Depends On', ' '.join(dep_utils.merged_dep_ids(step))), + ('Configuration Status', step.get('configurationStatus')), + ('Workloads', len(step.get('entities') or [])), + ('Applications', _APPLICATIONS_PLACEHOLDER), + ]) + + +def execution_table(result): + """Project a runbook execution status into one row per step.""" + rows = [] + status = _execution_status(result) + if isinstance(status, dict) and status.get('workstreams') is not None: + for workstream in status.get('workstreams') or []: + rows.extend(_exec_step_rows(workstream)) + elif isinstance(status, dict) and status.get('steps') is not None: + rows.extend(_exec_step_rows(status)) + elif isinstance(status, dict) and status: + rows.append(_exec_step_row(status)) + return rows + + +def _execution_status(result): + """Unwrap an execution resource to its status document.""" + if (isinstance(result, dict) + and result.get('properties') is not None + and result.get('workstreams') is None + and result.get('steps') is None): + return result.get('properties') or {} + return result or {} + + +def _exec_step_rows(workstream): + workstream = workstream or {} + return [_exec_step_row(step) + for step in workstream.get('steps', []) or []] + + +def _exec_step_row(step): + step = step or {} + return OrderedDict([ + ('Id', step.get('id') or step.get('stepId')), + ('Step Name', step.get('displayName') or step.get('stepName')), + ('Step Status', + step.get('status') or step.get('stepStatus') or step.get('state')), + ('Depends On', _format_depends_on(step.get('dependsOn'))), + ('Workload Progress', _workload_progress(step)), + ]) + + +def _format_depends_on(deps): + """Render a step's ``dependsOn`` as a space-separated list of step ids. + + Handles both the execution ``status.json`` shape (a list of objects + ``{"step": "", "mode": ...}``) and a plain list of id strings. + """ + if not deps: + return '' + ids = [] + for dep in deps: + if isinstance(dep, dict): + ids.append(dep.get('step') or dep.get('stepId') or '') + elif dep: + ids.append(str(dep)) + return ' '.join(dep_id for dep_id in ids if dep_id) + + +def _workload_progress(step): + """Summarize per-entity progress from ``entityExecutions``. + + Falls back to an explicit ``workloadProgress`` scalar when present. + """ + progress = step.get('workloadProgress') + if progress is not None: + return progress + entities = step.get('entityExecutions') + if not entities: + return None + total = len(entities) + completed = sum( + 1 for entity in entities + if str((entity or {}).get('state', '')).lower() == 'completed') + return '%d/%d completed' % (completed, total) diff --git a/src/migrate/azext_migrate/runbook/validators.py b/src/migrate/azext_migrate/runbook/validators.py new file mode 100644 index 00000000000..05d9e8d2481 --- /dev/null +++ b/src/migrate/azext_migrate/runbook/validators.py @@ -0,0 +1,100 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Command validators for the runbook feature.""" + +from azure.cli.core.azclierror import ( + RequiredArgumentMissingError, + InvalidArgumentValueError, +) +from azext_migrate.runbook.constants import ( + STEP_TYPE_APPROVAL, + STEP_TYPE_CUSTOM_SCRIPT, +) + + +def validate_generate(namespace): + """Ensure ``runbook generate`` has a source wave.""" + if not getattr(namespace, 'wave_name', None): + raise RequiredArgumentMissingError( + "--wave-name is required to generate a runbook.") + + +def validate_step_add(namespace): + """Enforce the ``definition step add`` parameter-set rules. + + * ``--approval-type`` is required for (and only valid with) + ``--step-type Approval``. + * ``--run-mode`` / ``--execution-target`` are valid only with + ``--step-type CustomScript``. + """ + step_type = getattr(namespace, 'step_type', None) + approval_type = getattr(namespace, 'approval_type', None) + run_mode = getattr(namespace, 'run_mode', None) + execution_target = getattr(namespace, 'execution_target', None) + + if step_type == STEP_TYPE_APPROVAL and not approval_type: + raise RequiredArgumentMissingError( + "--approval-type is required when --step-type is Approval.") + if step_type != STEP_TYPE_APPROVAL and approval_type: + raise InvalidArgumentValueError( + "--approval-type is only valid when --step-type is Approval.") + if step_type != STEP_TYPE_CUSTOM_SCRIPT and ( + run_mode or execution_target): + raise InvalidArgumentValueError( + "--run-mode and --execution-target are only valid when " + "--step-type is CustomScript.") + + +def validate_step_approve(namespace): + """Enforce the ``execution step approve`` parameter-set rules. + + ``--entities`` (per-entity approval) and ``--all-ready`` (approve every + ready entity) are mutually exclusive. Both apply only to Partial + approval steps; the service rejects them for Full/non-approval steps. + """ + entities = getattr(namespace, 'entities', None) + all_ready = getattr(namespace, 'all_ready', None) + if entities and all_ready: + raise InvalidArgumentValueError( + "--entities and --all-ready cannot be used together.") + + +def validate_step_complete(namespace): + """Ensure ``execution step complete`` has the required comment.""" + if not getattr(namespace, 'comment', None): + raise RequiredArgumentMissingError( + "--comment is required to complete a manual step.") + + +def _require_runbook_identity(namespace): + """Raise unless the full runbook identity (rg/project/name) is set.""" + missing = [] + if not getattr(namespace, 'resource_group_name', None): + missing.append('--resource-group/-g') + if not getattr(namespace, 'project_name', None): + missing.append('--project-name/-p') + if not getattr(namespace, 'runbook_name', None): + missing.append('--name/-n') + if missing: + raise RequiredArgumentMissingError( + "%s required unless --from-file is used." + % ', '.join(missing)) + + +def validate_definition_visualize(namespace): + """Require the runbook identity unless rendering a local ``--from-file``.""" + if getattr(namespace, 'from_file', None): + return + _require_runbook_identity(namespace) + + +def validate_execution_visualize(namespace): + """Require execution identity unless rendering a local ``--from-file``.""" + if getattr(namespace, 'from_file', None): + return + _require_runbook_identity(namespace) + if not getattr(namespace, 'execution_id', None): + raise RequiredArgumentMissingError( + "--execution-id is required unless --from-file is used.") diff --git a/src/migrate/azext_migrate/runbook/visualize/__init__.py b/src/migrate/azext_migrate/runbook/visualize/__init__.py new file mode 100644 index 00000000000..b3f4ff5bfad --- /dev/null +++ b/src/migrate/azext_migrate/runbook/visualize/__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. +# -------------------------------------------------------------------------------------------- +"""Client-side (non-REST) rendering of runbook definitions/executions. + +The visualize package turns a runbook definition or execution JSON +document into a single, self-contained, offline HTML dependency graph. +It is stdlib-only and performs no I/O beyond the caller writing the +returned HTML to disk. +""" diff --git a/src/migrate/azext_migrate/runbook/visualize/graph.py b/src/migrate/azext_migrate/runbook/visualize/graph.py new file mode 100644 index 00000000000..8a778baf3b3 --- /dev/null +++ b/src/migrate/azext_migrate/runbook/visualize/graph.py @@ -0,0 +1,183 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Transform a runbook definition/execution JSON document into a DAG model. + +This module is **data only** — it contains no HTML and performs no I/O. It +parses the runbook JSON into an in-memory directed acyclic graph (nodes + +edges), validates it (cycle detection, dangling ``dependsOn`` handling) and +computes a stable topological layering so the renderer can lay the graph out +deterministically. Keeping the graph shape free of HTML makes it trivial to +unit-test. +""" + +from knack.log import get_logger +from azure.cli.core.azclierror import InvalidArgumentValueError + +from azext_migrate.runbook import deps as dep_utils + +logger = get_logger(__name__) + +NODE_TYPE_STEP = 'step' + + +class Node: + """A single step in the runbook dependency graph.""" + + # pylint: disable=too-few-public-methods,too-many-arguments + def __init__(self, node_id, name, node_type=NODE_TYPE_STEP, + group=None, status=None, layer=0, ref=None): + self.id = node_id + self.name = name + self.type = node_type + self.group = group + self.status = status + self.layer = layer + self.ref = ref + + def __repr__(self): + return ( + "Node(id=%r, name=%r, group=%r, status=%r, layer=%r)" + % (self.id, self.name, self.group, self.status, self.layer)) + + +class Edge: + """A ``dependsOn`` dependency: ``source`` must finish before ``target``.""" + + # pylint: disable=too-few-public-methods + def __init__(self, source, target): + self.source = source + self.target = target + + def __repr__(self): + return "Edge(source=%r, target=%r)" % (self.source, self.target) + + +class Graph: + """A layered DAG of runbook steps.""" + + # pylint: disable=too-few-public-methods + def __init__(self, title, nodes, edges): + self.title = title + self.nodes = nodes + self.edges = edges + + @property + def layer_count(self): + return (max((n.layer for n in self.nodes), default=-1) + 1) + + +def _step_id(step): + return step.get('id') or step.get('stepId') or step.get('name') + + +def _step_name(step): + return (step.get('displayName') or step.get('name') + or step.get('stepName') or _step_id(step) or 'step') + + +def _step_status(step): + status = step.get('status') or step.get('state') + if isinstance(status, dict): + status = status.get('state') or status.get('status') + # Definition steps carry no execution state; fall back to the computed + # configuration status so the definition DAG colours by readiness. + return status or step.get('configurationStatus') + + +def _iter_steps(document): + """Yield ``(step, workstream_name)`` pairs from a definition/execution. + + Handles both the ``workstreams[].steps[]`` shape and a flat + ``steps[]`` shape, and unwraps an execution ``properties`` envelope. + """ + root = document + if isinstance(root, dict) and isinstance(root.get('properties'), dict): + merged = dict(root) + merged.update(root['properties']) + root = merged + if not isinstance(root, dict): + return + workstreams = root.get('workstreams') or [] + for workstream in workstreams: + if not isinstance(workstream, dict): + continue + ws_name = (workstream.get('displayName') + or workstream.get('name') or workstream.get('id')) + for step in workstream.get('steps', []) or []: + if isinstance(step, dict): + yield step, ws_name + for step in root.get('steps', []) or []: + if isinstance(step, dict): + yield step, None + + +def _build_graph(document, title): + nodes = [] + node_by_id = {} + for step, ws_name in _iter_steps(document): + node_id = _step_id(step) + if not node_id or node_id in node_by_id: + continue + node = Node( + node_id, _step_name(step), group=ws_name, + status=_step_status(step), ref=step.get('stepRef')) + nodes.append(node) + node_by_id[node_id] = node + + edges = [] + dependencies = {node.id: [] for node in nodes} + for step, _ in _iter_steps(document): + node_id = _step_id(step) + if node_id not in node_by_id: + continue + for dep_id in dep_utils.merged_dep_ids(step): + if dep_id not in node_by_id: + logger.warning( + "Step '%s' depends on unknown step '%s'; ignoring the " + "dangling dependency.", node_id, dep_id) + continue + edges.append(Edge(dep_id, node_id)) + dependencies[node_id].append(dep_id) + + _assign_layers(nodes, node_by_id, dependencies) + return Graph(title, nodes, edges) + + +def _assign_layers(nodes, node_by_id, dependencies): + """Compute a topological layering (Kahn's algorithm), raising on cycles.""" + remaining = dict(dependencies) + resolved = set() + order = list(node_by_id) + progressed = True + while remaining and progressed: + progressed = False + ready = [ + node_id for node_id in order + if node_id in remaining + and all(dep in resolved for dep in remaining[node_id])] + for node_id in ready: + deps = dependencies[node_id] + layer = 0 + for dep in deps: + layer = max(layer, node_by_id[dep].layer + 1) + node_by_id[node_id].layer = layer + resolved.add(node_id) + del remaining[node_id] + progressed = True + if remaining: + raise InvalidArgumentValueError( + 'The runbook dependency graph contains a cycle involving steps: ' + + ', '.join(sorted(remaining))) + nodes.sort(key=lambda n: (n.layer, n.id)) + + +def build_definition_graph(definition, title='Runbook definition'): + """Build the DAG for a runbook definition document.""" + return _build_graph(definition or {}, title) + + +def build_execution_graph(execution, title='Runbook execution'): + """Build the DAG for a runbook execution document (status-annotated).""" + return _build_graph(execution or {}, title) diff --git a/src/migrate/azext_migrate/runbook/visualize/renderer.py b/src/migrate/azext_migrate/runbook/visualize/renderer.py new file mode 100644 index 00000000000..13f354b9326 --- /dev/null +++ b/src/migrate/azext_migrate/runbook/visualize/renderer.py @@ -0,0 +1,509 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Render a runbook view into a self-contained, offline HTML file. + +This is the security-critical stage: **every** user-controlled value (step +names, workstream names, statuses, dependency labels) is passed through +:func:`html.escape` before it is substituted into the markup, which is the +mandatory XSS guard. The emitted HTML embeds all styles/scripts inline and +references no external/CDN resources, so it is fully offline and makes no +outbound network calls. + +The document shows two views of the same runbook: + +* a **grid** (portal-style, grouped by workstream) — the default view, and +* a **diagram** (SVG dependency DAG) — an optional view toggled in-page. + +The tiny inline toggle script runs entirely locally (no network), preserving +the offline/air-gapped guarantee. +""" + +import datetime +import html +import os +import re +import string + +from azext_migrate.runbook.visualize import viewmodel + +_TEMPLATE_PATH = os.path.join( + os.path.dirname(__file__), 'templates', 'runbook.html.tmpl') + +# SVG layout constants (deterministic, so output is stable for tests). +_MARGIN = 24 +_NODE_W = 220 +_NODE_H = 56 +_COL_GAP = 80 +_ROW_GAP = 24 +# Workstream swimlane band metrics. +_BAND_LABEL_H = 26 +_BAND_GAP = 18 + +# Execution ``state`` vocabulary, in legend display order. +_EXECUTION_LEGEND = ( + ('Completed', '#6bb700'), + ('InProgress', '#2899f5'), + ('Blocked', '#ffaa44'), + ('Failed', '#d13438'), + ('NotStarted', '#8a8886'), +) + +# Definition configuration-status vocabulary, in legend display order. +_DEFINITION_LEGEND = ( + ('Configured', '#6bb700'), + ('Partial', '#ffaa44'), + ('NotConfigured', '#d13438'), + ('Unknown', '#8a8886'), +) + +_PROGRESS_RE = re.compile(r'(\d+)\s*/\s*(\d+)') + + +def _esc(value): + """HTML-escape a possibly-``None`` user value (the XSS guard).""" + if value is None: + return '' + return html.escape(str(value), quote=True) + + +def _status_class(status): + """Map a status to a CSS-safe class suffix (first word, alnum only).""" + if not status: + return '' + token = str(status).strip().split(' ', 1)[0] + token = re.sub(r'[^A-Za-z0-9_-]', '', token) + return ' status-%s' % token if token else '' + + +def _workstream_order(graph): + """Group nodes into workstream swimlanes, preserving first appearance.""" + order = [] + by_ws = {} + for node in graph.nodes: + name = node.group or 'Ungrouped' + if name not in by_ws: + by_ws[name] = [] + order.append(name) + by_ws[name].append(node) + return [(name, by_ws[name]) for name in order] + + +def _layout(graph): + """Lay steps out in workstream swimlanes with dependency columns. + + The horizontal axis is the (global) dependency layer so ``dependsOn`` + edges always flow left-to-right; the vertical axis groups steps into + per-workstream bands. Returns ``(positions, bands, width, height)`` where + ``bands`` is a list of ``(name, top, height, count)`` tuples. + """ + positions = {} + bands = [] + width = _MARGIN * 2 + graph.layer_count * _NODE_W \ + + max(graph.layer_count - 1, 0) * _COL_GAP + y = _MARGIN + for name, nodes in _workstream_order(graph): + band_top = y + content_top = band_top + _BAND_LABEL_H + rows_per_layer = {} + max_rows = 0 + for node in nodes: + row = rows_per_layer.get(node.layer, 0) + rows_per_layer[node.layer] = row + 1 + node_x = _MARGIN + node.layer * (_NODE_W + _COL_GAP) + node_y = content_top + row * (_NODE_H + _ROW_GAP) + positions[node.id] = (node_x, node_y) + max_rows = max(max_rows, row + 1) + band_height = _BAND_LABEL_H + max_rows * (_NODE_H + _ROW_GAP) + bands.append((name, band_top, band_height, len(nodes))) + y = band_top + band_height + _BAND_GAP + height = y + _MARGIN - _BAND_GAP + return positions, bands, width, height + + +def _svg(graph): + if not graph.nodes: + return '

This runbook has no steps to display.

' + + positions, bands, width, height = _layout(graph) + parts = [ + '' + % (width, height, width, height)] + + for name, top, band_height, count in bands: + parts.append( + '' + '' + 'Workstream: %s (%d)' + % (_MARGIN / 2, top, width - _MARGIN, band_height, + _MARGIN / 2 + 12, top + 16, _esc(name), count)) + + for edge in graph.edges: + if edge.source not in positions or edge.target not in positions: + continue + sx, sy = positions[edge.source] + tx, ty = positions[edge.target] + x1, y1 = sx + _NODE_W, sy + _NODE_H / 2 + x2, y2 = tx, ty + _NODE_H / 2 + midx = (x1 + x2) / 2 + parts.append( + '' + % (x1, y1, midx, y1, midx, y2, x2, y2)) + + for node in graph.nodes: + x, y = positions[node.id] + status_class = _status_class(node.status) + sub = node.ref or node.status + parts.append('' % status_class) + parts.append( + '' + % (x, y, _NODE_W, _NODE_H)) + parts.append( + '%s' + % (x + 12, y + 24, _esc(node.name))) + if sub: + parts.append( + '%s' + % (x + 12, y + 42, _esc(sub))) + parts.append('') + + parts.append('') + return '\n'.join(parts) + + +def _legend(graph, view): + """Render the status legend appropriate to the view kind.""" + if view is not None and view.kind == viewmodel.KIND_DEFINITION: + statuses = _DEFINITION_LEGEND + else: + statuses = _EXECUTION_LEGEND + if not any(node.status for node in graph.nodes): + return '' + items = ''.join( + '%s' % (color, _esc(label)) + for label, color in statuses) + return '
%s
' % items + + +# --------------------------------------------------------------------------- +# Portal-style grid +# --------------------------------------------------------------------------- + +def _summary_cards(view): + if view is None or not view.summary: + return '' + stats = ''.join( + '
%s' + '%s
' % (_esc(value), _esc(label)) + for label, value in view.summary) + return '
%s
' % stats + + +def _dep_cell(step): + if not step.deps: + return '-' + chips = ''.join( + '%s' % _esc(dep) for dep in step.deps) + return '%s' % chips + + +def _progress_bar(text): + match = _PROGRESS_RE.search(text or '') + if not match: + return '' + done, total = int(match.group(1)), int(match.group(2)) + pct = int(round(100.0 * done / total)) if total else 0 + return ('
' + % max(0, min(100, pct))) + + +def _definition_row(index, step): + """Render one definition step as a clickable grid row (portal-style).""" + ref = ('%s' % _esc(step.step_ref) + if step.step_ref else '') + dep = ', '.join(step.deps) if step.deps else '-' + status = step.status or 'Unknown' + return ( + '
' + '
' + '' + '%s%s
' + '
%s
' + '
%s
' + '
%s
' + '
' + % (index, _esc(step.name), ref, _status_class(status), _esc(status), + _esc(dep), _esc(step.workloads))) + + +def _iter_indexed_steps(view): + """Yield ``(index, workstream_name, step)`` in stable grid order.""" + index = 0 + for workstream in view.workstreams: + for step in workstream.steps: + yield index, workstream.name, step + index += 1 + + +def _definition_grid(view): + """Render the definition as a portal-style grid: header + rows.""" + parts = [ + '
' + '
Steps
' + '
Configuration status
' + '
Step dependency
' + '
Entities
'] + index = 0 + for workstream in view.workstreams: + head = 'Workstream: %s (%d)' % ( + _esc(workstream.name or 'Ungrouped'), len(workstream.steps)) + parts.append('
%s
' % head) + if not workstream.steps: + parts.append('
' + 'No steps in this workstream.
') + for step in workstream.steps: + parts.append(_definition_row(index, step)) + index += 1 + return '
%s
' % ''.join(parts) + + +def _field(label, value): + text = value if value not in (None, '') else '-' + return ('
%s
' + '
%s
' + % (_esc(label), _esc(text))) + + +def _chip_field(label, values): + if not values: + body = '
-
' + else: + body = '
%s
' % ''.join( + '%s' % _esc(value) for value in values) + return ('
%s
%s
' + % (_esc(label), body)) + + +def _detail_html(workstream_name, step): + """Build the step detail-pane markup (shown in the side drawer).""" + entities = step.entity_names + body = ( + _field('Step type', step.step_ref) + + _field('Step ID', step.id) + + ('
Configuration ' + 'status
%s' + '
' + % (_status_class(step.status), _esc(step.status or 'Unknown'))) + + _chip_field('Entities (%d)' % len(entities), entities) + + _chip_field('Pre-requisites', step.prereqs) + + _chip_field('Depends on', step.dep_details)) + return ( + '
' + '
' + '

%s

' + '
Workstream: %s
' + '
' + '
%s
' + % (_esc(step.name), _esc(workstream_name or 'Ungrouped'), body)) + + +def _definition_details(view): + """Emit hidden per-step detail blocks that the drawer clones on click.""" + if view is None or view.kind != viewmodel.KIND_DEFINITION: + return '' + blocks = ''.join( + '' + % (index, _detail_html(ws_name, step)) + for index, ws_name, step in _iter_indexed_steps(view)) + return '' % blocks + + +# CLI cmdlet help chips (static; shown above the definition grid). +_HELP_CHIPS = ( + ('▶', 'Start execution', + 'Runs the wave and streams live progress in the execution view.', + 'az migrate runbook execution start --resource-group ' + '--project-name --runbook-name '), + ('+', 'Add a step', + 'Adds a step to a workstream in the runbook definition.', + 'az migrate runbook definition step add --resource-group ' + '--project-name --runbook-name ' + '--workstream-id --step-ref '), + ('⇉', 'Merge workstreams', + 'Combines two workstreams into a single track.', + 'az migrate runbook definition workstream merge --resource-group ' + '--project-name --runbook-name ' + '--source-id --target-id '), + ('▱', 'Split a workstream', + 'Splits a workstream into parallel tracks.', + 'az migrate runbook definition workstream split --resource-group ' + '--project-name --runbook-name ' + '--workstream-id '), + ('↻', 'Refresh this view', + 'Regenerates the HTML from the latest runbook definition.', + 'az migrate runbook definition visualize --resource-group ' + '--project-name --runbook-name '), +) + + +def _help_bar(view): + """Render the static CLI cmdlet help chips (definition only).""" + if view is None or view.kind != viewmodel.KIND_DEFINITION: + return '' + chips = ''.join( + '' + % (_esc(title), _esc(desc), _esc(cmd), ico, _esc(title)) + for ico, title, desc, cmd in _HELP_CHIPS) + return ( + '
This is a ' + 'read-only view — actions run from the Azure CLI. Pick one to ' + 'see the command:
%s
' + % chips) + + +def _meta_block(view): + """Render the header metadata strip (definition only).""" + if view is None or not view.meta: + return '' + fields = '' + for label, value in view.meta: + display = _format_generated(value) if label == 'Generated' else value + fields += ( + '
%s' + '%s
' + % (_esc(label), _esc(display), _esc(display))) + return '
%s
' % fields + + +def _execution_card(step): + rows = [ + '
Status' + '%s
' + % (_status_class(step.status), _esc(step.status or 'NotStarted')), + '
Dependency%s
' + % _dep_cell(step), + ] + if step.workload_progress: + rows.append( + '
Progress' + '%s
' % _esc(step.workload_progress)) + progress_bar = _progress_bar(step.workload_progress) + if progress_bar: + rows.append(progress_bar) + if step.entities: + pills = ''.join( + '%s' + % (_status_class(entity.status), _esc(entity.name), + _esc(entity.status or '')) + for entity in step.entities) + rows.append('
%s
' % pills) + return _card_shell(step, rows) + + +def _card_shell(step, rows): + return ( + '
' + '
%s
' + '
%s
%s
' + % (_esc(step.name), _esc(step.id), ''.join(rows))) + + +def _grid(view): + if view is None: + return '' + if not view.workstreams or view.step_count == 0: + return '

This runbook has no steps to display.

' + if view.kind == viewmodel.KIND_DEFINITION: + return _definition_grid(view) + groups = [] + for workstream in view.workstreams: + head = 'Workstream: %s (%d)' % ( + _esc(workstream.name or 'Ungrouped'), len(workstream.steps)) + cards = ''.join(_execution_card(step) for step in workstream.steps) + groups.append( + '

%s

' + '
%s
' % (head, cards)) + return ''.join(groups) + + +# --------------------------------------------------------------------------- +# Document assembly +# --------------------------------------------------------------------------- + +def render(graph, view=None, refresh_interval=None): + """Return a complete, self-contained HTML document for the runbook. + + ``graph`` drives the SVG dependency diagram; the optional ``view`` + (:class:`~.viewmodel.RunbookView`) drives the default portal-style grid + and enables the in-page grid/diagram toggle. When ``refresh_interval`` is + a positive number of seconds, an offline ```` + tag is embedded so a browser viewing the file auto-reloads it from disk on + that cadence (used by ``--watch`` so the user never has to refresh + manually). Reloading a local file makes no network call, preserving the + offline/air-gapped guarantee. + """ + with open(_TEMPLATE_PATH, encoding='utf-8') as handle: + template = string.Template(handle.read()) + title = graph.title if view is None else view.title + step_count = len(graph.nodes) if view is None else view.step_count + summary = '%d step%s' % (step_count, '' if step_count == 1 else 's') + generated = _format_generated( + None if view is None else view.generated) + grid_html = _grid(view) + return template.substitute( + title=_esc(title), + summary=_esc(summary), + generated=_esc(generated), + meta=_meta_block(view), + help=_help_bar(view), + details=_definition_details(view), + legend=_legend(graph, view), + refresh=_refresh_meta(refresh_interval), + summary_cards=_summary_cards(view), + toggle=_toggle(grid_html), + grid=grid_html, + grid_hidden='' if grid_html else ' hidden', + diagram_hidden=' hidden' if grid_html else '', + svg=_svg(graph)) + + +def _format_generated(declared): + """Format the generation timestamp for the header. + + Prefer the document's own ``generatedAt`` (ISO 8601) when present, + normalising it to ``YYYY-MM-DD HH:MM UTC``; otherwise fall back to the + current render time. + """ + if declared: + text = str(declared).replace('T', ' ') + text = text.split('.', 1)[0].rstrip('Z').strip() + return '%s UTC' % text if text else text + return datetime.datetime.now(datetime.timezone.utc).strftime( + '%Y-%m-%d %H:%M UTC') + + +def _toggle(grid_html): + """Render the grid/diagram tab buttons (only when a grid exists).""" + if not grid_html: + return '' + return ( + '
' + '' + '
') + + +def _refresh_meta(refresh_interval): + """Build an offline auto-reload ```` tag, or '' when disabled.""" + try: + seconds = int(refresh_interval) + except (TypeError, ValueError): + return '' + if seconds <= 0: + return '' + return '\n' % seconds diff --git a/src/migrate/azext_migrate/runbook/visualize/templates/runbook.html.tmpl b/src/migrate/azext_migrate/runbook/visualize/templates/runbook.html.tmpl new file mode 100644 index 00000000000..8c1d2973e5b --- /dev/null +++ b/src/migrate/azext_migrate/runbook/visualize/templates/runbook.html.tmpl @@ -0,0 +1,436 @@ + + + + + +$refresh$title + + + +
+ + Azure Migrate Runbook Viewer +
+
+

$title

+
$summary · generated $generated
+
+$meta +$help +$toggle +$summary_cards +$legend +
+$grid +
+
+
+$svg +
+
+$details + +
+ + + + diff --git a/src/migrate/azext_migrate/runbook/visualize/viewmodel.py b/src/migrate/azext_migrate/runbook/visualize/viewmodel.py new file mode 100644 index 00000000000..13c282f227c --- /dev/null +++ b/src/migrate/azext_migrate/runbook/visualize/viewmodel.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. +# -------------------------------------------------------------------------------------------- +"""Build the *grid* view model for a runbook definition/execution. + +This is the data model behind the portal-style grid (the default view in the +generated HTML), grouped by workstream. Like :mod:`graph`, this module is +**data only** — it contains no HTML and performs no I/O, so it is trivially +unit-testable. The renderer turns this model into offline, XSS-escaped markup. +""" + +from azext_migrate.runbook import deps as dep_utils + +KIND_DEFINITION = 'definition' +KIND_EXECUTION = 'execution' + + +class EntityProgress: + """Per-entity execution state shown under an execution step.""" + + # pylint: disable=too-few-public-methods + def __init__(self, name, status): + self.name = name + self.status = status + + +class StepRow: + """One row in the grid (a single runbook step).""" + + # pylint: disable=too-few-public-methods,too-many-arguments + # pylint: disable=too-many-instance-attributes + def __init__(self, step_id, name, deps_names=None, status=None, + workloads=None, workload_progress=None, entities=None, + step_ref=None, entity_names=None, prereqs=None, + dep_details=None): + self.id = step_id + self.name = name + self.deps = deps_names or [] + self.status = status + self.workloads = workloads + self.workload_progress = workload_progress + self.entities = entities or [] + self.step_ref = step_ref + # Detail-pane fields (definition): resolved entity display names and + # the prerequisite / dependsOn edges labelled with their mode. + self.entity_names = entity_names or [] + self.prereqs = prereqs or [] + self.dep_details = dep_details or [] + + +class Workstream: + """A named group of step rows.""" + + # pylint: disable=too-few-public-methods + def __init__(self, name, steps): + self.name = name + self.steps = steps + + +class RunbookView: + """The full grid view model for one runbook.""" + + # pylint: disable=too-few-public-methods,too-many-arguments + def __init__(self, title, kind, workstreams, summary, + meta=None, generated=None): + self.title = title + self.kind = kind + self.workstreams = workstreams + self.summary = summary + # ``meta`` is a list of (label, value) header fields (e.g. step + # library version, wave id); ``generated`` is the source-declared + # generation timestamp, if the document carried one. + self.meta = meta or [] + self.generated = generated + + @property + def step_count(self): + return sum(len(ws.steps) for ws in self.workstreams) + + +def _unwrap(document): + """Return the root object, unwrapping an execution ``properties`` envelope.""" + root = document or {} + if isinstance(root, dict) and isinstance(root.get('properties'), dict): + merged = dict(root) + merged.update(root['properties']) + return merged + return root if isinstance(root, dict) else {} + + +def _step_id(step): + return step.get('stepId') or step.get('id') or step.get('name') + + +def _step_name(step): + return (step.get('displayName') or step.get('name') + or step.get('stepName') or _step_id(step) or 'step') + + +def _iter_workstreams(root): + """Yield ``(name, [steps])`` pairs, covering grouped and flat shapes.""" + workstreams = root.get('workstreams') + if isinstance(workstreams, list) and workstreams: + for workstream in workstreams: + if not isinstance(workstream, dict): + continue + name = (workstream.get('displayName') or workstream.get('name') + or workstream.get('id') or 'Workstream') + steps = [s for s in workstream.get('steps') or [] + if isinstance(s, dict)] + yield name, steps + return + flat = [s for s in root.get('steps') or [] if isinstance(s, dict)] + if flat: + yield None, flat + + +def _step_name_map(root): + """Map every step id to its display name (for dependency labels).""" + names = {} + for _, steps in _iter_workstreams(root): + for step in steps: + names[_step_id(step)] = _step_name(step) + return names + + +def _dep_labels(step, id_to_name): + return [id_to_name.get(dep_id, dep_id) + for dep_id in dep_utils.merged_dep_ids(step)] + + +def _entity_name_map(root): + """Map every entity id to its display name (for step detail panes).""" + names = {} + for entity in root.get('entities') or []: + if isinstance(entity, dict): + entity_id = entity.get('id') or entity.get('name') + if entity_id: + names[entity_id] = entity.get('displayName') or entity_id + return names + + +def _dep_entries(raw, id_to_name): + """Label a prerequisite/dependsOn list with resolved names and mode.""" + entries = [] + for dep in raw or []: + if isinstance(dep, dict): + dep_id = dep.get('step') or dep.get('stepId') + mode = dep.get('mode') + else: + dep_id, mode = dep, None + if not dep_id: + continue + name = id_to_name.get(dep_id, dep_id) + entries.append('%s (%s)' % (name, mode) if mode else name) + return entries + + +def build_definition_view(document, title): + """Build the grid view model for a runbook definition document.""" + root = _unwrap(document) + id_to_name = _step_name_map(root) + entity_map = _entity_name_map(root) + workstreams = [] + status_counts = {} + for name, steps in _iter_workstreams(root): + rows = [] + for step in steps: + status = step.get('configurationStatus') + if status: + key = str(status).split(' ', 1)[0] + status_counts[key] = status_counts.get(key, 0) + 1 + entity_ids = step.get('entities') or [] + rows.append(StepRow( + step_id=_step_id(step), + name=_step_name(step), + deps_names=_dep_labels(step, id_to_name), + status=status, + workloads=len(entity_ids), + step_ref=step.get('stepRef'), + entity_names=[entity_map.get(eid, eid) + for eid in entity_ids], + prereqs=_dep_entries(step.get('prerequisite'), id_to_name), + dep_details=_dep_entries(step.get('dependsOn'), id_to_name))) + workstreams.append(Workstream(name, rows)) + + step_total = sum(len(ws.steps) for ws in workstreams) + summary = [('Workstreams', len(workstreams)), ('Steps', step_total), + ('Entities', len(root.get('entities') or []))] + summary.extend(sorted(status_counts.items())) + meta, generated = _definition_meta(root) + return RunbookView(title, KIND_DEFINITION, workstreams, summary, + meta=meta, generated=generated) + + +def _definition_meta(root): + """Extract header metadata from a runbook definition document. + + Returns ``(meta, generated)`` where ``meta`` is a list of + ``(label, value)`` header fields and ``generated`` is the + source-declared generation timestamp (or ``None``). + """ + metadata = root.get('metadata') if isinstance( + root.get('metadata'), dict) else {} + generated = metadata.get('generatedAt') + meta = [] + versions = root.get('stepLibraryVersions') + if isinstance(versions, dict) and versions: + meta.append(('Runbook version', ', '.join( + '%s %s' % (name, ver) for name, ver in sorted(versions.items())))) + if generated: + meta.append(('Generated', generated)) + meta.append(('Data source', 'runbook.json')) + resource_id = root.get('runbookResourceId') + if resource_id: + meta.append(('Runbook resource id', resource_id)) + wave_id = metadata.get('waveId') + if wave_id: + meta.append(('Wave id', wave_id)) + return meta, generated + + +def _entity_status(entity): + status = entity.get('status') or entity.get('state') + if isinstance(status, dict): + status = status.get('state') or status.get('status') + return status + + +def _progress_text(step): + """Return a "n/m completed" summary from ``entityExecutions``.""" + explicit = step.get('workloadProgress') + if explicit is not None: + return str(explicit) + entities = step.get('entityExecutions') + if not isinstance(entities, list) or not entities: + return None + completed = sum( + 1 for entity in entities + if isinstance(entity, dict) + and str(_entity_status(entity) or '').lower() == 'completed') + return '%d/%d completed' % (completed, len(entities)) + + +def _exec_status(step): + return (step.get('status') or step.get('stepStatus') + or step.get('state')) + + +def build_execution_view(document, title): + """Build the grid view model for a runbook execution status document.""" + root = _unwrap(document) + id_to_name = _step_name_map(root) + workstreams = [] + status_counts = {} + for name, steps in _iter_workstreams(root): + rows = [] + for step in steps: + status = _exec_status(step) + if status: + status_counts[str(status)] = \ + status_counts.get(str(status), 0) + 1 + entity_execs = [e for e in step.get('entityExecutions') or [] + if isinstance(e, dict)] + entities = [ + EntityProgress( + e.get('displayName') or e.get('entityId') or e.get('name'), + _entity_status(e)) + for e in entity_execs] + rows.append(StepRow( + step_id=_step_id(step), + name=_step_name(step), + deps_names=_dep_labels(step, id_to_name), + status=status, + workload_progress=_progress_text(step), + entities=entities)) + workstreams.append(Workstream(name, rows)) + + summary = [] + overall = root.get('state') or root.get('status') + if overall: + summary.append(('State', overall)) + summary.extend(sorted(status_counts.items())) + return RunbookView(title, KIND_EXECUTION, workstreams, summary) diff --git a/src/migrate/azext_migrate/shared/__init__.py b/src/migrate/azext_migrate/shared/__init__.py new file mode 100644 index 00000000000..34913fb394d --- /dev/null +++ b/src/migrate/azext_migrate/shared/__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/migrate/azext_migrate/shared/arm_client.py b/src/migrate/azext_migrate/shared/arm_client.py new file mode 100644 index 00000000000..05e05308580 --- /dev/null +++ b/src/migrate/azext_migrate/shared/arm_client.py @@ -0,0 +1,212 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Single cross-feature ARM REST surface built on send_raw_request.""" + +import json as _json +import re as _re +import time as _time + +from knack.log import get_logger + +from azure.cli.core.util import send_raw_request + +from azext_migrate.shared import arm_ids, errors +from azext_migrate.shared.constants import ( + RUNBOOKS_API_VERSION, + WAVE_OPERATIONS_API_VERSION, +) + +logger = get_logger(__name__) + +# Async status polling tuning. +_DEFAULT_POLL_DELAY = 5 +_MAX_POLL_DELAY = 60 +_TERMINAL_SUCCESS = 'succeeded' +_TERMINAL_FAILURE = ('failed', 'canceled', 'cancelled') + + +def _poll_delay(response): + """Seconds to wait before the next poll, honoring Retry-After.""" + retry_after = response.headers.get('Retry-After') + if retry_after and retry_after.isdigit(): + return min(int(retry_after), _MAX_POLL_DELAY) + return _DEFAULT_POLL_DELAY + + +def _rewrite_poll_api_version(url): + """Point an async-operation status URL at the wave-operations API era. + + Runbook create/delete are served by RUNBOOKS_API_VERSION, but the + Azure-AsyncOperation/Location status endpoint (migrateProjects/ + waveOperations) is only registered at WAVE_OPERATIONS_API_VERSION and + returns NoRegisteredProviderFound otherwise. The header echoes the + request's api-version, so swap only that value, preserving the signed + token that follows. + """ + if 'api-version=' not in url.lower(): + return url + ('&' if '?' in url else '?') + \ + 'api-version=' + WAVE_OPERATIONS_API_VERSION + return _re.sub( + r'([?&]api-version=)[^&]+', + r'\g<1>' + WAVE_OPERATIONS_API_VERSION, + url, count=1) + + +class ArmClient: + """Thin, generic wrapper over send_raw_request for ARM resources. + + Every migrate feature package routes its REST calls through this one + surface so authentication, endpoint selection, serialization, paging + and error mapping live in exactly one place. + """ + + def __init__(self, cmd, api_version=RUNBOOKS_API_VERSION): + self.cmd = cmd + self.api_version = api_version + + def _url(self, resource_id): + endpoint = self.cmd.cli_ctx.cloud.endpoints.resource_manager + return endpoint.rstrip('/') + arm_ids.with_api_version( + resource_id, self.api_version) + + def _send(self, method, resource_id, body=None): + kwargs = {} + if body is not None: + kwargs['body'] = _json.dumps(body) + response = send_raw_request( + self.cmd.cli_ctx, method, self._url(resource_id), **kwargs) + if response.status_code >= 400: + errors.raise_for_arm_error(response) + return response + + @staticmethod + def _json_or_none(response): + if not getattr(response, 'content', None): + return None + try: + return response.json() + except ValueError: + return None + + def _poll_until_done(self, response, method, resource_id): + """Follow an Azure LRO to completion, returning the initial body. + + Create/delete on runbooks are long-running: they return 201/202 + with an Azure-AsyncOperation (or Location) header. Poll that URL + until the operation reaches a terminal state, then return the + original response body (the caller already has the resource repr). + """ + header_name = ('Azure-AsyncOperation' + if response.headers.get('Azure-AsyncOperation') + else 'Location') + poll_url = response.headers.get(header_name) + result = self._json_or_none(response) + if response.status_code not in (201, 202) or not poll_url: + logger.info( + "%s '%s' completed synchronously (HTTP %s).", + method, resource_id, response.status_code) + return result + poll_url = _rewrite_poll_api_version(poll_url) + op_ref = poll_url.split('?', 1)[0] + delay = _poll_delay(response) + logger.warning( + "%s '%s' is a long-running operation (HTTP %s). Tracking via " + "'%s' header: %s. First status check in %ss " + "(use --no-wait to skip).", + method, resource_id, response.status_code, header_name, + op_ref, delay) + logger.info("Full async-operation poll URL: %s", poll_url) + start = _time.monotonic() + attempt = 0 + while True: + _time.sleep(delay) + attempt += 1 + poll = send_raw_request(self.cmd.cli_ctx, 'GET', poll_url) + if poll.status_code >= 400: + errors.raise_for_arm_error(poll) + body = self._json_or_none(poll) or {} + status = body.get('status') or '' + elapsed = int(_time.monotonic() - start) + norm = status.lower() + if poll.status_code in (200, 204) and not status: + logger.warning( + "%s '%s' completed (elapsed %ss, %s poll(s)).", + method, resource_id, elapsed, attempt) + return result + if norm == _TERMINAL_SUCCESS: + logger.warning( + "%s '%s' succeeded (elapsed %ss, %s poll(s)).", + method, resource_id, elapsed, attempt) + return result + if norm in _TERMINAL_FAILURE: + errors.raise_for_async_operation(body) + delay = _poll_delay(poll) + logger.warning( + "%s '%s' still running: status=%s (elapsed %ss, " + "poll #%s, next check in %ss).", + method, resource_id, status or '(none)', elapsed, + attempt, delay) + + def _begin(self, method, resource_id, body=None, no_wait=False): + response = self._send(method, resource_id, body) + if no_wait: + logger.warning( + "%s '%s' accepted; --no-wait set, not polling for " + "completion.", method, resource_id) + return self._json_or_none(response) + return self._poll_until_done(response, method, resource_id) + + def get(self, resource_id): + """GET a resource and return its JSON body.""" + return self._send('GET', resource_id).json() + + def get_or_none(self, resource_id): + """GET a resource, returning None on 404 (existence check).""" + from azure.cli.core.azclierror import HTTPError + try: + response = send_raw_request( + self.cmd.cli_ctx, 'GET', self._url(resource_id)) + except HTTPError as ex: + if getattr(ex.response, 'status_code', None) == 404: + return None + raise + if response.status_code >= 400: + errors.raise_for_arm_error(response) + return response.json() + + def list(self, collection_id): + """GET a collection, following nextLink pagination.""" + items = [] + url = self._url(collection_id) + while url: + response = send_raw_request(self.cmd.cli_ctx, 'GET', url) + if response.status_code >= 400: + errors.raise_for_arm_error(response) + body = response.json() + items.extend(body.get('value', [])) + url = body.get('nextLink') + return items + + def put(self, resource_id, body=None, no_wait=False): + """PUT (create/generate/start) a resource, awaiting any LRO.""" + return self._begin('PUT', resource_id, body, no_wait) + + def patch(self, resource_id, body=None): + """PATCH (update) a resource.""" + return self._json_or_none(self._send('PATCH', resource_id, body)) + + def delete(self, resource_id, no_wait=False): + """DELETE a resource, awaiting any LRO.""" + return self._begin('DELETE', resource_id, no_wait=no_wait) + + def post_action(self, resource_id, action_name, body=None, + no_wait=False): + """POST {resourceId}/{action_name} with an optional JSON body. + + This is the workhorse for every action endpoint (AddStep, + PerformAction, ProvideApproval, GenerateDownloadUrl, ...). + """ + action_id = f"{resource_id}/{action_name}" + return self._begin('POST', action_id, body, no_wait) diff --git a/src/migrate/azext_migrate/shared/arm_ids.py b/src/migrate/azext_migrate/shared/arm_ids.py new file mode 100644 index 00000000000..1604fe4de30 --- /dev/null +++ b/src/migrate/azext_migrate/shared/arm_ids.py @@ -0,0 +1,34 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Generic ARM resource-id and URL builders shared across features.""" + +from azext_migrate.shared import constants + + +def migrate_project_id(subscription_id, resource_group, project_name): + """Build a migrate-project ARM id.""" + return constants.MIGRATE_PROJECT_ID_TEMPLATE.format( + subscription_id=subscription_id, + resource_group=resource_group, + project_name=project_name, + ) + + +def runbook_id(project_id, runbook_name): + """Build a runbook ARM id from a project id.""" + return constants.RUNBOOK_ID_TEMPLATE.format( + project_id=project_id, runbook_name=runbook_name) + + +def execution_id(runbook_resource_id, execution): + """Build a runbook-execution ARM id from a runbook id.""" + return constants.EXECUTION_ID_TEMPLATE.format( + runbook_id=runbook_resource_id, execution_id=execution) + + +def with_api_version(resource_id, api_version): + """Append the api-version query parameter to a resource id/url.""" + joiner = '&' if '?' in resource_id else '?' + return f"{resource_id}{joiner}api-version={api_version}" diff --git a/src/migrate/azext_migrate/shared/constants.py b/src/migrate/azext_migrate/shared/constants.py new file mode 100644 index 00000000000..4758ce22629 --- /dev/null +++ b/src/migrate/azext_migrate/shared/constants.py @@ -0,0 +1,31 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Constants shared across all migrate feature packages.""" + +# Resource provider namespace (canonical casing). +PROVIDER_NAMESPACE = "Microsoft.Migrate" + +# API version for the Runbooks resource provider surface. +# NOTE: the migrateProjects/runbooks type is only registered at +# 2020-06-01-preview. Newer versions (2025/2026) are for the wave APIs +# and return NoRegisteredProviderFound on the runbooks path. +RUNBOOKS_API_VERSION = "2020-06-01-preview" + +# Runbook create/delete are long-running operations whose async status +# is served by the migrateProjects/waveOperations type. That type is NOT +# registered at RUNBOOKS_API_VERSION; the async-operation status URL must +# be polled at this newer API version instead. +WAVE_OPERATIONS_API_VERSION = "2025-03-30-preview" + +# Canonical ARM ID templates (camelCase per the confirmed spec): +# /subscriptions/{s}/resourceGroups/{rg}/providers/Microsoft.Migrate +# /migrateProjects/{p}/runbooks/{n}/executions/{e} +MIGRATE_PROJECT_ID_TEMPLATE = ( + "/subscriptions/{subscription_id}" + "/resourceGroups/{resource_group}" + "/providers/Microsoft.Migrate/migrateProjects/{project_name}" +) +RUNBOOK_ID_TEMPLATE = "{project_id}/runbooks/{runbook_name}" +EXECUTION_ID_TEMPLATE = "{runbook_id}/executions/{execution_id}" diff --git a/src/migrate/azext_migrate/shared/errors.py b/src/migrate/azext_migrate/shared/errors.py new file mode 100644 index 00000000000..5c2d1c904b4 --- /dev/null +++ b/src/migrate/azext_migrate/shared/errors.py @@ -0,0 +1,59 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Map ARM error responses to typed azclierror exceptions.""" + +from azure.cli.core.azclierror import ( + AzureResponseError, + ClientRequestError, + CLIInternalError, + ForbiddenError, + InvalidArgumentValueError, + ResourceNotFoundError, +) + + +def raise_for_arm_error(response): + """Raise a typed CLI error for an ARM response with status >= 400.""" + status = response.status_code + code, message = _extract_error(response) + detail = f"{code}: {message}" if code else message + if status == 404: + raise ResourceNotFoundError(detail) + if status == 400: + raise InvalidArgumentValueError(detail) + if status == 403: + raise ForbiddenError(detail) + if status == 409: + raise ClientRequestError(detail) + if status >= 500: + raise CLIInternalError(detail) + raise ClientRequestError(detail) + + +def raise_for_async_operation(status_body): + """Raise a typed CLI error for a failed async operation status body.""" + status = status_body.get('status', 'Failed') + error = status_body.get('error') + if isinstance(error, dict): + code = error.get('code') + message = error.get('message', '') + detail = f"{code}: {message}" if code else message + else: + detail = '' + if not detail: + detail = f"The operation completed with status '{status}'." + raise AzureResponseError(detail) + + +def _extract_error(response): + """Extract (code, message) from an ARM error body when present.""" + try: + body = response.json() + except ValueError: + return None, response.text + error = body.get('error') if isinstance(body, dict) else None + if isinstance(error, dict): + return error.get('code'), error.get('message', response.text) + return None, response.text diff --git a/src/migrate/azext_migrate/shared/files.py b/src/migrate/azext_migrate/shared/files.py new file mode 100644 index 00000000000..0912d478d44 --- /dev/null +++ b/src/migrate/azext_migrate/shared/files.py @@ -0,0 +1,287 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Download and safely extract SAS-protected ZIP artifacts. + +Several runbook endpoints (``GenerateDownloadUrl``) return a pre-signed +blob SAS URL that points at a ZIP. The SAS is self-authorizing, so the +blob is fetched with a plain HTTPS GET (no ARM token) and then extracted +with guards against path traversal (zip-slip) and decompression bombs. +""" + +import io +import json +import os +import tempfile +import zipfile +from urllib.request import Request, urlopen + +from knack.log import get_logger + +from azure.cli.core.azclierror import ( + CLIInternalError, + InvalidArgumentValueError, +) + +logger = get_logger(__name__) + +# Guard rails for the untrusted archive we extract. +_MAX_TOTAL_UNCOMPRESSED = 256 * 1024 * 1024 +_MAX_MEMBERS = 1000 + +# Keys the GenerateDownloadUrl response may use for the SAS URL, checked +# both at the top level and under ``properties``. +_SAS_URL_KEYS = ( + 'downloadUrl', 'downloadUri', 'sasUrl', 'sasUri', 'url', 'uri') + +# Derived/computed inputs the CLI must never surface or download. This +# document shares the 'runbookInputs' shape with the user parameters, so it +# can only be distinguished by name (content classification is not enough). +_DERIVED_INPUTS_NAMES = ('derived-input.json', 'derived-inputs.json') + +# File name of the execution status document fetched via a per-execution +# SAS download (GenerateDownloadUrl on the execution resource). +# TODO(confirm): validate against a live per-execution SAS download; the +# blob may be the raw status.json or a ZIP that contains it. +_STATUS_SUFFIX = 'status.json' + + +def extract_sas_url(response_body): + """Return the download URL from a GenerateDownloadUrl response body.""" + if not isinstance(response_body, dict): + return None + for source in (response_body, response_body.get('properties')): + if not isinstance(source, dict): + continue + for key in _SAS_URL_KEYS: + value = source.get(key) + if isinstance(value, str) and value: + return value + return None + + +def download_bytes(url): + """HTTP GET a self-authorizing https URL and return the raw bytes.""" + if not isinstance(url, str) or not url.lower().startswith('https://'): + raise InvalidArgumentValueError( + 'The download URL must be an absolute https URL.') + request = Request(url, method='GET') + # The URL is a pre-signed blob SAS returned by ARM and validated above + # to be https; no ARM token is attached. + with urlopen(request) as response: # nosec B310 + return response.read() + + +def _looks_like_spec(parsed): + """True when a parsed JSON document is a runbook definition/spec.""" + return isinstance(parsed, dict) and ( + 'runbookSpec' in parsed or 'workstreams' in parsed) + + +def _looks_like_parameters(parsed): + """True when a parsed JSON document is a runbook parameters file.""" + return isinstance(parsed, dict) and ( + 'runbookInputs' in parsed + or 'stepInputs' in parsed + or 'schema' in parsed) + + +def _classify_archive(zip_bytes): + """Sort the GenerateDownloadUrl archive members by role. + + Returns ``{'definition': (name, bytes) | None, + 'parameters': (name, bytes) | None, 'docs': [(name, bytes), ...]}``. + + The archive ships the definition (``runbookSpec``), the user parameters + (``runbookInputs``), the ``derived-input.json`` computed inputs, and a + documentation markdown. ``derived-input.json`` shares the parameters + shape and is distinguished only by name, so it is skipped here; every + other member is classified by content. This is the single source of + truth for the archive layout. + """ + definition = parameters = None + docs = [] + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive: + _guard_archive(archive.infolist()) + for info in archive.infolist(): + if info.is_dir(): + continue + name = os.path.basename(info.filename.replace('\\', '/')) + lower = name.lower() + if lower in _DERIVED_INPUTS_NAMES: + continue + data = archive.read(info) + if lower.endswith('.md'): + docs.append((name, data)) + continue + if not lower.endswith('.json'): + continue + try: + parsed = json.loads(data.decode('utf-8')) + except ValueError: + continue + if definition is None and _looks_like_spec(parsed): + definition = (name, data) + elif parameters is None and _looks_like_parameters(parsed): + parameters = (name, data) + return { + 'definition': definition, 'parameters': parameters, 'docs': docs} + + +def read_spec_json(zip_bytes): + """Return the parsed runbook definition (``runbookSpec``) or None.""" + found = _classify_archive(zip_bytes)['definition'] + return json.loads(found[1].decode('utf-8')) if found else None + + +def extract_parameters_file(zip_bytes): + """Return ``(filename, raw_bytes)`` for the user parameters, or None. + + ``derived-input.json`` (which shares the parameters shape) is never + returned; see :func:`_classify_archive`. + """ + return _classify_archive(zip_bytes)['parameters'] + + +def read_parameters_json(zip_bytes): + """Return the parsed ``runbookInputs`` object from the ZIP, or None.""" + found = extract_parameters_file(zip_bytes) + if not found: + return None + parsed = json.loads(found[1].decode('utf-8')) + if isinstance(parsed, dict) and isinstance( + parsed.get('runbookInputs'), dict): + return parsed['runbookInputs'] + return parsed + + +def read_status_json(raw_bytes): + """Return the parsed execution status document from a SAS download. + + The per-execution SAS blob may be either the raw ``status.json`` bytes + or a ZIP archive that contains it; both are handled. Raises when the + content is neither valid JSON nor a ZIP with a JSON member. + """ + if raw_bytes[:4] == b'PK\x03\x04': + with zipfile.ZipFile(io.BytesIO(raw_bytes)) as archive: + member = None + for info in archive.infolist(): + if info.is_dir(): + continue + name = info.filename.replace('\\', '/').lower() + if name.endswith(_STATUS_SUFFIX): + member = info + break + if member is None: + for info in archive.infolist(): + if not info.is_dir() \ + and info.filename.lower().endswith('.json'): + member = info + break + if member is None: + raise CLIInternalError( + 'The downloaded archive did not contain a status file.') + return json.loads(archive.read(member).decode('utf-8')) + return json.loads(raw_bytes.decode('utf-8')) + + +def read_json_file(path): + """Read and parse a local JSON file, returning the parsed object. + + Enables offline rendering/testing of the visualize commands from + definition/parameters/status JSON files without contacting the service. + """ + with open(path, 'r', encoding='utf-8') as handle: + return json.load(handle) + + +def resolve_output_path(file, default_name): + """Resolve a user ``--file`` value to an absolute output file path. + + ``file`` may be ``None`` (write ``default_name`` into the current + directory), a directory (write ``default_name`` inside it), or a full + file path. The result is always an absolute, normalized path. + """ + if not file: + return os.path.join(os.getcwd(), default_name) + target = os.path.abspath(file) + if os.path.isdir(target): + return os.path.join(target, default_name) + return target + + +def write_text(path, text): + """Write ``text`` (UTF-8) to ``path`` atomically, creating parent dirs. + + The content is written to a temporary file in the same directory and then + atomically moved into place with :func:`os.replace`. This guarantees a + reader (e.g. a browser auto-reloading the file during ``--watch``) never + observes a partially written file. + """ + absolute = os.path.abspath(path) + parent = os.path.dirname(absolute) + if parent: + os.makedirs(parent, exist_ok=True) + fd, tmp = tempfile.mkstemp( + prefix='.runbook-', suffix='.tmp', dir=parent or None) + try: + with os.fdopen(fd, 'w', encoding='utf-8') as handle: + handle.write(text) + os.replace(tmp, absolute) + except BaseException: + try: + os.remove(tmp) + except OSError: + pass + raise + return absolute + + +def open_in_browser(path): + """Best-effort open a local file in the default browser.""" + import webbrowser + try: + webbrowser.open('file://' + os.path.abspath(path)) + except OSError as ex: # pragma: no cover - environment dependent + logger.warning('Could not open the file in a browser: %s', ex) + + +def extract_definition_files(zip_bytes, destination): + """Write the runbook definition, its parameters and docs to disk. + + Writes the definition (``runbookSpec``), the user parameters + (``runbookInputs``) and any ``.md`` docs; the redundant + ``derived-input.json`` is skipped (see :func:`_classify_archive`). The + parameters file is downloaded because the definition's per-step + ``configurationStatus`` is computed from it, but callers still render only + the definition in table/CLI output. Member names are flattened to their + base name, so a hostile archive path cannot escape ``destination`` + (zip-slip is designed out rather than checked at write time). Returns the + absolute paths written. + """ + destination = os.path.abspath(destination) + os.makedirs(destination, exist_ok=True) + classified = _classify_archive(zip_bytes) + selected = list(classified['docs']) + if classified['definition']: + selected.insert(0, classified['definition']) + if classified['parameters']: + selected.append(classified['parameters']) + written = [] + for name, data in selected: + target = os.path.join(destination, os.path.basename(name)) + with open(target, 'wb') as handle: + handle.write(data) + written.append(target) + return written + + +def _guard_archive(infos): + """Reject archives with too many members or an implausible size.""" + if len(infos) > _MAX_MEMBERS: + raise CLIInternalError( + 'Downloaded archive has too many entries.') + if sum(i.file_size for i in infos) > _MAX_TOTAL_UNCOMPRESSED: + raise CLIInternalError( + 'Downloaded archive is unexpectedly large.') diff --git a/src/migrate/azext_migrate/shared/telemetry.py b/src/migrate/azext_migrate/shared/telemetry.py new file mode 100644 index 00000000000..db46d5f2f48 --- /dev/null +++ b/src/migrate/azext_migrate/shared/telemetry.py @@ -0,0 +1,37 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +"""Best-effort telemetry wrappers. These never raise on failure.""" + +from knack.log import get_logger + +logger = get_logger(__name__) + + +def record_exception(ex, fault_type, summary=None): + """Record a handled exception as a telemetry fault.""" + try: + from azure.cli.core import telemetry + telemetry.set_exception( + exception=ex, fault_type=fault_type, summary=summary) + except Exception as tex: # pylint: disable=broad-except + logger.debug('telemetry record_exception failed: %s', tex) + + +def set_user_fault(summary=None): + """Flag the current failure as a user fault (4xx/validation).""" + try: + from azure.cli.core import telemetry + telemetry.set_user_fault(summary=summary) + except Exception as tex: # pylint: disable=broad-except + logger.debug('telemetry set_user_fault failed: %s', tex) + + +def add_event(name, properties=None): + """Emit a lightweight extension telemetry event (no PII).""" + try: + from azure.cli.core import telemetry + telemetry.add_extension_event(name, properties or {}) + except Exception as tex: # pylint: disable=broad-except + logger.debug('telemetry add_event failed: %s', tex) diff --git a/src/migrate/azext_migrate/tests/latest/runbook/__init__.py b/src/migrate/azext_migrate/tests/latest/runbook/__init__.py new file mode 100644 index 00000000000..34913fb394d --- /dev/null +++ b/src/migrate/azext_migrate/tests/latest/runbook/__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/migrate/azext_migrate/tests/latest/runbook/recordings/test_runbook_show_and_list.yaml b/src/migrate/azext_migrate/tests/latest/runbook/recordings/test_runbook_show_and_list.yaml new file mode 100644 index 00000000000..11c61c141ea --- /dev/null +++ b/src/migrate/azext_migrate/tests/latest/runbook/recordings/test_runbook_show_and_list.yaml @@ -0,0 +1,98 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + CommandName: + - migrate runbook show + Connection: + - keep-alive + ParameterSetName: + - -g --project-name -n + User-Agent: + - python/3.12.10 (Windows-11-10.0.26100-SP0) AZURECLI/2.88.0 + method: GET + uri: https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/BP_AE_Can/providers/Microsoft.Migrate/migrateProjects/BP-AE-Can-Proj/runbooks/testrunbook1?api-version=2020-06-01-preview + response: + body: + string: '{"properties":{"scope":{"ScopeType":"Wave","scopeType":"Wave","waveId":"/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/bp_ae_can/providers/microsoft.migrate/migrateprojects/bp-ae-can-proj/waves/wave66"},"artifactId":"rb-testrunbook1","state":"ExecutionSucceeded","provisioningState":"Succeeded"},"id":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/BP_AE_Can/providers/Microsoft.Migrate/MigrateProjects/BP-AE-Can-Proj/Runbooks/testrunbook1","name":"testrunbook1","type":"Microsoft.Migrate/MigrateProjects/Runbooks","location":"","eTag":null}' + headers: + cache-control: + - no-cache + content-length: + - '581' + content-type: + - application/json; charset=utf-8 + date: + - Fri, 17 Jul 2026 12:59:16 GMT + expires: + - '-1' + pragma: + - no-cache + strict-transport-security: + - max-age=31536000; includeSubDomains + x-cache: + - CONFIG_NOCACHE + x-content-type-options: + - nosniff + x-ms-operation-identifier: + - tenantId=72f988bf-86f1-41af-91ab-2d7cd011db47,objectId=92634de3-03f6-4092-b41b-20616b11a464/australiaeast/949f8bc7-0670-49f8-b169-38c414445645 + x-ms-ratelimit-remaining-subscription-global-reads: + - '16499' + x-msedge-ref: + - 'Ref A: BEF09BF87170414D8B447BE86681E59F Ref B: MAA211070113031 Ref C: 2026-07-17T12:59:16Z' + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + CommandName: + - migrate runbook list + Connection: + - keep-alive + ParameterSetName: + - -g --project-name + User-Agent: + - python/3.12.10 (Windows-11-10.0.26100-SP0) AZURECLI/2.88.0 + method: GET + uri: https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/BP_AE_Can/providers/Microsoft.Migrate/migrateProjects/BP-AE-Can-Proj/runbooks?api-version=2020-06-01-preview + response: + body: + string: '{"value":[{"properties":{"scope":{"ScopeType":"Wave","scopeType":"Wave","waveId":"/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/bp_ae_can/providers/microsoft.migrate/migrateprojects/bp-ae-can-proj/waves/wave66"},"artifactId":"rb-testrunbook1","state":"ExecutionSucceeded","provisioningState":"Succeeded"},"id":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/BP_AE_Can/providers/Microsoft.Migrate/MigrateProjects/BP-AE-Can-Proj/Runbooks/testrunbook1","name":"testrunbook1","type":"Microsoft.Migrate/MigrateProjects/Runbooks","location":"","eTag":null}]}' + headers: + cache-control: + - no-cache + content-length: + - '593' + content-type: + - application/json; charset=utf-8 + date: + - Fri, 17 Jul 2026 12:59:23 GMT + expires: + - '-1' + pragma: + - no-cache + strict-transport-security: + - max-age=31536000; includeSubDomains + x-cache: + - CONFIG_NOCACHE + x-content-type-options: + - nosniff + x-ms-operation-identifier: + - tenantId=72f988bf-86f1-41af-91ab-2d7cd011db47,objectId=92634de3-03f6-4092-b41b-20616b11a464/australiaeast/0cd37251-8846-462a-9b05-1914819ff3e2 + x-ms-ratelimit-remaining-subscription-global-reads: + - '16499' + x-msedge-ref: + - 'Ref A: 0EAA51D6728E49589A758C63707E3BC2 Ref B: MAA211070116025 Ref C: 2026-07-17T12:59:22Z' + status: + code: 200 + message: OK +version: 1 diff --git a/src/migrate/azext_migrate/tests/latest/runbook/test_runbook_recording.py b/src/migrate/azext_migrate/tests/latest/runbook/test_runbook_recording.py new file mode 100644 index 00000000000..72db69da02a --- /dev/null +++ b/src/migrate/azext_migrate/tests/latest/runbook/test_runbook_recording.py @@ -0,0 +1,47 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import unittest + +from azure.cli.testsdk import ScenarioTest + +TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..')) + +# Runbooks require a pre-existing migrate project, wave, and generated +# runbook, none of which a ResourceGroupPreparer can provision. The scenario +# therefore targets a fixed, pre-provisioned runbook; the recording scrubs the +# subscription id, so playback needs no live resources. +PROJECT_RG = "BP_AE_Can" +PROJECT_NAME = "BP-AE-Can-Proj" +RUNBOOK_NAME = "testrunbook1" + + +class RunbookScenario(ScenarioTest): + """Recorded read scenario for the runbook show/list commands.""" + + def test_runbook_show_and_list(self): + self.kwargs.update({ + 'rg': PROJECT_RG, + 'project': PROJECT_NAME, + 'name': RUNBOOK_NAME, + }) + + self.cmd( + 'migrate runbook show -g {rg} --project-name {project} ' + '-n {name}', + checks=[ + self.check('name', '{name}'), + self.check('type', 'Microsoft.Migrate/MigrateProjects/' + 'Runbooks'), + ]) + + self.cmd( + 'migrate runbook list -g {rg} --project-name {project}', + checks=[self.check("length([?name=='{name}'])", 1)]) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/migrate/azext_migrate/tests/latest/runbook/test_runbook_scenario.py b/src/migrate/azext_migrate/tests/latest/runbook/test_runbook_scenario.py new file mode 100644 index 00000000000..1130978b9e8 --- /dev/null +++ b/src/migrate/azext_migrate/tests/latest/runbook/test_runbook_scenario.py @@ -0,0 +1,136 @@ +# -------------------------------------------------------------------------------------------- +# 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 import mock + +from azext_migrate.runbook.cmds import runbook as runbook_cmds +from azext_migrate.runbook.cmds import execution_step as execution_step_cmds + +SUB = "00000000-0000-0000-0000-000000000000" +RG = "myRg" +PROJECT = "myProject" +RUNBOOK = "myRunbook" +WAVE = "myWave" + +PROJECT_ID = ( + f"/subscriptions/{SUB}/resourceGroups/{RG}/providers/" + f"Microsoft.Migrate/migrateProjects/{PROJECT}") +RUNBOOK_ID = f"{PROJECT_ID}/runbooks/{RUNBOOK}" +EXECUTION_ID = f"{RUNBOOK_ID}/executions/exec1" + + +def _mock_cmd(): + cmd = mock.Mock() + cmd.cli_ctx.cloud.endpoints.resource_manager = ( + "https://management.azure.com") + return cmd + + +class RunbookCrudScenarioTest(unittest.TestCase): + """Exercises the runbook generate/show/list/delete orchestration.""" + + def setUp(self): + self.cmd = _mock_cmd() + sub_patch = mock.patch( + 'azext_migrate.runbook.cmds.runbook.get_subscription_id', + return_value=SUB) + self.addCleanup(sub_patch.stop) + sub_patch.start() + + client_patch = mock.patch( + 'azext_migrate.runbook.cmds.runbook.ArmClient') + self.addCleanup(client_patch.stop) + self.client_cls = client_patch.start() + self.client = self.client_cls.return_value + + def test_runbook_crud(self): + generated = { + "id": RUNBOOK_ID, "name": RUNBOOK, + "properties": {"status": "Generating", "scope": { + "scopeType": "Wave", + "waveId": f"{PROJECT_ID}/waves/{WAVE}"}}} + self.client.put.return_value = generated + + result = runbook_cmds.generate( + self.cmd, RG, PROJECT, RUNBOOK, WAVE) + + self.assertEqual(result, generated) + put_id, put_body = self.client.put.call_args[0] + self.assertEqual(put_id, RUNBOOK_ID) + self.assertEqual( + put_body["properties"]["scope"]["WaveId"], + f"{PROJECT_ID}/waves/{WAVE}") + + self.client.get.return_value = generated + shown = runbook_cmds.show(self.cmd, RG, PROJECT, RUNBOOK) + self.assertEqual(shown["name"], RUNBOOK) + self.client.get.assert_called_once_with(RUNBOOK_ID) + + self.client.list.return_value = [ + generated, + {"name": "other", "properties": {"status": "New", "scope": { + "waveId": f"{PROJECT_ID}/waves/otherWave"}}}] + filtered = runbook_cmds.list_( + self.cmd, RG, PROJECT, wave_name=WAVE) + self.assertEqual([r["name"] for r in filtered], [RUNBOOK]) + self.client.list.assert_called_once_with( + f"{PROJECT_ID}/runbooks") + + by_status = runbook_cmds.list_( + self.cmd, RG, PROJECT, status="New") + self.assertEqual([r["name"] for r in by_status], ["other"]) + + self.client.delete.return_value = None + runbook_cmds.delete(self.cmd, RG, PROJECT, RUNBOOK) + self.client.delete.assert_called_once_with( + RUNBOOK_ID, no_wait=False) + + +class ExecutionStepScenarioTest(unittest.TestCase): + """Exercises the retry/approve/complete step-action orchestration.""" + + def setUp(self): + self.cmd = _mock_cmd() + sub_patch = mock.patch( + 'azext_migrate.runbook.cmds.execution.get_subscription_id', + return_value=SUB) + self.addCleanup(sub_patch.stop) + sub_patch.start() + + client_patch = mock.patch( + 'azext_migrate.runbook.cmds.execution_step.ArmClient') + self.addCleanup(client_patch.stop) + self.client = client_patch.start().return_value + + def test_execution_step_actions(self): + self.client.post_action.return_value = {"stepId": "step1"} + + execution_step_cmds.retry( + self.cmd, RG, PROJECT, RUNBOOK, "exec1", "step1") + resource_id, action, body = self.client.post_action.call_args[0] + self.assertEqual(resource_id, EXECUTION_ID) + self.assertEqual(action, 'PerformAction') + self.assertEqual(body["action"], 4) + self.assertEqual(body["targetId"], "step1") + + execution_step_cmds.approve( + self.cmd, RG, PROJECT, RUNBOOK, "exec1", "step1", + entities=["ent1"]) + _, action, body = self.client.post_action.call_args[0] + self.assertEqual(action, 'ProvideApproval') + self.assertEqual(body["action"], "Approve") + self.assertEqual(body["migrationEntityIds"], ["ent1"]) + + execution_step_cmds.complete( + self.cmd, RG, PROJECT, RUNBOOK, "exec1", "step1", "done") + _, action, body = self.client.post_action.call_args[0] + self.assertEqual(action, 'UpdateStepStatus') + self.assertEqual(body["action"], "Complete") + self.assertEqual(body["comment"], "done") + + +if __name__ == '__main__': + unittest.main() diff --git a/src/migrate/azext_migrate/tests/latest/runbook/test_runbook_unit.py b/src/migrate/azext_migrate/tests/latest/runbook/test_runbook_unit.py new file mode 100644 index 00000000000..4e4960dc800 --- /dev/null +++ b/src/migrate/azext_migrate/tests/latest/runbook/test_runbook_unit.py @@ -0,0 +1,1723 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import io +import json +import os +import copy +import tempfile +import unittest +import zipfile +from types import SimpleNamespace +from unittest import mock + +from azure.cli.core.azclierror import ( + AzureResponseError, + CLIInternalError, + InvalidArgumentValueError, + RequiredArgumentMissingError, +) +from knack.util import CLIError + +from azext_migrate.shared import arm_ids +from azext_migrate.shared import arm_client as arm_client_mod +from azext_migrate.shared import files +from azext_migrate.shared.arm_client import ArmClient +from azext_migrate.shared.constants import WAVE_OPERATIONS_API_VERSION +from azext_migrate.runbook import models, transformers +from azext_migrate.runbook import deps as deps_mod +from azext_migrate.runbook import config_status as config_status_mod +from azext_migrate.runbook.cmds import runbook as runbook_cmds +from azext_migrate.runbook.cmds import definition as definition_cmds +from azext_migrate.runbook.cmds import definition_step as step_cmds +from azext_migrate.runbook.cmds import ( + definition_workstream as workstream_cmds, +) +from azext_migrate.runbook.cmds import execution as execution_cmds +from azext_migrate.runbook.cmds import execution_step as execution_step_cmds +from azext_migrate.runbook.cmds import parameter as parameter_cmds +from azext_migrate.runbook.visualize import graph as visualize_graph +from azext_migrate.runbook.visualize import renderer as visualize_renderer +from azext_migrate.runbook.visualize import viewmodel as visualize_viewmodel +from azext_migrate.runbook.constants import ( + SCOPE_TYPE_WAVE, + RUNBOOK_STATUS_VALUES, + STEP_TYPE_APPROVAL, + STEP_TYPE_CUSTOM_SCRIPT, +) +from azext_migrate.runbook.models import ExecutionAction +from azext_migrate.runbook.validators import ( + validate_generate, + validate_step_add, + validate_step_approve, + validate_step_complete, +) + +SUB = "00000000-0000-0000-0000-000000000000" +RG = "myRg" +PROJECT = "myProject" +RUNBOOK = "myRunbook" +WAVE = "myWave" + + +class RunbookArmIdTests(unittest.TestCase): + + def test_migrate_project_id(self): + project = arm_ids.migrate_project_id(SUB, RG, PROJECT) + self.assertEqual( + project, + f"/subscriptions/{SUB}/resourceGroups/{RG}/providers/" + f"Microsoft.Migrate/migrateProjects/{PROJECT}") + + def test_runbook_id(self): + project = arm_ids.migrate_project_id(SUB, RG, PROJECT) + self.assertEqual( + arm_ids.runbook_id(project, RUNBOOK), + f"{project}/runbooks/{RUNBOOK}") + + def test_with_api_version_no_query(self): + self.assertEqual( + arm_ids.with_api_version("/a/b", "2020-06-01-preview"), + "/a/b?api-version=2020-06-01-preview") + + def test_with_api_version_existing_query(self): + self.assertEqual( + arm_ids.with_api_version("/a/b?x=1", "2020-06-01-preview"), + "/a/b?x=1&api-version=2020-06-01-preview") + + +class RunbookModelTests(unittest.TestCase): + + def test_wave_id(self): + project = arm_ids.migrate_project_id(SUB, RG, PROJECT) + self.assertEqual( + models.wave_id(project, WAVE), + f"{project}/waves/{WAVE}") + + def test_build_generate_body(self): + body = models.build_generate_body("/waves/myWave") + self.assertEqual( + body, + {"properties": {"scope": { + "ScopeType": SCOPE_TYPE_WAVE, + "WaveId": "/waves/myWave"}}}) + + def test_build_update_body_empty(self): + self.assertEqual( + models.build_update_body(), {"properties": {}}) + + def test_build_update_body_with_description(self): + self.assertEqual( + models.build_update_body(description="new desc"), + {"properties": {"description": "new desc"}}) + + +class RunbookTransformerTests(unittest.TestCase): + + def test_single_runbook(self): + item = {"name": RUNBOOK, "properties": {"state": "Ready"}} + row = transformers.runbook_table(item) + self.assertEqual(row["Name"], RUNBOOK) + self.assertEqual(row["State"], "Ready") + + def test_runbook_list(self): + items = [ + {"name": "r1", "properties": {"state": "Ready"}}, + {"name": "r2", "properties": {"state": "Generating"}}, + ] + rows = transformers.runbook_table(items) + self.assertEqual([r["Name"] for r in rows], ["r1", "r2"]) + + def test_missing_properties(self): + row = transformers.runbook_table({"name": "r1"}) + self.assertIsNone(row["State"]) + + +class RunbookValidatorTests(unittest.TestCase): + + def test_validate_generate_ok(self): + validate_generate(SimpleNamespace(wave_name=WAVE)) + + def test_validate_generate_missing_wave(self): + with self.assertRaises(RequiredArgumentMissingError): + validate_generate(SimpleNamespace(wave_name=None)) + + +class RunbookStatusChoiceTests(unittest.TestCase): + + def test_status_values(self): + self.assertEqual( + RUNBOOK_STATUS_VALUES, + ["Generating", "New", "ReadyToStart", "InExecution", + "Paused", "Completed", "Failed"]) + + +def _fake_response(status_code, headers=None, body=None): + resp = mock.Mock() + resp.status_code = status_code + resp.headers = headers or {} + resp.content = b'{}' if body is not None else b'' + resp.json.return_value = body if body is not None else {} + return resp + + +def _arm_client(): + cmd = mock.Mock() + cmd.cli_ctx.cloud.endpoints.resource_manager = ( + "https://management.azure.com") + return ArmClient(cmd) + + +class ArmClientPollApiVersionTests(unittest.TestCase): + + def test_rewrites_waveoperations_api_version(self): + url = ("https://management.azure.com/subscriptions/s/providers/" + "Microsoft.Migrate/migrateProjects/p/WaveOperations/op" + "?api-version=2020-06-01-preview&c=SIG&s=SIG2") + rewritten = arm_client_mod._rewrite_poll_api_version(url) + self.assertIn( + "api-version=" + WAVE_OPERATIONS_API_VERSION, rewritten) + self.assertNotIn("api-version=2020-06-01-preview", rewritten) + # Signed token must be preserved untouched. + self.assertIn("&c=SIG&s=SIG2", rewritten) + + def test_rewrites_any_poll_url_api_version(self): + url = "https://x/operationstatus/o?api-version=2020-06-01-preview" + rewritten = arm_client_mod._rewrite_poll_api_version(url) + self.assertEqual( + rewritten, + "https://x/operationstatus/o?api-version=" + + WAVE_OPERATIONS_API_VERSION) + + def test_appends_api_version_when_missing(self): + url = "https://x/operationstatus/o" + rewritten = arm_client_mod._rewrite_poll_api_version(url) + self.assertEqual( + rewritten, + "https://x/operationstatus/o?api-version=" + + WAVE_OPERATIONS_API_VERSION) + + +class ArmClientLroTests(unittest.TestCase): + + def setUp(self): + sleep_patch = mock.patch.object(arm_client_mod._time, 'sleep') + self.addCleanup(sleep_patch.stop) + sleep_patch.start() + send_patch = mock.patch.object( + arm_client_mod, 'send_raw_request') + self.addCleanup(send_patch.stop) + self.send = send_patch.start() + + def test_delete_polls_until_succeeded(self): + async_url = ("https://management.azure.com/.../WaveOperations/op" + "?api-version=2020-06-01-preview") + accepted = _fake_response( + 201, headers={'Azure-AsyncOperation': async_url}, + body={"properties": {"state": "ExecutionSucceeded"}}) + running = _fake_response(200, body={"status": "Running"}) + done = _fake_response(200, body={"status": "Succeeded"}) + self.send.side_effect = [accepted, running, done] + + result = _arm_client().delete("/runbooks/r") + + self.assertEqual( + result, {"properties": {"state": "ExecutionSucceeded"}}) + # Initial DELETE + two status polls. + self.assertEqual(self.send.call_count, 3) + polled_url = self.send.call_args_list[1][0][2] + self.assertIn( + "api-version=" + WAVE_OPERATIONS_API_VERSION, polled_url) + + def test_delete_raises_on_failed_operation(self): + async_url = ("https://management.azure.com/.../WaveOperations/op" + "?api-version=2020-06-01-preview") + accepted = _fake_response( + 202, headers={'Azure-AsyncOperation': async_url}, body={}) + failed = _fake_response(200, body={ + "status": "Failed", + "error": {"code": "BadThing", "message": "it broke"}}) + self.send.side_effect = [accepted, failed] + + with self.assertRaises(AzureResponseError): + _arm_client().delete("/runbooks/r") + + def test_delete_no_wait_skips_polling(self): + async_url = "https://x/WaveOperations/op?api-version=x" + accepted = _fake_response( + 202, headers={'Azure-AsyncOperation': async_url}, body={}) + self.send.side_effect = [accepted] + + _arm_client().delete("/runbooks/r", no_wait=True) + + self.assertEqual(self.send.call_count, 1) + + +class RunbookWaitTests(unittest.TestCase): + + def setUp(self): + sub_patch = mock.patch.object( + runbook_cmds, 'get_subscription_id', return_value=SUB) + self.addCleanup(sub_patch.stop) + sub_patch.start() + sleep_patch = mock.patch.object(runbook_cmds.time, 'sleep') + self.addCleanup(sleep_patch.stop) + sleep_patch.start() + client_patch = mock.patch.object(runbook_cmds, 'ArmClient') + self.addCleanup(client_patch.stop) + self.client = client_patch.start().return_value + + def _wait(self, **kwargs): + return runbook_cmds.wait( + mock.Mock(), RG, PROJECT, RUNBOOK, **kwargs) + + def test_requires_a_condition(self): + with self.assertRaises(InvalidArgumentValueError): + self._wait() + + def test_created_returns_when_succeeded(self): + self.client.get_or_none.return_value = { + "properties": {"provisioningState": "Succeeded"}} + self.assertIsNone(self._wait(created=True)) + self.assertEqual(self.client.get_or_none.call_count, 1) + + def test_deleted_returns_when_absent(self): + self.client.get_or_none.return_value = None + self.assertIsNone(self._wait(deleted=True)) + + def test_exists_returns_when_present(self): + self.client.get_or_none.return_value = {"properties": {}} + self.assertIsNone(self._wait(exists=True)) + + def test_failed_provisioning_raises(self): + self.client.get_or_none.return_value = { + "properties": {"provisioningState": "Failed"}} + with self.assertRaises(AzureResponseError): + self._wait(created=True) + + def test_custom_condition_met(self): + self.client.get_or_none.return_value = { + "properties": {"state": "ExecutionSucceeded"}} + self.assertIsNone(self._wait( + custom="properties.state=='ExecutionSucceeded'")) + + def test_times_out_when_never_satisfied(self): + self.client.get_or_none.return_value = { + "properties": {"provisioningState": "InProgress"}} + with self.assertRaises(CLIError): + self._wait(created=True, interval=1, timeout=2) + + +class RunbookUpdateRegenerateTests(unittest.TestCase): + + def setUp(self): + sub_patch = mock.patch.object( + runbook_cmds, 'get_subscription_id', return_value=SUB) + self.addCleanup(sub_patch.stop) + sub_patch.start() + client_patch = mock.patch.object(runbook_cmds, 'ArmClient') + self.addCleanup(client_patch.stop) + self.client = client_patch.start().return_value + + def _runbook_id(self): + project = arm_ids.migrate_project_id(SUB, RG, PROJECT) + return arm_ids.runbook_id(project, RUNBOOK) + + def test_update_calls_patch_with_body(self): + self.client.patch.return_value = {"ok": True} + result = runbook_cmds.update( + mock.Mock(), RG, PROJECT, RUNBOOK, description="d") + self.assertEqual(result, {"ok": True}) + self.client.patch.assert_called_once_with( + self._runbook_id(), {"properties": {"description": "d"}}) + + def test_regenerate_posts_action(self): + self.client.post_action.return_value = {"ok": True} + result = runbook_cmds.regenerate( + mock.Mock(), RG, PROJECT, RUNBOOK, no_wait=True) + self.assertEqual(result, {"ok": True}) + self.client.post_action.assert_called_once_with( + self._runbook_id(), 'Regenerate', no_wait=True) + + +class RunbookDefinitionTransformerTests(unittest.TestCase): + + def test_workstreams_flattened_to_steps(self): + definition = {"workstreams": [ + {"id": "w1", "steps": [ + {"stepId": "s1", "displayName": "Step One", + "prerequisite": [{"step": "b"}], + "dependsOn": [{"step": "a"}], + "configurationStatus": "Configured", + "entities": ["e1", "e2"]}]}, + {"id": "w2", "steps": [{"stepId": "s2"}]}, + ]} + rows = transformers.definition_table(definition) + self.assertEqual([r["Step Id"] for r in rows], ["s1", "s2"]) + self.assertEqual([r["Workstream Id"] for r in rows], ["w1", "w2"]) + self.assertEqual(rows[0]["Step Name"], "Step One") + self.assertEqual(rows[0]["Depends On"], "b a") + self.assertEqual(rows[0]["Configuration Status"], "Configured") + self.assertEqual(rows[0]["Workloads"], 2) + self.assertEqual(rows[0]["Applications"], "-") + + def test_single_workstream(self): + rows = transformers.definition_table( + {"id": "w1", "steps": [{"id": "s1"}]}) + self.assertEqual([r["Step Id"] for r in rows], ["s1"]) + self.assertEqual(rows[0]["Workstream Id"], "w1") + + def test_single_step(self): + rows = transformers.definition_table({"stepId": "s9"}) + self.assertEqual(rows[0]["Step Id"], "s9") + + def test_empty_definition(self): + self.assertEqual(transformers.definition_table({}), []) + + def test_parameters_document_does_not_fabricate_row(self): + # A parameters/inputs document must never be rendered as a single + # bogus step row (regression: -o table showed one empty 3-column + # row when the parameters file was mis-selected as the definition). + params = {"runbookInputs": { + "schema": {"vm.agentless.setup": {}}, + "stepInputs": {"vm.agentless.setup-1": {}}}} + self.assertEqual(transformers.definition_table(params), []) + + + +class DefinitionProjectionTests(unittest.TestCase): + + def setUp(self): + self.definition = {"workstreams": [ + {"id": "w1", "steps": [{"id": "s1"}, {"stepId": "s2"}]}, + {"id": "w2", "steps": [{"id": "s3"}]}, + ]} + + def test_full_definition_returned(self): + result = definition_cmds._project_definition( + self.definition, None, None) + self.assertEqual(result, self.definition) + + def test_filter_by_workstream(self): + result = definition_cmds._project_definition( + self.definition, "w2", None) + self.assertEqual(result["id"], "w2") + + def test_filter_by_step_id(self): + result = definition_cmds._project_definition( + self.definition, None, "s2") + self.assertEqual(result["stepId"], "s2") + + def test_step_id_no_match(self): + result = definition_cmds._project_definition( + self.definition, None, "missing") + self.assertEqual(result, {}) + + def test_workstream_no_match(self): + result = definition_cmds._project_definition( + self.definition, "missing", None) + self.assertEqual(result, {}) + + +def _make_zip(members): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, 'w') as archive: + for name, content in members.items(): + archive.writestr(name, content) + return buffer.getvalue() + + +class FilesTests(unittest.TestCase): + + def test_extract_sas_url_top_level(self): + self.assertEqual( + files.extract_sas_url({"downloadUrl": "https://x"}), + "https://x") + + def test_extract_sas_url_in_properties(self): + self.assertEqual( + files.extract_sas_url( + {"properties": {"sasUri": "https://y"}}), + "https://y") + + def test_extract_sas_url_none(self): + self.assertIsNone(files.extract_sas_url({"other": 1})) + + def test_read_spec_json_prefers_spec_suffix(self): + zip_bytes = _make_zip({ + "extra.json": '{"a": 1}', + "rb-x-spec.json": '{"runbookSpec": {"id": "r"}}', + }) + spec = files.read_spec_json(zip_bytes) + self.assertEqual(spec, {"runbookSpec": {"id": "r"}}) + + def test_extract_definition_files_flattens_hostile_path(self): + # Zip-slip is designed out: a member with a traversal path is + # written by its base name, staying inside the destination. + zip_bytes = _make_zip({ + "runbook.json": '{"runbookSpec": {}}', + "../../evil.md": "# bad", + }) + with tempfile.TemporaryDirectory() as tmp: + written = files.extract_definition_files(zip_bytes, tmp) + for path in written: + self.assertEqual( + os.path.commonpath([tmp, os.path.abspath(path)]), tmp) + self.assertTrue(os.path.isfile(os.path.join(tmp, "evil.md"))) + + def test_extract_parameters_file_by_content(self): + zip_bytes = _make_zip({ + "runbook.json": '{"runbookSpec": {}}', + "user-inputs.json": '{"runbookInputs": {"stepInputs": {}}}', + }) + name, data = files.extract_parameters_file(zip_bytes) + self.assertEqual(name, "user-inputs.json") + self.assertIn(b"runbookInputs", data) + + def test_extract_parameters_file_none_when_only_spec(self): + zip_bytes = _make_zip({"rb-x-spec.json": '{"runbookSpec": {}}'}) + self.assertIsNone(files.extract_parameters_file(zip_bytes)) + + def test_read_spec_json_selects_spec_by_content(self): + # Real service names the members runbook.json / user-inputs.json, + # neither of which carries a -spec.json suffix. Selection must fall + # back to content so the parameters file is never returned as the + # definition. + for members in ( + {"user-inputs.json": '{"runbookInputs": {"schema": {}}}', + "runbook.json": + '{"runbookSpec": {"workstreams": []}}'}, + {"runbook.json": + '{"runbookSpec": {"workstreams": []}}', + "user-inputs.json": '{"runbookInputs": {"schema": {}}}'}): + spec = files.read_spec_json(_make_zip(members)) + self.assertIn("runbookSpec", spec) + self.assertIn("workstreams", spec["runbookSpec"]) + + def test_read_spec_json_none_when_only_parameters(self): + zip_bytes = _make_zip({ + "user-inputs.json": + '{"runbookInputs": {"stepInputs": {}}}'}) + self.assertIsNone(files.read_spec_json(zip_bytes)) + + def test_extract_parameters_selects_inputs_by_content(self): + # Mirror of the spec test: the params file must win over the spec + # regardless of member ordering or non-standard names. + for members in ( + {"runbook.json": + '{"runbookSpec": {"workstreams": []}}', + "user-inputs.json": + '{"runbookInputs": {"schema": {}}}'}, + {"user-inputs.json": + '{"runbookInputs": {"schema": {}}}', + "runbook.json": + '{"runbookSpec": {"workstreams": []}}'}): + name, data = files.extract_parameters_file(_make_zip(members)) + self.assertEqual(name, "user-inputs.json") + self.assertIn("runbookInputs", json.loads(data.decode())) + + def test_read_parameters_json_by_content(self): + zip_bytes = _make_zip({ + "runbook.json": '{"runbookSpec": {"workstreams": []}}', + "user-inputs.json": + '{"runbookInputs": {"stepInputs": {"s1": {}}}}'}) + params = files.read_parameters_json(zip_bytes) + self.assertEqual(params, {"stepInputs": {"s1": {}}}) + + def test_parameters_excludes_derived_input(self): + # The real archive ships runbook.json (spec), user-inputs.json and + # derived-input.json (both runbookInputs-shaped). Only user-inputs + # is the parameters file; derived-input must never be selected. + zip_bytes = _make_zip({ + "runbook.json": '{"runbookSpec": {"workstreams": []}}', + "derived-input.json": + '{"runbookInputs": {"stepInputs": {"d": {}}}}', + "user-inputs.json": + '{"runbookInputs": {"stepInputs": {"u": {}}}}'}) + name, data = files.extract_parameters_file(zip_bytes) + self.assertEqual(name, "user-inputs.json") + self.assertEqual( + files.read_parameters_json(zip_bytes), {"stepInputs": {"u": {}}}) + self.assertNotIn("derived", data.decode()) + + def test_read_spec_ignores_input_documents(self): + zip_bytes = _make_zip({ + "derived-input.json": '{"runbookInputs": {"schema": {}}}', + "user-inputs.json": '{"runbookInputs": {"schema": {}}}', + "runbook.json": + '{"runbookSpec": {"workstreams": [{"id": "w1"}]}}'}) + spec = files.read_spec_json(zip_bytes) + self.assertIn("runbookSpec", spec) + + def test_extract_definition_files_includes_inputs_not_derived(self): + zip_bytes = _make_zip({ + "runbook.json": '{"runbookSpec": {"workstreams": []}}', + "user-inputs.json": '{"runbookInputs": {}}', + "derived-input.json": '{"runbookInputs": {}}', + "runbook.md": "# docs", + }) + with tempfile.TemporaryDirectory() as tmp: + written = files.extract_definition_files(zip_bytes, tmp) + names = sorted(os.path.basename(p) for p in written) + self.assertEqual( + names, ["runbook.json", "runbook.md", "user-inputs.json"]) + self.assertTrue( + os.path.exists(os.path.join(tmp, "user-inputs.json"))) + self.assertFalse( + os.path.exists(os.path.join(tmp, "derived-input.json"))) + + + +class DefinitionCommandTests(unittest.TestCase): + + def setUp(self): + sub_patch = mock.patch.object( + definition_cmds, 'get_subscription_id', return_value=SUB) + self.addCleanup(sub_patch.stop) + sub_patch.start() + client_patch = mock.patch.object(definition_cmds, 'ArmClient') + self.addCleanup(client_patch.stop) + self.client = client_patch.start().return_value + + def _runbook_id(self): + project = arm_ids.migrate_project_id(SUB, RG, PROJECT) + return arm_ids.runbook_id(project, RUNBOOK) + + def test_show_projects_runbook_spec(self): + self.client.post_action.return_value = { + "downloadUrl": "https://blob/x"} + zip_bytes = _make_zip({ + "rb-x-spec.json": + '{"runbookSpec": {"workstreams": ' + '[{"id": "w1", "steps": []}]}}'}) + with mock.patch.object( + definition_cmds.files, 'download_bytes', + return_value=zip_bytes) as dl: + result = definition_cmds.show( + mock.Mock(), RG, PROJECT, RUNBOOK, workstream_id="w1") + dl.assert_called_once_with("https://blob/x") + self.client.post_action.assert_called_once_with( + self._runbook_id(), 'GenerateDownloadUrl') + self.assertEqual(result["id"], "w1") + + def test_show_raises_without_download_url(self): + self.client.post_action.return_value = {"expiresAt": "t"} + with self.assertRaises(CLIInternalError): + definition_cmds.show(mock.Mock(), RG, PROJECT, RUNBOOK) + + def test_download_writes_files(self): + self.client.post_action.return_value = { + "downloadUrl": "https://blob/x"} + with mock.patch.object( + definition_cmds.files, 'download_bytes', + return_value=b'zip'), \ + mock.patch.object( + definition_cmds.files, 'extract_definition_files', + return_value=["/tmp/runbook.json", "/tmp/readme.md"]) as ex: + result = definition_cmds.download( + mock.Mock(), RG, PROJECT, RUNBOOK, destination="/tmp") + ex.assert_called_once_with(b'zip', "/tmp") + self.assertEqual(result, [ + {"kind": "definition", "path": "/tmp/runbook.json"}, + {"kind": "documentation", "path": "/tmp/readme.md"}, + ]) + + +class StepModelTests(unittest.TestCase): + + def test_build_add_step_body_manual(self): + body = models.build_add_step_body("Manual", "Step 1") + self.assertEqual(body["stepName"], "Step 1") + self.assertEqual(body["displayName"], "Step 1") + self.assertEqual(body["stepRef"], "Manual") + self.assertEqual(body["migrationEntityIds"], []) + self.assertEqual(body["dependsOn"], []) + self.assertNotIn("approvalType", body) + + def test_build_add_step_body_approval(self): + body = models.build_add_step_body( + "Approval", "Approve", approval_type="Full", + depends_on=["s0"], step_description="desc") + self.assertEqual(body["stepRef"], "Approval") + self.assertEqual(body["approvalType"], "Full") + self.assertEqual(body["dependsOn"], [{"Mode": 0, "stepId": "s0"}]) + self.assertEqual(body["description"], "desc") + + def test_build_add_step_body_custom_script(self): + body = models.build_add_step_body( + "CustomScript", "Run", run_mode="Once", + execution_target="Appliance") + self.assertEqual(body["runMode"], "Once") + self.assertEqual(body["executionTarget"], "Appliance") + + def test_build_update_step_body_minimal(self): + self.assertEqual( + models.build_update_step_body("s1"), {"stepId": "s1"}) + + def test_build_update_step_body_full(self): + body = models.build_update_step_body( + "s1", step_name="New", step_description="d", + depends_on=["s0"]) + self.assertEqual(body, { + "stepId": "s1", "displayName": "New", + "description": "d", + "dependsOn": [{"Mode": 0, "stepId": "s0"}]}) + + def test_build_delete_step_body(self): + self.assertEqual( + models.build_delete_step_body("s1"), {"stepId": "s1"}) + + def test_build_split_workstream_body(self): + body = models.build_split_workstream_body( + "ws1", "new", ["e1", "e2"]) + self.assertEqual(body, { + "sourceWorkstreamId": "ws1", + "stepIds": [], + "migrationEntityIds": ["e1", "e2"], + "newWorkstreamName": "new"}) + + def test_build_merge_workstreams_body(self): + body = models.build_merge_workstreams_body(["w1", "w2"], "merged") + self.assertEqual(body, { + "workstreamId": ["w1", "w2"], + "newWorkstreamName": "merged"}) + + +class StepValidatorTests(unittest.TestCase): + + def _ns(self, **kwargs): + defaults = dict( + step_type=None, approval_type=None, run_mode=None, + execution_target=None) + defaults.update(kwargs) + return SimpleNamespace(**defaults) + + def test_manual_ok(self): + validate_step_add(self._ns(step_type="Manual")) + + def test_approval_requires_approval_type(self): + with self.assertRaises(RequiredArgumentMissingError): + validate_step_add(self._ns(step_type=STEP_TYPE_APPROVAL)) + + def test_approval_ok_with_type(self): + validate_step_add(self._ns( + step_type=STEP_TYPE_APPROVAL, approval_type="Full")) + + def test_approval_type_rejected_for_manual(self): + with self.assertRaises(InvalidArgumentValueError): + validate_step_add(self._ns( + step_type="Manual", approval_type="Full")) + + def test_run_mode_rejected_for_manual(self): + with self.assertRaises(InvalidArgumentValueError): + validate_step_add(self._ns( + step_type="Manual", run_mode="Once")) + + def test_execution_target_rejected_for_approval(self): + with self.assertRaises(InvalidArgumentValueError): + validate_step_add(self._ns( + step_type=STEP_TYPE_APPROVAL, approval_type="Full", + execution_target="Appliance")) + + def test_custom_script_ok(self): + validate_step_add(self._ns( + step_type=STEP_TYPE_CUSTOM_SCRIPT, run_mode="Once", + execution_target="Appliance")) + + +class StepCommandTests(unittest.TestCase): + + def setUp(self): + sub_patch = mock.patch.object( + definition_cmds, 'get_subscription_id', return_value=SUB) + self.addCleanup(sub_patch.stop) + sub_patch.start() + client_patch = mock.patch.object(step_cmds, 'ArmClient') + self.addCleanup(client_patch.stop) + self.client = client_patch.start().return_value + + def _runbook_id(self): + project = arm_ids.migrate_project_id(SUB, RG, PROJECT) + return arm_ids.runbook_id(project, RUNBOOK) + + def test_add_posts_add_step(self): + self.client.post_action.return_value = {"ok": True} + result = step_cmds.add( + mock.Mock(), RG, PROJECT, RUNBOOK, "Manual", "Step 1") + self.assertEqual(result, {"ok": True}) + self.client.post_action.assert_called_once_with( + self._runbook_id(), 'AddStep', + models.build_add_step_body("Manual", "Step 1")) + + def test_update_posts_update_step(self): + self.client.post_action.return_value = {"ok": True} + step_cmds.update( + mock.Mock(), RG, PROJECT, RUNBOOK, "s1", step_name="New") + self.client.post_action.assert_called_once_with( + self._runbook_id(), 'UpdateStep', + {"stepId": "s1", "displayName": "New"}) + + def test_remove_posts_delete_step(self): + self.client.post_action.return_value = {"ok": True} + step_cmds.remove(mock.Mock(), RG, PROJECT, RUNBOOK, "s1") + self.client.post_action.assert_called_once_with( + self._runbook_id(), 'DeleteStep', {"stepId": "s1"}) + + +class WorkstreamCommandTests(unittest.TestCase): + + def setUp(self): + sub_patch = mock.patch.object( + definition_cmds, 'get_subscription_id', return_value=SUB) + self.addCleanup(sub_patch.stop) + sub_patch.start() + client_patch = mock.patch.object(workstream_cmds, 'ArmClient') + self.addCleanup(client_patch.stop) + self.client = client_patch.start().return_value + + def _runbook_id(self): + project = arm_ids.migrate_project_id(SUB, RG, PROJECT) + return arm_ids.runbook_id(project, RUNBOOK) + + def test_split_posts_split_workstream(self): + self.client.post_action.return_value = {"ok": True} + workstream_cmds.split( + mock.Mock(), RG, PROJECT, RUNBOOK, "ws1", "new", ["e1"]) + self.client.post_action.assert_called_once_with( + self._runbook_id(), 'SplitWorkstream', + models.build_split_workstream_body("ws1", "new", ["e1"])) + + def test_merge_posts_merge_workstreams(self): + self.client.post_action.return_value = {"ok": True} + workstream_cmds.merge( + mock.Mock(), RG, PROJECT, RUNBOOK, ["w1", "w2"], "merged") + self.client.post_action.assert_called_once_with( + self._runbook_id(), 'MergeWorkstreams', + {"workstreamId": ["w1", "w2"], + "newWorkstreamName": "merged"}) + + +class ExecutionModelTests(unittest.TestCase): + + def test_start_body_is_empty_properties(self): + self.assertEqual( + models.build_start_execution_body(), {"properties": {}}) + + def test_action_enum_values(self): + self.assertEqual(int(ExecutionAction.START), 0) + self.assertEqual(int(ExecutionAction.PAUSE), 1) + self.assertEqual(int(ExecutionAction.RESUME), 2) + self.assertEqual(int(ExecutionAction.CANCEL), 3) + self.assertEqual(int(ExecutionAction.RETRY), 4) + + def test_perform_action_body_shape(self): + body = models.build_perform_action_body(ExecutionAction.PAUSE) + self.assertEqual( + body, + {"action": 1, "targetId": "", "migrationEntityIds": []}) + self.assertIsInstance(body["action"], int) + + def test_perform_action_body_with_target(self): + body = models.build_perform_action_body( + ExecutionAction.RESUME, target_id="t1", entity_ids=["e1"]) + self.assertEqual( + body, + {"action": 2, "targetId": "t1", + "migrationEntityIds": ["e1"]}) + + +class ExecutionTransformerTests(unittest.TestCase): + + def test_flattens_workstream_steps(self): + result = { + "workstreams": [ + {"steps": [ + {"id": "s1", "displayName": "Step 1", + "status": "Running", + "workloadProgress": "1/2"}, + ]}, + ], + } + rows = transformers.execution_table(result) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["Id"], "s1") + self.assertEqual(rows[0]["Step Status"], "Running") + self.assertEqual(rows[0]["Workload Progress"], "1/2") + + def test_unwraps_properties(self): + result = { + "properties": { + "steps": [ + {"stepId": "s2", "stepName": "Step 2", + "stepStatus": "Succeeded"}, + ], + }, + } + rows = transformers.execution_table(result) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["Id"], "s2") + self.assertEqual(rows[0]["Step Status"], "Succeeded") + + +class ExecutionCommandTests(unittest.TestCase): + + def setUp(self): + sub_patch = mock.patch.object( + execution_cmds, 'get_subscription_id', return_value=SUB) + self.addCleanup(sub_patch.stop) + sub_patch.start() + client_patch = mock.patch.object(execution_cmds, 'ArmClient') + self.addCleanup(client_patch.stop) + self.client = client_patch.start().return_value + + def _runbook_id(self): + project = arm_ids.migrate_project_id(SUB, RG, PROJECT) + return arm_ids.runbook_id(project, RUNBOOK) + + def test_start_puts_execution(self): + self.client.put.return_value = {"ok": True} + result = execution_cmds.start(mock.Mock(), RG, PROJECT, RUNBOOK) + self.assertEqual(result, {"ok": True}) + self.client.put.assert_called_once() + args, kwargs = self.client.put.call_args + self.assertTrue( + args[0].startswith(self._runbook_id() + '/executions/')) + self.assertEqual(args[1], {"properties": {}}) + self.assertFalse(kwargs.get('no_wait')) + + def test_start_no_wait(self): + execution_cmds.start( + mock.Mock(), RG, PROJECT, RUNBOOK, no_wait=True) + _, kwargs = self.client.put.call_args + self.assertTrue(kwargs.get('no_wait')) + + def test_list_calls_executions_collection(self): + self.client.list.return_value = [] + execution_cmds.list_(mock.Mock(), RG, PROJECT, RUNBOOK) + self.client.list.assert_called_once_with( + self._runbook_id() + '/executions') + + def test_show_returns_execution(self): + self.client.post_action.return_value = {"downloadUrl": "https://b/x"} + status = {"state": "InProgress", "id": "e1"} + with mock.patch.object( + execution_cmds.files, 'download_bytes', + return_value=json.dumps(status).encode('utf-8')) as dl: + result = execution_cmds.show( + mock.Mock(), RG, PROJECT, RUNBOOK, "e1") + self.assertEqual(result, status) + self.client.post_action.assert_called_once_with( + arm_ids.execution_id(self._runbook_id(), "e1"), + 'GenerateDownloadUrl') + dl.assert_called_once_with("https://b/x") + + def test_show_projects_step(self): + status = { + "workstreams": [ + {"steps": [{"id": "s1"}, {"id": "s2"}]}, + ], + } + self.client.post_action.return_value = {"downloadUrl": "https://b/x"} + with mock.patch.object( + execution_cmds.files, 'download_bytes', + return_value=json.dumps(status).encode('utf-8')): + result = execution_cmds.show( + mock.Mock(), RG, PROJECT, RUNBOOK, "e1", step_id="s2") + self.assertEqual(result, {"id": "s2"}) + + def test_pause_posts_perform_action(self): + self.client.post_action.return_value = {"ok": True} + execution_cmds.pause(mock.Mock(), RG, PROJECT, RUNBOOK, "e1") + self.client.post_action.assert_called_once_with( + arm_ids.execution_id(self._runbook_id(), "e1"), + 'PerformAction', + {"action": 1, "targetId": "", "migrationEntityIds": []}) + + def test_resume_posts_perform_action(self): + self.client.post_action.return_value = {"ok": True} + execution_cmds.resume(mock.Mock(), RG, PROJECT, RUNBOOK, "e1") + _, args, _ = self.client.post_action.mock_calls[0] + self.assertEqual(args[2]["action"], 2) + + def test_cancel_posts_perform_action(self): + self.client.post_action.return_value = {"ok": True} + execution_cmds.cancel(mock.Mock(), RG, PROJECT, RUNBOOK, "e1") + _, args, _ = self.client.post_action.mock_calls[0] + self.assertEqual(args[2]["action"], 3) + + +class ExecutionStepModelTests(unittest.TestCase): + + def test_build_retry_step_body(self): + body = models.build_retry_step_body("step1") + self.assertEqual(body, { + "action": 4, "targetId": "step1", + "migrationEntityIds": []}) + self.assertIsInstance(body["action"], int) + + def test_build_approve_step_body_full(self): + body = models.build_approve_step_body("step1") + self.assertEqual(body, { + "action": "Approve", "targetId": "step1", + "migrationEntityIds": []}) + + def test_build_approve_step_body_partial(self): + body = models.build_approve_step_body( + "step1", entity_ids=["e1", "e2"]) + self.assertEqual(body["migrationEntityIds"], ["e1", "e2"]) + + def test_build_complete_step_body(self): + body = models.build_complete_step_body("step1", "done") + self.assertEqual(body, { + "action": "Complete", "targetId": "step1", + "migrationEntityIds": [], "comment": "done"}) + + +class ExecutionStepValidatorTests(unittest.TestCase): + + def test_approve_full_ok(self): + validate_step_approve( + SimpleNamespace(entities=None, all_ready=False)) + + def test_approve_entities_ok(self): + validate_step_approve( + SimpleNamespace(entities=["e1"], all_ready=False)) + + def test_approve_all_ready_ok(self): + validate_step_approve( + SimpleNamespace(entities=None, all_ready=True)) + + def test_approve_entities_and_all_ready_rejected(self): + with self.assertRaises(InvalidArgumentValueError): + validate_step_approve( + SimpleNamespace(entities=["e1"], all_ready=True)) + + def test_complete_requires_comment(self): + with self.assertRaises(RequiredArgumentMissingError): + validate_step_complete(SimpleNamespace(comment=None)) + + def test_complete_ok_with_comment(self): + validate_step_complete(SimpleNamespace(comment="done")) + + +class ExecutionStepCommandTests(unittest.TestCase): + + def setUp(self): + sub_patch = mock.patch.object( + execution_cmds, 'get_subscription_id', return_value=SUB) + self.addCleanup(sub_patch.stop) + sub_patch.start() + client_patch = mock.patch.object(execution_step_cmds, 'ArmClient') + self.addCleanup(client_patch.stop) + self.client = client_patch.start().return_value + + def _execution_id(self): + project = arm_ids.migrate_project_id(SUB, RG, PROJECT) + runbook = arm_ids.runbook_id(project, RUNBOOK) + return arm_ids.execution_id(runbook, "e1") + + def test_retry_posts_perform_action(self): + self.client.post_action.return_value = {"ok": True} + result = execution_step_cmds.retry( + mock.Mock(), RG, PROJECT, RUNBOOK, "e1", "step1") + self.assertEqual(result, {"ok": True}) + self.client.post_action.assert_called_once_with( + self._execution_id(), 'PerformAction', + {"action": 4, "targetId": "step1", + "migrationEntityIds": []}) + + def test_approve_posts_provide_approval(self): + self.client.post_action.return_value = {"ok": True} + execution_step_cmds.approve( + mock.Mock(), RG, PROJECT, RUNBOOK, "e1", "step1", + entities=["ent1"]) + self.client.post_action.assert_called_once_with( + self._execution_id(), 'ProvideApproval', + {"action": "Approve", "targetId": "step1", + "migrationEntityIds": ["ent1"]}) + + def test_complete_posts_update_step_status(self): + self.client.post_action.return_value = {"ok": True} + execution_step_cmds.complete( + mock.Mock(), RG, PROJECT, RUNBOOK, "e1", "step1", "done") + self.client.post_action.assert_called_once_with( + self._execution_id(), 'UpdateStepStatus', + {"action": "Complete", "targetId": "step1", + "migrationEntityIds": [], "comment": "done"}) + + +class ParameterCommandTests(unittest.TestCase): + + def setUp(self): + sub_patch = mock.patch.object( + definition_cmds, 'get_subscription_id', return_value=SUB) + self.addCleanup(sub_patch.stop) + sub_patch.start() + client_patch = mock.patch.object(definition_cmds, 'ArmClient') + self.addCleanup(client_patch.stop) + self.client = client_patch.start().return_value + + def _runbook_id(self): + project = arm_ids.migrate_project_id(SUB, RG, PROJECT) + return arm_ids.runbook_id(project, RUNBOOK) + + def test_download_writes_parameters_file(self): + self.client.post_action.return_value = { + "downloadUrl": "https://blob/x"} + zip_bytes = _make_zip({ + "runbook.json": '{"runbookSpec": {}}', + "user-inputs.json": '{"runbookInputs": {"stepInputs": {}}}', + }) + with tempfile.TemporaryDirectory() as tmp: + with mock.patch.object( + parameter_cmds.files, 'download_bytes', + return_value=zip_bytes) as dl: + result = parameter_cmds.download( + mock.Mock(), RG, PROJECT, RUNBOOK, file=tmp) + dl.assert_called_once_with("https://blob/x") + self.client.post_action.assert_called_once_with( + self._runbook_id(), 'GenerateDownloadUrl') + expected = os.path.join(tmp, "user-inputs.json") + self.assertEqual(result, {"path": expected}) + with open(expected) as handle: + self.assertEqual( + handle.read(), '{"runbookInputs": {"stepInputs": {}}}') + + def test_download_raises_without_parameters_file(self): + self.client.post_action.return_value = { + "downloadUrl": "https://blob/x"} + zip_bytes = _make_zip({"rb-x-spec.json": '{"runbookSpec": {}}'}) + with mock.patch.object( + parameter_cmds.files, 'download_bytes', + return_value=zip_bytes): + with self.assertRaises(CLIInternalError): + parameter_cmds.download( + mock.Mock(), RG, PROJECT, RUNBOOK) + + +_DEFINITION_DOC = { + "workstreams": [ + { + "id": "ws1", + "displayName": "Web tier", + "steps": [ + {"id": "s1", "displayName": "Prepare", "dependsOn": []}, + {"id": "s2", "displayName": "Migrate", + "dependsOn": [{"stepId": "s1"}]}, + {"id": "s3", "displayName": "Cutover", + "dependsOn": ["s1", "s2"]}, + ], + } + ] +} + + +# Mirrors the execution status.json shape: top-level state/workstreams, +# steps keyed by stepId/displayName/state, dependsOn as objects with a +# "step" key, and per-entity progress under entityExecutions. +_STATUS_DOC = { + "state": "InProgress", + "workstreams": [ + { + "id": "ws1", + "displayName": "Web tier", + "steps": [ + {"stepId": "setup", "displayName": "Setup", + "state": "Completed", "dependsOn": []}, + {"stepId": "network", "displayName": "Network", + "state": "Failed", "dependsOn": []}, + {"stepId": "dataSync", "displayName": "Data sync", + "state": "InProgress", + "dependsOn": [ + {"step": "setup", "mode": "step"}, + {"step": "network", "mode": "perEntity"}, + ], + "entityExecutions": [ + {"entityId": "e1", "state": "Completed"}, + {"entityId": "e2", "state": "InProgress"}, + ]}, + {"stepId": "cutover", "displayName": "Cutover", + "state": "Blocked", + "dependsOn": [{"step": "dataSync", "mode": "step"}]}, + ], + } + ], +} + + +class ExecutionStatusParsingTests(unittest.TestCase): + + def test_read_status_json_raw_bytes(self): + parsed = files.read_status_json( + json.dumps(_STATUS_DOC).encode('utf-8')) + self.assertEqual(parsed["state"], "InProgress") + + def test_read_status_json_from_zip(self): + zip_bytes = _make_zip({"status.json": json.dumps(_STATUS_DOC)}) + parsed = files.read_status_json(zip_bytes) + self.assertEqual(parsed["state"], "InProgress") + + def test_execution_table_renders_without_crash(self): + rows = transformers.execution_table(_STATUS_DOC) + by_id = {row['Id']: row for row in rows} + self.assertEqual(by_id['setup']['Step Status'], 'Completed') + self.assertEqual(by_id['network']['Step Status'], 'Failed') + self.assertEqual(by_id['cutover']['Step Status'], 'Blocked') + + def test_execution_table_formats_depends_on(self): + rows = transformers.execution_table(_STATUS_DOC) + by_id = {row['Id']: row for row in rows} + self.assertEqual(by_id['dataSync']['Depends On'], 'setup network') + self.assertEqual(by_id['setup']['Depends On'], '') + + def test_execution_table_workload_progress(self): + rows = transformers.execution_table(_STATUS_DOC) + by_id = {row['Id']: row for row in rows} + self.assertEqual( + by_id['dataSync']['Workload Progress'], '1/2 completed') + self.assertIsNone(by_id['setup']['Workload Progress']) + + def test_execution_graph_edges_from_step_key(self): + graph = visualize_graph.build_execution_graph(_STATUS_DOC) + edges = {(e.source, e.target) for e in graph.edges} + self.assertIn(('setup', 'dataSync'), edges) + self.assertIn(('network', 'dataSync'), edges) + self.assertIn(('dataSync', 'cutover'), edges) + by_id = {n.id: n for n in graph.nodes} + self.assertEqual(by_id['network'].status, 'Failed') + self.assertEqual(by_id['cutover'].status, 'Blocked') + + +class VisualizeGraphTests(unittest.TestCase): + + def test_build_definition_graph_nodes_and_edges(self): + graph = visualize_graph.build_definition_graph(_DEFINITION_DOC) + self.assertEqual({n.id for n in graph.nodes}, {"s1", "s2", "s3"}) + self.assertEqual(len(graph.edges), 3) + by_id = {n.id: n for n in graph.nodes} + self.assertEqual(by_id["s1"].name, "Prepare") + self.assertEqual(by_id["s1"].group, "Web tier") + + def test_topological_layering(self): + graph = visualize_graph.build_definition_graph(_DEFINITION_DOC) + layer = {n.id: n.layer for n in graph.nodes} + self.assertEqual(layer["s1"], 0) + self.assertEqual(layer["s2"], 1) + self.assertEqual(layer["s3"], 2) + + def test_cycle_detection_raises(self): + doc = {"steps": [ + {"id": "a", "dependsOn": ["b"]}, + {"id": "b", "dependsOn": ["a"]}, + ]} + with self.assertRaises(InvalidArgumentValueError): + visualize_graph.build_definition_graph(doc) + + def test_dangling_dependency_is_dropped(self): + doc = {"steps": [ + {"id": "a", "dependsOn": ["missing"]}, + ]} + graph = visualize_graph.build_definition_graph(doc) + self.assertEqual(len(graph.nodes), 1) + self.assertEqual(graph.edges, []) + self.assertEqual(graph.nodes[0].layer, 0) + + def test_execution_graph_carries_status(self): + doc = {"properties": {"steps": [ + {"id": "a", "displayName": "A", "status": "Succeeded"}, + {"id": "b", "displayName": "B", "status": "Running", + "dependsOn": ["a"]}, + ]}} + graph = visualize_graph.build_execution_graph(doc) + by_id = {n.id: n for n in graph.nodes} + self.assertEqual(by_id["a"].status, "Succeeded") + self.assertEqual(by_id["b"].status, "Running") + + +class VisualizeRendererTests(unittest.TestCase): + + def test_escapes_malicious_step_name(self): + doc = {"steps": [ + {"id": "s1", "displayName": ""}, + ]} + graph = visualize_graph.build_definition_graph(doc) + html_text = visualize_renderer.render(graph) + self.assertNotIn("", html_text) + self.assertIn("<script>alert(1)</script>", html_text) + + def test_output_is_self_contained(self): + graph = visualize_graph.build_definition_graph(_DEFINITION_DOC) + html_text = visualize_renderer.render(graph) + self.assertNotIn("http://", html_text) + self.assertNotIn("https://", html_text) + self.assertNotIn("src=", html_text) + self.assertIn("', html_text) + # The auto-reload must stay offline (no URL to fetch). + self.assertNotIn("http://", html_text) + self.assertNotIn("https://", html_text) + + def test_auto_reload_omitted_for_non_positive_interval(self): + graph = visualize_graph.build_definition_graph(_DEFINITION_DOC) + for value in (0, -1, None, "x"): + html_text = visualize_renderer.render( + graph, refresh_interval=value) + self.assertNotIn("http-equiv", html_text) + + +class VisualizeCommandTests(unittest.TestCase): + + def setUp(self): + sub_patch = mock.patch.object( + definition_cmds, 'get_subscription_id', return_value=SUB) + self.addCleanup(sub_patch.stop) + sub_patch.start() + exec_sub_patch = mock.patch.object( + execution_cmds, 'get_subscription_id', return_value=SUB) + self.addCleanup(exec_sub_patch.stop) + exec_sub_patch.start() + + def test_definition_visualize_writes_html(self): + zip_bytes = _make_zip({ + "rb-x-spec.json": json.dumps( + {"runbookSpec": _DEFINITION_DOC}), + }) + with tempfile.TemporaryDirectory() as tmp: + with mock.patch.object(definition_cmds, 'ArmClient') as client, \ + mock.patch.object( + definition_cmds.files, 'download_bytes', + return_value=zip_bytes): + client.return_value.post_action.return_value = { + "downloadUrl": "https://blob/x"} + result = definition_cmds.visualize( + mock.Mock(), RG, PROJECT, RUNBOOK, file=tmp) + path = result['path'] + self.assertTrue(os.path.isfile(path)) + with open(path, encoding='utf-8') as handle: + self.assertIn("Steps<", html_text) + self.assertIn(">Configuration status<", html_text) + self.assertIn(">Step dependency<", html_text) + self.assertIn(">Entities<", html_text) + # Steps render as grid rows carrying the stepRef badge. + self.assertIn('class="row__ref"', html_text) + self.assertIn("vm.agentless.setup", html_text) + + def test_definition_metadata_header_renders(self): + document = { + "runbookResourceId": "/subscriptions/s/rb/testrunbook", + "metadata": { + "waveId": "/subscriptions/s/waves/testwave", + "generatedAt": "2026-07-25T06:51:42.6972548Z", + }, + "stepLibraryVersions": {"vm.agentless": "1.0"}, + "workstreams": [ + {"id": "w0", "displayName": "Init", "steps": [ + {"stepId": "s1", "displayName": "Setup"}]}, + ], + } + view = visualize_viewmodel.build_definition_view( + document, title="Def") + graph = visualize_graph.build_definition_graph(document, title="Def") + html_text = visualize_renderer.render(graph, view=view) + self.assertIn('class="tab-meta"', html_text) + self.assertIn("Runbook version", html_text) + self.assertIn("vm.agentless 1.0", html_text) + self.assertIn("Runbook resource id", html_text) + self.assertIn("/subscriptions/s/rb/testrunbook", html_text) + self.assertIn("Wave id", html_text) + # generatedAt drives the header timestamp (normalised, offline) + # and also appears as a dedicated metadata field. + self.assertIn("2026-07-25 06:51:42 UTC", html_text) + self.assertIn(">Generated<", html_text) + + def test_definition_rows_open_detail_drawer(self): + document = { + "runbookResourceId": "/subscriptions/s/rb/tr", + "metadata": {"generatedAt": "2026-01-01T00:00:00Z"}, + "entities": [{"id": "e1", "displayName": "VM-App01"}], + "workstreams": [ + {"id": "w0", "displayName": "Init", "steps": [ + {"stepId": "s1", "displayName": "Prepare", + "stepRef": "vm.prep"}, + {"stepId": "s2", "displayName": "Migrate", + "stepRef": "vm.migrate", "entities": ["e1"], + "prerequisite": [{"step": "s1", "mode": "Blocking"}], + "dependsOn": [{"step": "s1", "mode": "Soft"}]}]}, + ], + } + view = visualize_viewmodel.build_definition_view( + document, title="Def") + graph = visualize_graph.build_definition_graph(document, title="Def") + html_text = visualize_renderer.render(graph, view=view) + # Rows are keyboard-accessible buttons wired to hidden detail blocks. + self.assertIn('data-step="0"', html_text) + self.assertIn('data-step="1"', html_text) + self.assertIn('role="button"', html_text) + self.assertIn('id="detail-1"', html_text) + self.assertIn('class="drawer"', html_text) + # The detail block carries the step's full context. + self.assertIn("Step type", html_text) + self.assertIn("vm.migrate", html_text) + self.assertIn("VM-App01", html_text) + self.assertIn("Prepare (Blocking)", html_text) + self.assertIn("Prepare (Soft)", html_text) + # Everything stays offline/self-contained. + self.assertNotIn("https://", html_text) + self.assertNotIn("http://", html_text) + + def test_definition_renders_brand_bar_and_cli_help(self): + document = { + "workstreams": [ + {"id": "w0", "displayName": "Init", "steps": [ + {"stepId": "s1", "displayName": "Setup"}]}, + ], + } + view = visualize_viewmodel.build_definition_view( + document, title="Def") + graph = visualize_graph.build_definition_graph(document, title="Def") + html_text = visualize_renderer.render(graph, view=view) + self.assertIn("Azure Migrate Runbook Viewer", html_text) + self.assertIn('class="how-bar"', html_text) + self.assertIn( + "az migrate runbook execution start", html_text) + self.assertIn( + "az migrate runbook definition step add", html_text) + + def test_diagram_uses_workstream_swimlanes(self): + graph = visualize_graph.build_definition_graph( + _REAL_DEFINITION, title="Def") + html_text = visualize_renderer.render(graph) + # The SVG groups steps into labelled workstream bands and keeps + # the dependency edges + per-step info (stepRef sub-label). + self.assertIn('class="lane"', html_text) + self.assertIn("Workstream: Initialization (1)", html_text) + self.assertIn("Workstream: waveapp (3)", html_text) + self.assertIn('class="edge"', html_text) + self.assertIn("vm.agentless.migration", html_text) + + def test_execution_grid_shows_progress_and_groups(self): + view = visualize_viewmodel.build_execution_view( + _STATUS_DOC, title="Exec") + graph = visualize_graph.build_execution_graph( + _STATUS_DOC, title="Exec") + html_text = visualize_renderer.render(graph, view=view) + self.assertIn("Workstream: Web tier (4)", html_text) + self.assertIn("1/2 completed", html_text) + self.assertNotIn("https://", html_text) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/migrate/docs/runbook-cli-design.md b/src/migrate/docs/runbook-cli-design.md new file mode 100644 index 00000000000..4dee3037647 --- /dev/null +++ b/src/migrate/docs/runbook-cli-design.md @@ -0,0 +1,1118 @@ +# Azure Migrate Runbooks CLI — Implementation & Architecture Plan + +> Status: **Design only. No implementation code is produced in this phase.** +> Scope boundary: **all new code lives under `src/migrate/azext_migrate/`.** +> `k8s-extension` is studied as a *reference pattern only* — nothing is imported from it. + +--- + +## 0. Agent execution prerequisites (read first) + +This section makes the plan runnable by an autonomous agent. Everything below is *execution fact*, not +architecture — do these before Phase 0 (§15). + +### 0.1 Environment bootstrap (one-time) + +```pwsh +# activate the existing venv (root: C:\virtual\environment) +& C:\virtual\environment\Scripts\Activate.ps1 # or: python -m venv to create a new one +pip install azdev +# from the repo root (azure-cli-extensions): +azdev setup --repo . # wires azure-cli + this ext repo +azdev extension add migrate # make the migrate ext importable/dev-installed +az extension list # confirm 'migrate' is a dev extension +``` + +Everything the `Verify` gates run (`azdev style|linter|test migrate`) requires this to be done once. If +`azdev` is unavailable, STOP and escalate — do not hand-roll pytest. + +### 0.2 Concrete constants to pin (fill before writing `constants.py`) + +The design references these but the **literal values must be read from the spec** (`spec/Runbooks/*`, +`spec/RunbookExecutions/*`) or confirmed with the service owner — do NOT invent them: + +| Constant | Where it lives | Source of truth | Status | +| --- | --- | --- | --- | +| `RUNBOOKS_API_VERSION` = `"2020-06-01-preview"` | `shared/constants.py` | `api-version=` in the spec YAMLs | **confirmed** | +| Provider namespace + type-segment casing (`Microsoft.Migrate` / `migrateProjects` / `runbooks` / `executions`) | `shared/constants.py` | resource IDs + returned `type` in the spec YAMLs | **confirmed** — canonical ID `/subscriptions/{s}/resourceGroups/{rg}/providers/Microsoft.Migrate/migrateProjects/{p}/runbooks/{n}/executions/{e}` (camelCase `migrateProjects`). Note: existing `local`/replication code uses lowercase `migrateprojects` (`helpers/_server.py`); ARM path is case-insensitive so both work, but new runbook code pins this canonical casing for stable recordings + `id` parsing | +| `ScopeType` value (`"Wave"`) + `WaveId` template | `runbook/constants.py`, `models.build_generate_body` | CreateRunbook body in spec | confirmed (§1.3.3) | +| `RunbookExecutionAction` codes | `runbook/models.py` | service enum | confirmed (§1.3.2) | +| `stepRef` values per step type | `runbook/models.py` | AddStep body in spec / service | **placeholder for now** — stub in `models.py` with `# TODO(confirm): stepRef per step type`; fill from spec/service before Phase 3 (`step add`) | +| Runbook status terminal states (for `wait`/`--watch`/polling) | `runbook/constants.py` | GetRunbook `properties.status` enum | **CONFIRM from spec** | + +Any row marked **CONFIRM** is a Phase-0 blocker for the module that needs it; if unavailable, stub the +constant with a `# TODO(confirm): ` and gate the dependent live test with `@live_only()` until known. + +### 0.3 Test-harness recipe (migrate has none today) + +Phase 1 introduces the first recordings. Create a shared `ScenarioTest` base once so every scenario test +inherits scrubbing (there is no existing base to copy in migrate): + +- Put it at `tests/latest/_test_base.py` with a `MigrateScenarioTest(ScenarioTest)` that registers, in + `__init__`/`setUp`: subscription-id replacer → `00000000-0000-0000-0000-000000000000`, and processors that + strip `sig=`/`Bearer`/SAS query strings from **both** request and response bodies/URIs (see §14). +- Unit tests use plain `unittest.TestCase` with a mocked `cmd` (copy the `_create_mock_cmd` shape from the + existing `test_migrate_commands.py`). +- Test method name == recording file name (§14). Keep fixtures in `runbook/data/`. + +### 0.4 Per-command parameter contract + +The exact `options_list`, enum choices, and required/optional flags per command are **already fully +enumerated in the CLI spec** (`spec/az migrate runbooks cli - spec.md`) — every command has its own +"Arguments" table listing each flag, type, required/optional, and allowed values. This doc deliberately does +**not** duplicate them; the spec's per-command Arguments tables **are** the parameter contract. The agent's +job is to transcribe them 1:1 into `runbook/params.py` (`options_list` = the spec's literal `--flag` +spelling, enums = the spec's allowed-value lists, `required=` per the spec's column), and to encode the +cross-argument constraints (approval / custom-script parameter-sets, mutual exclusions) in +`runbook/validators.py` per §5/§12. Where a name is ambiguous, the spec's literal `--flag` spelling wins. +Keep the finalized arg table in `runbook/params.py` docstrings so it stays the single source in code. + +--- + +## 1. Repository observations + +### 1.1 The `migrate` extension today + +- **Package:** `src/migrate/azext_migrate/`, extension name `migrate`, version `3.0.0b4`, + `azext.minCliCoreVersion = 2.75.0`, `azext.isPreview = true`. +- **Command loader:** `MigrateCommandsLoader(AzCommandsLoader)` in + [azext_migrate/__init__.py](../azext_migrate/__init__.py). It: + - registers `custom_command_type = azext_migrate.custom#{}`, + - calls `load_aaz_command_table` (the `aaz/` package is currently empty — just `__init__.py`), + - then calls `load_command_table` from `commands.py` and `load_arguments` from `_params.py`. +- **Existing command groups** (all `is_preview=True`) in + [azext_migrate/commands.py](../azext_migrate/commands.py): + - `migrate get-discovered-server` + - `migrate local replication {init,new,list,get,remove,get-job}` + - `migrate local start-migration` +- **REST already uses raw requests.** [azext_migrate/helpers/_utils.py](../azext_migrate/helpers/_utils.py) + already wraps `azure.cli.core.util.send_raw_request` with: + - `send_get_request(cmd, uri)` + - `get_resource_by_id(cmd, resource_id, api_version)` (returns `None` on 404) + - `create_or_update_resource(cmd, resource_id, api_version, properties)` (handles 202/empty) + - `delete_resource(cmd, resource_id, api_version)` + - `validate_arm_id_format(arm_id, template)` + - centralized `APIVersion` enum, `IdFormats`, `RoleDefinitionIds` constants. + **This is the precedent the spec's Open Question #1/#2 refers to — we extend it, not replace it.** +- **Logging:** `knack.log.get_logger(__name__)`; **errors:** `knack.util.CLIError`. +- **Helpers layout:** `helpers/_utils.py`, `helpers/_server.py`, `helpers/migration/start/`, + `helpers/replication/{get,init,job,list,new,remove}/` — i.e. **one folder per verb** with logic split + out of `custom.py`. Commands in `custom.py` are thin and import their implementation lazily. +- **Tests:** [azext_migrate/tests/latest/test_migrate_commands.py](../azext_migrate/tests/latest/test_migrate_commands.py) + uses `azure.cli.testsdk.ScenarioTest` but with **`unittest.mock` patched HTTP** — there is currently + **no `recordings/` folder** in the migrate extension. + +### 1.2 The `k8s-extension` reference (patterns to replicate, not import) + +- Clean separation: `commands.py`, `_params.py`, `_validators.py`, `custom.py`, `_format.py` (table + transformers), `_help.py`, `action.py` (custom argparse actions), `consts.py`, `utils.py`. +- `commands.py` uses `command_group` / `custom_command` / `custom_show_command`, with + `supports_no_wait=True`, `table_transformer=`, `confirmation=`. +- Centralized `consts.py` for API versions, RP namespaces, fault types. +- `utils.py` centralizes logging (`knack.log`), **telemetry** (`azure.cli.core.telemetry.set_exception / + set_user_fault / add_extension_event`), and reusable error mapping (`HttpResponseError` → + `azure.cli.core.azclierror.*`). +- Uses typed CLI errors from `azure.cli.core.azclierror` (`InvalidArgumentValueError`, + `RequiredArgumentMissingError`, `MutuallyExclusiveArgumentError`, `ResourceNotFoundError`, etc.). +- **Recording tests:** `tests/latest/` with `ScenarioTest`, `recordings/.yaml` (one YAML per + test), `data/` for fixtures, `MockClasses.py` for pure unit tests. YAML filename == test method name. +- **Difference from migrate:** k8s-extension uses **vendored SDK clients**. We deliberately **do not** + copy that; per the spec's Overall Objective we invoke ARM directly via `send_raw_request()`. + +### 1.3 Spec observations (source of truth) + +Command tree from `spec/az migrate runbooks cli - spec.md` (Appendix A) and REST files under +`spec/Runbooks/` and `spec/RunbookExecutions/`: + +| Command group | Verbs | Backing | +| --- | --- | --- | +| `migrate runbook` | `generate`, `show`, `list`, `update`, `delete`, `wait` | ARM resource `.../migrateProjects/{p}/runbooks/{n}` | +| `migrate runbook definition` | `show`, `download`, `visualize` | definition document / files / client-side | +| `migrate runbook definition step` | `add`, `update`, `remove` | sub-objects of definition (read-modify-write) | +| `migrate runbook definition workstream` | `split`, `merge` | sub-objects of definition | +| `migrate runbook parameter` | `download`, `upload` | parameters file (SAS blob) | +| `migrate runbook execution` | `start`, `pause`, `resume`, `cancel`, `show`, `list`, `visualize` | ARM child `.../runbooks/{n}/executions/{id}` | +| `migrate runbook execution step` | `retry`, `approve`, `complete` | action on a step within an execution | +| `migrate wave` | `show`, `list` | supporting reads | +| `migrate project` | `show`, `list` | supporting reads | +| `migrate workload` | `show`, `list`, `update-target-settings` | supporting reads + update | + +Confirmed REST endpoints (all `https://management.azure.com{MigrateProjectResourceId}/...`, +`api-version={RunbooksAPIVersion}`, `auth: inherit`): + +**Runbook resource & sub-object actions** (`spec/Runbooks/`): + +- `PUT .../runbooks/{n}` — CreateRunbook (`generate`); body `properties.scope = {ScopeType:Wave, WaveId}` +- `GET .../runbooks/{n}` — GetRunbook (`show`) +- `GET .../runbooks` — ListRunbooks (`list`) +- `DELETE .../runbooks/{n}` — DeleteRunbook (`delete`) +- `POST .../runbooks/{n}/Regenerate` — RegenerateRunbook (re-`generate`; no body) +- `POST .../runbooks/{n}/AddStep` — body `{stepName, displayName, stepRef, migrationEntityIds, dependsOn[]}` +- `POST .../runbooks/{n}/UpdateStep` — body `{stepId, displayName, dependsOn[]}` +- `POST .../runbooks/{n}/DeleteStep` — body `{stepId}` +- `POST .../runbooks/{n}/SplitWorkstream` — body `{sourceWorkstreamId, stepIds[], migrationEntityIds[], newWorkstreamName}` +- `POST .../runbooks/{n}/MergeWorkstreams` — body `{workstreamId[], newWorkstreamName}` +- `POST .../runbooks/{n}/GenerateDownloadUrl` — download definition/spec + parameters; returns a **SAS URL to a ZIP** + +**Execution resource & actions** (`spec/RunbookExecutions/`): + +- `GET .../runbooks/{n}/executions` — ListRunbookExecutions (`execution list`) +- `GET .../runbooks/{n}/executions/{e}` — GetRunbookExecution (`execution show`) +- `PUT .../runbooks/{n}/executions/{e}` — StartRunbookExecution (`execution start`); body `{properties:{}}` +- `PATCH .../runbooks/{n}/executions/{e}` — PatchRunbookExecution; body `{status}` +- `POST .../runbooks/{n}/executions/{e}/PerformAction` — body `{action:, targetId, migrationEntityIds[]}` → **pause=1/resume=2/cancel=3/retry=4** (differ only by the integer `RunbookExecutionAction` code) +- `POST .../runbooks/{n}/executions/{e}/ProvideApproval` — body `{action:"Approve", targetId:, migrationEntityIds[]}` (`execution step approve`; `Reject` also available) +- `POST .../runbooks/{n}/executions/{e}/UpdateStepStatus` — body `{action:"Complete", targetId:, migrationEntityIds[]}` (`execution step complete`; `Fail`/`Skip` also available) +- `POST .../runbooks/{n}/executions/{e}/GenerateDownloadUrl` — download status.json; returns a **SAS URL to a ZIP** + +> **Dominant pattern:** ~13 of the ~20 endpoints are **`POST {resourceId}/{ActionName}` with a small JSON +> body**. This is the single most important architectural signal — it validates one generic +> `post_action(resource_id, action_name, body)` client method that every edit/action command reuses +> (see §6). + +**Inconsistencies / assumptions (clarified with the spec owner):** + +1. **~~Swapped verbs~~ — RESOLVED.** The execution YAMLs now correctly declare `GET` for + `GetRunbookExecution` and `PUT` for `StartRunbookExecution`. +2. **`action` field — codes now CONFIRMED.** All execution actions come from one service enum + `RunbookExecutionAction` (0-based ordinal): `Start=0, Pause=1, Resume=2, Cancel=3, Retry=4, Complete=5, + Fail=6, Skip=7, Approve=8, Reject=9`. `PerformAction` uses the **integer** code (e.g. `"action": 1` for + pause); `ProvideApproval`/`UpdateStepStatus` send the **string** member name (`"Approve"`/`"Complete"`). + `models.py` mirrors this one enum + body builders. **Nothing about these codes is open anymore** — a + value change would be a one-line edit. +3. **Start execution needs no scope.** `StartRunbookExecution` body is `{properties:{}}`; it only needs the + **runbook ARM id in the URL**. Scope (`{ScopeType:"Wave", WaveId}`) applies **only to `generate`** + (CreateRunbook). `WaveId` is derived as `{project_resource_id}/waves/{wave_name}` in + `models.build_generate_body`; the extra leading slash in the YAML example is an artifact, not part of the + value. No casing/scope concern remains. +4. **`GenerateDownloadUrl` returns a SAS URL to a blob, not the file.** Flow: `POST .../GenerateDownloadUrl` + → response carries a **pre-signed blob SAS URL** → `files.py` does a plain HTTP GET on that URL + (**no ARM token** — the SAS is self-authorizing) to fetch a **ZIP** → extract with Python `zipfile`. + Used by definition download, parameter download, and execution status download. +5. **CLI params → REST body mapping for steps.** The CLI surfaces + `--step-type/--step-name/--step-description/--depends-on` plus type-specific `--approval-type` (Approval) + and `--run-mode/--execution-target` (CustomScript). `models.py` maps these onto the `AddStep`/`UpdateStep` + body (`{stepName, displayName, stepRef, migrationEntityIds, dependsOn, …}`). **The same parameter-set + treatment applies to `step update` (§2.3), not only `step add`**, and additional approval/custom-script + properties the service adds later are absorbed by this mapping layer. +6. **`MergeWorkstreams` takes an array of workstreams.** Body key `workstreamId` is a **list**; the CLI + surfaces it as `--source-workstream-ids` (plural). Mapping handled in `models.py`. +7. **The YAMLs are a Swagger substitute only.** `auth: inherit`, `settings`, `runtime`/`.bru` scripts, and + `seq` are **Bruno-client artifacts and are ignored** by the implementation — only **method + URL + body** + are contractually meaningful. (`auth: inherit` just means "use the caller's ARM token", which + `send_raw_request` supplies automatically.) +8. **Steps/workstreams are not ARM resources** and are edited via dedicated **POST action endpoints** + (AddStep/UpdateStep/DeleteStep/SplitWorkstream/MergeWorkstreams) — no read-modify-write PATCH; + `cmds/definition_step.py`/`cmds/definition_workstream.py` call `post_action` directly. + +--- + +## 2. Proposed folder structure + +All new code is organized as **self-contained feature packages** inside `azext_migrate` (starting with +`runbook/`), plus a **cross-feature `shared/` access layer** every group reuses. New command paths never +collide with existing `get-discovered-server` / `local` commands and can be reviewed/shipped independently. + +```text +src/migrate/ +├── azext_migrate/ +│ ├── __init__.py # (edit) also load runbook command table + args + help +│ ├── commands.py # (unchanged existing) +│ ├── custom.py # (unchanged) existing local/replication commands +│ ├── _params.py # (unchanged) +│ ├── _help.py # (unchanged) + import feature _help modules +│ ├── helpers/ # existing shared helpers (send_raw_request wrappers) — reused +│ │ └── _utils.py +│ ├── shared/ # NEW cross-feature access layer (used by EVERY migrate feature group) +│ │ ├── __init__.py +│ │ ├── arm_client.py # ArmClient — generic ARM REST wrapper (send_raw_request) +│ │ ├── arm_ids.py # generic ID/URL builders, --ids parsing, api-version join +│ │ ├── constants.py # provider namespace, base ID templates, api-version registry +│ │ ├── errors.py # ARM error → azclierror mapping +│ │ ├── polling.py # LRO / --no-wait / wait / --watch helpers +│ │ ├── files.py # GenerateDownloadUrl SAS→zip + safe local file IO +│ │ └── telemetry.py # azure.cli.core.telemetry wrappers +│ └── runbook/ # FEATURE package: `migrate runbook` (+ its nested subgroups) +│ ├── __init__.py +│ ├── commands.py # load_runbook_command_table(self) — registers group + all subgroups +│ ├── params.py # load_runbook_arguments(self) +│ ├── _help.py # helps[...] for every runbook command +│ ├── constants.py # runbook-specific enums, ID templates, fault types +│ ├── validators.py # runbook argument-constraint validators +│ ├── transformers.py # runbook `--output table` transformers +│ ├── models.py # runbook request-body builders + enums +│ ├── cmds/ # business logic — ONE module per (sub)group; filename = subgroup path +│ │ ├── __init__.py +│ │ ├── runbook.py # `migrate runbook` generate/show/list/update/delete/wait/regenerate +│ │ ├── definition.py # `migrate runbook definition` show/download/visualize +│ │ ├── definition_step.py # `migrate runbook definition step` add/update/remove (nested subgroup) +│ │ ├── definition_workstream.py # `migrate runbook definition workstream` split/merge (nested subgroup) +│ │ ├── parameter.py # `migrate runbook parameter` download/upload +│ │ ├── execution.py # `migrate runbook execution` start/show/list/pause/resume/cancel/visualize +│ │ └── execution_step.py # `migrate runbook execution step` retry/approve/complete (nested subgroup) +│ └── visualize/ # client-side (non-REST) rendering +│ ├── __init__.py +│ ├── graph.py # definition/execution JSON -> DAG model +│ ├── renderer.py # DAG model -> self-contained HTML (HTML-escaped, inline JS/CSS) +│ └── templates/ +│ └── runbook.html.tmpl # inline template (no CDN references) +│ # future peer groups (`migrate wave`/`project`/`workload`) — when implemented, each gets its OWN +│ # feature package (wave/, project/, workload/) beside runbook/, reusing shared/ +└── azext_migrate/tests/latest/ + ├── __init__.py + ├── shared/ # tests for the cross-feature shared/ layer + │ ├── test_arm_ids_unit.py # id/--ids parsing, api-version join + │ └── test_errors_unit.py # ARM status → azclierror mapping + └── runbook/ # ALL runbook tests live under one per-feature folder + ├── __init__.py + ├── test_runbook_scenario.py # ScenarioTest recording/playback tests + ├── test_runbook_unit.py # pure unit tests (models, validators, transformers, graph, renderer) + ├── recordings/ # one YAML per test method (filename == method name) + │ ├── test_runbook_crud.yaml + │ ├── test_runbook_execution.yaml + │ └── ... + └── data/ # fixtures scoped to runbook tests + ├── sample_definition.json + └── sample_parameters.json + # future peer groups add their OWN test folder here too (tests/latest/wave/, .../project/, …) +``` + +**Why feature packages + a shared layer:** it guarantees namespace isolation from the existing flat +`custom.py`/`_params.py`, keeps each (sub)group's logic in its own small module, and lets each feature +surface be enabled/disabled by **one loader call** (`load_runbook_command_table(self)`) added to +`__init__.py` (§4). The runbook feature therefore **does not touch the existing `custom.py` at all** — its +`CliCommandType(operations_tmpl='azext_migrate.runbook.cmds.{}')` routes every command into `runbook/cmds/*` +instead, so `cmds/` plays the role `custom.py` plays for the existing commands. Crucially this makes the +pattern **extensible**: future top-level groups (`migrate wave`, `migrate project`, `migrate workload`, and +anything added later) are new **peer packages**, each enabled by its own one-line +`load__command_table(self)` hook, that reuse the same `shared/` layer instead of duplicating it. + +### 2.1 Avoiding utility duplication across subgroups (the core concern) + +**Concern:** if each subgroup gets its own folder with its own `command`/`init`/`util`, common helpers get +copy-pasted into every folder. This is a real anti-pattern — and it is exactly what the existing migrate +`helpers/replication/{get,init,job,list,new,remove}/` **verb-per-folder** layout risks. + +**How other multi-subgroup extensions actually organize (reference evidence):** + +- **`containerapp`** — the largest multi-subgroup extension in this repo (`containerapp`, `containerapp env`, + `containerapp job`, `containerapp session`, `containerapp auth`, `containerapp env storage`, …). It does + **NOT** use folder-per-subgroup. It has **one flat shared layer at the package root**: + `_utils.py`, `_clients.py`, `_client_factory.py`, `_constants.py`, `_models.py`, `_transformers.py`, + `_validators.py`, `_help.py`, `_params.py`, a **single** `commands.py`, a **single** `custom.py`. + Per-subgroup *logic* lives in **one file per subgroup** (`containerapp_env_decorator.py`, + `containerapp_job_decorator.py`, …) that all consume the shared root layer. Cross-cutting helpers are + further split **by concern, not by subgroup** (`_ssh_utils.py`, `_dapr_utils.py`, `_archive_utils.py`). +- **`dataprotection`** keeps generated commands in `aaz/` and hand-written logic in a single `manual/` + package + one `custom.py` — again, shared helpers are centralized, not per-subgroup. + +**Conclusion / rule for migrate:** subgroups differ only in **business logic**, never in utilities. +Therefore: + +1. **A cross-feature `shared/` access layer** — `arm_client.py`, `arm_ids.py`, `errors.py`, `polling.py`, + `files.py`, `telemetry.py`, and base `constants.py` are defined **exactly once** under + `azext_migrate/shared/` and imported by **every feature group** (runbook, wave, project, workload, and + anything added later). Feature-specific `models.py`/`transformers.py`/`validators.py`/`constants.py` live + in each feature package and are shared by that feature's subgroups. +2. **`cmds/` contains only per-(sub)group business logic** — each module is a thin orchestrator + (resolve → validate → build body → `arm_client.post_action/put/get` → transform). **No `arm_client.py`, + `_utils.py`, or `constants.py` is ever created inside `cmds/`.** A lint/review rule enforces that + `cmds/*` and feature packages may import from `shared/`, but `shared/` never imports from any feature. +3. Because ~13 endpoints are the same `POST {resourceId}/{action}` shape, the *entire* edit/action surface + (steps, workstreams, execution actions, approvals, completes, regenerate) is served by **one** + `arm_client.post_action` + a handful of body builders in `models.py`. There is essentially nothing left + to duplicate — `cmds/definition_step.py:add()` is ~5 lines: build body, call + `post_action(runbook_id, 'AddStep', body)`. + +This directly resolves the duplication worry: `commands`/`params`/`_help` are split per subgroup for +readability, but **all common code lives in a single shared layer**, mirroring how `containerapp` scales to +dozens of subgroups without duplicating utilities. + +### 2.2 Reference patterns for logic placement (why a sub-package, not top-level `custom.py`) + +Two in-repo patterns were studied: + +- **`migrate` (existing `local`/replication):** a **thin top-level `custom.py`** whose functions **lazily + import** their implementation from `helpers/replication//…`. This works and keeps `custom.py` small, + but as the surface grows `custom.py` becomes a catch-all and the per-verb folders tempt utility + duplication. +- **`containerapp` (large extension):** a single large `custom.py` that delegates to **decorator classes** + (one per feature, e.g. `ContainerAppJobDecorator`) over a **centralized** `_utils.py`/`_clients.py`. + +Both centralize utilities and keep the command layer thin. The runbook feature adopts the **self-contained +`runbook/` sub-package** with `cmds/*.py` (plain functions, no decorator state machine — raw REST +doesn't need one) over a single shared access layer. This matches containerapp's centralized-utility +principle while staying isolated inside the `migrate` boundary. **The existing `local` commands are left +as-is** for now (isolation requirement); §2.4 describes how they *can* later adopt this same pattern +without any user-facing change. + +### 2.3 Naming, nested subgroups, and peer groups (extensibility rules) + +These rules make the layout scale to arbitrary future `migrate` groups/subgroups: + +- **Logic folder is `cmds/`** (not `operations/`), and **files are named after the subgroup path** below the + feature root — no `_ops` suffix. Examples inside `runbook/cmds/`: `runbook.py`, `definition.py`, + `parameter.py`, `execution.py`. +- **Nested / doubly-nested subgroups** encode their full path with underscores, so depth is unlimited and + two different `step` subgroups never collide: + - `migrate runbook definition step` → `cmds/definition_step.py` + - `migrate runbook execution step` → `cmds/execution_step.py` + A third level (e.g. a hypothetical `migrate runbook definition step approval`) is simply + `cmds/definition_step_approval.py`. The registration string `'#'` maps 1:1 to the file. +- **Peer groups are separate feature packages, never nested under `runbook/`.** Future groups like + `migrate wave`, `migrate project`, and `migrate workload` would each be a **sibling** feature package in + its **own folder named after the group** (`wave/`, `project/`, `workload/`) — not a shared/generic + `supporting/` folder, and not nested under `runbook/`. (They are **out of scope for now** — see §15 + Future TODO.) +- **Every feature package reuses `shared/`** for REST/IDs/errors/polling/files/telemetry, so adding a group + never duplicates the access layer. Enablement is **one loader hook per feature package** in + `__init__.py` (§4). + +### 2.4 Future: migrating the existing `local` commands to the feature-package pattern + +The current `local`/replication commands (`migrate get-discovered-server`, `migrate local replication …`, +`migrate local start-migration`) **can** be moved onto this pattern later as a pure refactor — **command +paths stay identical**, so there is no user-facing break. This is **out of scope now** (don't touch working +commands), but nothing in the design blocks it. The mapping: + +| Today (`local`, verb-per-folder) | Future (feature-package pattern) | +| --- | --- | +| thin `custom.py` funcs that lazily import helpers | `local/cmds/*` functions bound via `operations_tmpl` | +| `helpers/replication/{get,init,job,list,new,remove}/` (folder per verb) | `local/cmds/replication.py` (one module per subgroup) | +| `helpers/migration/start/` | `local/cmds/start_migration.py` (or a `local start-migration` module) | +| `helpers/_utils.py` `send_raw_request` wrappers | folded into `shared/arm_client.py` / `shared/arm_ids.py` | +| registration in root `commands.py` | `local/commands.py::load_local_command_table(self)` | +| args in root `_params.py` | `local/params.py::load_local_arguments(self)` | + +Migration steps (mechanical, incremental): + +1. Create a `local/` **peer package** (sibling of `runbook/`); move the replication/migration logic into + `local/cmds/*`, collapsing the six verb-folders into per-subgroup modules. +2. Point a new `CliCommandType(operations_tmpl='azext_migrate.local.cmds.{}')` at them. +3. Add `load_local_command_table(self)` / `load_local_arguments(self)` hooks in `__init__.py` and **remove** + the old root `commands.py` / `_params.py` registration for those commands. +4. Retire `helpers/replication/*` once its logic lives in `local/cmds/*` + `shared/`. + +Why it's safe / low-risk: paths are unchanged (`migrate local …`); it's the same REST, now routed through +`shared/arm_client` instead of `helpers/_utils`. Best done as its **own phase after** the runbook feature is +stable, so `shared/` is already proven before `local` depends on it. Caveat: the existing +`unittest.mock`-patched HTTP tests would be re-pointed at `shared/arm_client` (and ideally gain recordings, +per §14). + +--- + +## 3. Module responsibilities + +| Module | Responsibility | Depends on | +| --- | --- | --- | +| `runbook/commands.py` | Register command groups/subgroups, wire `supports_no_wait`, `confirmation`, `table_transformer`, `custom_show_command`. | knack/azcli, `transformers`, `constants` | +| `runbook/params.py` | Declare arguments, `options_list`, `get_enum_type`, `get_three_state_flag`, attach `validators`. | `constants`, `validators` | +| `runbook/_help.py` | `helps[...]` YAML docstrings + **examples** (release requirement per spec Open Q#4). | — | +| `runbook/constants.py` | Runbook-specific enums, ID templates, fault types (extends the shared api-version registry). | `shared/constants` | +| `runbook/validators.py` | Enforce the spec's **Argument constraints** (Azure CLI has no parameter-sets). | `constants`, azclierror | +| `runbook/transformers.py` | `--output table` transformers (definition, execution status, list). | — | +| `shared/arm_client.py` | **Single, cross-feature** ARM REST surface: `get/list/put/patch/delete/post_action` via `send_raw_request`; header/body serialization; delegates error mapping to `errors`. | `errors`, `arm_ids`, azcli.util | +| `shared/arm_ids.py` | Generic resource-ID/URL builders; join `api-version`; parse `--ids`. | `constants` | +| `shared/polling.py` | Poll GET until terminal status; back `generate`/`execution start` `--no-wait`; power `wait`; `--watch`. | `arm_client`, `constants` | +| `shared/errors.py` | Map ARM error bodies + status codes to `azclierror` types with actionable messages. | azclierror, telemetry | +| `shared/telemetry.py` | `record_exception`, `set_user_fault`, `add_event` wrappers (no-op safe). | azcli.telemetry | +| `shared/files.py` | `POST GenerateDownloadUrl` → SAS blob URL → HTTP GET (no ARM token) → unzip; safe local path handling. | `arm_client` | +| `shared/constants.py` | Provider namespace, base ID templates, api-version registry — shared by all feature groups. | — | +| `runbook/models.py` | Request-body builders (e.g. `build_generate_body(wave_id)`), enum definitions, response projections. | `constants` | +| `runbook/cmds/*` | Business logic per (sub)group; **one module per subgroup path**; orchestrate validate → build request → call client → transform. | shared layer + models/validators/transformers | +| `runbook/visualize/*` | Pure client-side transform of JSON → DAG → **self-contained, HTML-escaped** HTML file. | stdlib only | + +**Dependency direction (strictly one-way):** +`commands/params` → `cmds` → (`models`, `transformers`, `validators`, feature `constants`) → `shared/` +(`arm_client`, `arm_ids`, `polling`, `files`, `errors`, `telemetry`, base `constants`). No cycles. +`cmds` never imports `commands`; `shared/` never imports any feature package. + +--- + +## 4. Command registration strategy + +Keep existing registration untouched; **append** a single call so new commands are additive and +conflict-free. + +- [azext_migrate/__init__.py](../azext_migrate/__init__.py) `load_command_table`: after the existing + `load_command_table(self, args)`, add `from azext_migrate.runbook.commands import + load_runbook_command_table; load_runbook_command_table(self)`. +- `load_arguments`: after existing `load_arguments`, add + `from azext_migrate.runbook.params import load_runbook_arguments; load_runbook_arguments(self)`. +- Help: `azext_migrate/_help.py` adds `from azext_migrate.runbook import _help # noqa` (import side-effect + registers `helps`). No changes to existing help entries. + +Inside `runbook/commands.py`, register with a **custom command type** scoped to the `cmds` package so +resolution is unambiguous. **This is why the feature needs no `custom.py`:** the existing commands resolve +through `custom_command_type = azext_migrate.custom#{}` (into `custom.py`), whereas the runbook group binds +its own `CliCommandType(operations_tmpl='azext_migrate.runbook.cmds.{}')`, so `'runbook#generate'` loads +`azext_migrate.runbook.cmds.runbook.generate`. The `runbook/cmds/*` modules **are** this feature's command +implementations — `custom.py` is left untouched. Nested subgroups get their own `command_group` block and +their own `cmds` module: + +```python +# runbook/commands.py +runbook_cmds = CliCommandType(operations_tmpl='azext_migrate.runbook.cmds.{}') + +with self.command_group('migrate runbook', runbook_cmds, is_preview=True) as g: + g.custom_command('generate', 'runbook#generate', supports_no_wait=True) + g.custom_show_command('show', 'runbook#show', table_transformer='...') + g.custom_command('list', 'runbook#list_', table_transformer='...') + g.custom_command('update', 'runbook#update') + g.custom_command('delete', 'runbook#delete', confirmation=True) + g.custom_wait_command('wait', 'show') # native predicates over custom show (spec §7) + +# nested subgroup: `migrate runbook definition` +with self.command_group('migrate runbook definition', runbook_cmds) as g: + g.custom_show_command('show', 'definition#show', table_transformer='...') + g.custom_command('download', 'definition#download') + +# doubly-nested subgroup: `migrate runbook definition step` +with self.command_group('migrate runbook definition step', runbook_cmds) as g: + g.custom_command('add', 'definition_step#add') + g.custom_command('update', 'definition_step#update') + g.custom_command('remove', 'definition_step#remove', confirmation=True) +``` + +`operations_tmpl` resolves `'#'` to `azext_migrate.runbook.cmds..`, so the +module name is exactly the subgroup path (`definition_step`, `execution_step`) — **nesting depth is +unlimited and two different `step` subgroups never collide.** + +**Peer groups** (added later) would live in their **own** feature package named after the group and be +registered the same way (they are *siblings* of `migrate runbook`, not children). For example, a future +`migrate wave` group: + +```python +# wave/commands.py (added only when wave/project/workload are implemented — each in its own folder) +wave_cmds = CliCommandType(operations_tmpl='azext_migrate.wave.cmds.{}') +with self.command_group('migrate wave', wave_cmds, is_preview=True) as g: + g.custom_show_command('show', 'wave#show') + g.custom_command('list', 'wave#list_') +``` + +This mirrors the k8s-extension registration style while staying self-contained per feature. + +**Isolation guarantees:** new command paths all start with `migrate runbook` (and, when added, future peer +groups like `migrate wave`) — none overlap existing `migrate get-discovered-server` or `migrate local ...`. +No global argument context is modified except adding new contexts. + +--- + +## 5. CLI command organization + +- **`commands.py`** — *only* command registration + transformer/confirmation/no-wait wiring. No logic. +- **`params.py`** — *only* argument declarations. Shared arg types defined once: + `project_name`, `runbook_name` (`--runbook-name`; and `-n/--name` where the runbook is the primary + resource), `execution_id`, `step_id`, `workstream_id`, `wave_name`, enum types (status, step-type, + approval-type, run-mode, execution-target). `--ids` is auto-provided by Azure CLI when `id_part` is set + on the name arguments, satisfying the spec's `--ids` addressing. +- **`cmds/*.py`** — thin command functions (target ~15–40 lines each). Each function: + 1. resolves identity (`arm_ids`), 2. validates (`validators`, already partly enforced declaratively), + 3. builds request (`models`), 4. calls `arm_client`, 5. transforms/returns. +- **`validators.py`** — cross-argument constraints the spec spells out per command (e.g. `--approval-type` + required when `--step-type Approval`; `--run-mode`/`--execution-target` only for `--step-type CustomScript`; + `--entities`/`--all-ready` only for `Partial` and mutually exclusive; `--comment` required for `complete`). + **`step update` reuses the same parameter-set validators as `step add`** (approval / custom-script + conditionals), inferring the step's type from the target step when `--step-type` is omitted. +- **`shared/arm_client.py` + `shared/arm_ids.py`** — the client/helper modules; the only place + `send_raw_request` and URL/ID assembly live (reused by every feature group). +- **`transformers.py`** — the "default table view" tables named in the spec (definition table: + `Id, Step Name, Depends On, Configuration Status, Workloads, Applications`; execution table: + `Id, Step Name, Step Status, Depends On, Workload Progress`). + +--- + +## 6. REST client architecture + +A single `ArmClient` (in `shared/arm_client.py`) centralizes every ARM interaction (spec Objective + +Reusability §8). It wraps +the existing proven `send_raw_request` helpers rather than reinventing them. + +```text +cmds/* ──► ArmClient (shared/) ──► send_raw_request(cli_ctx, method, url, body, headers) + │ (auth, token, cloud endpoint handled by Az CLI) + ├─ arm_ids: build resource id + ?api-version= + └─ errors: status/body → azclierror +``` + +Responsibilities of `ArmClient` (all take `cmd`): + +- `get(resource_id)` / `get_or_none(resource_id)` — GET; 404→None variant for existence checks. +- `list(collection_id)` — GET with automatic **`nextLink` pagination** aggregation (runbooks, executions). +- `put(resource_id, body)` — create/generate/start; returns body or async handle. +- `patch(resource_id, body)` — `runbook update`, `PatchRunbookExecution` (`{status}`). +- `delete(resource_id)` — delete. +- `post_action(resource_id, action_name, body=None)` — **the workhorse.** Serves every + `POST {resourceId}/{action_name}` endpoint: `Regenerate`, `AddStep`, `UpdateStep`, `DeleteStep`, + `SplitWorkstream`, `MergeWorkstreams`, `GenerateDownloadUrl`, `PerformAction`, `ProvideApproval`, + `UpdateStepStatus`. ~13 commands share this one method. +- All methods send `Content-Type=application/json`, serialize dict→JSON, parse JSON→dict, and route errors + through `errors.raise_for_arm_error(response)`. + +**Action-code abstraction (removes duplication for execution actions):** `PerformAction` handles +`pause/resume/cancel/retry` via an integer `action` code from the confirmed `RunbookExecutionAction` enum +(`Start=0, Pause=1, Resume=2, Cancel=3, Retry=4, Complete=5, Fail=6, Skip=7, Approve=8, Reject=9`). +`models.py` owns an `ExecutionAction` enum (member → int) and a +single `build_perform_action_body(action, target_id=None, entity_ids=None)` builder, so those four commands +are one-liners differing only by enum member. `approve`/`complete` reuse the **same** enum but serialize the +**string member name** for the `ProvideApproval`/`UpdateStepStatus` bodies via +`build_step_action_body(action, target_id, entity_ids)`. + +**Download/unzip:** `GenerateDownloadUrl` returns a SAS URL to a **ZIP**; `files.py` downloads then +extracts with Python `zipfile` (definition+docs, parameters, or execution status.json). + +**Cloud/endpoint handling:** URLs are always +`cmd.cli_ctx.cloud.endpoints.resource_manager + resource_id + '?api-version=' + RUNBOOKS_API_VERSION`, +so sovereign clouds work automatically (matches existing `_utils.py` pattern). + +**Reuse decision:** `arm_client.py` internally calls the existing +`helpers/_utils.py` functions where they already fit (`get_resource_by_id`, `create_or_update_resource`, +`delete_resource`) and adds runbook-specific methods (`list` with paging, `post_action`, `patch`). This +honors "reuse migrate utilities where appropriate" without importing anything from other extensions. + +--- + +## 7. Utility layer design + +- **`arm_ids.py`** + - `project_id(sub, rg, project)`, `runbook_id(project_id, name)`, + `execution_id(runbook_id, execution_id)` from ID templates in `constants.py`. + - `resolve_ids(cmd, namespace)` — when `--ids` is supplied, parse it into + (sub, rg, project, runbook, execution); otherwise assemble from discrete args. Central place for the + spec's "Execution identity" addressing rules. + - `with_api_version(resource_id)` — append `?api-version=`. +- **`polling.py`** — `poll_until(cmd, get_fn, predicate, interval, timeout)` used by `--watch`, + and terminal-status detection for `generate`/`execution start`. The public `wait` command uses the CLI's + native `custom_wait_command` bound to the custom `show`, so `wait` gets `--created/--updated/--deleted/` + `--exists/--custom/--interval/--timeout` for free (no hand-written polling for `wait` itself). +- **`files.py`** — `download_sas_zip(sas_url, dest)` (plain HTTP GET on the pre-signed blob URL — **no ARM + token** — then unzip with Python `zipfile`), `save_json(obj, path)`, + `generate_and_download(cmd, resource_id, dest)` = `POST GenerateDownloadUrl` → read SAS URL from response + → `download_sas_zip`. Validates/normalizes destination paths (prevents path traversal), defaults to CWD + per spec. +- **`errors.py`** — `raise_for_arm_error(response)`; maps `404→ResourceNotFoundError`, + `400→InvalidArgumentValueError`/`BadRequestError`, `403→ForbiddenError`, `409→ClientRequestError`, + `5xx→CLIInternalError`; extracts `error.code`/`error.message`; appends remediation hints. +- **`telemetry.py`** — `record_exception(ex, fault_type, summary)`, `set_user_fault()`, + `add_event(name, props)`; all guarded so telemetry never breaks a command. +- **`transformers.py`** — pure functions `dict → list[OrderedDict]` for the named table views + (feature-specific; lives in `runbook/`). + +The access modules above (`arm_client`, `arm_ids`, `polling`, `files`, `errors`, `telemetry`, base +`constants`) are **cross-feature** and live in `shared/`, so `runbook/` and any future feature group +(e.g. `wave/`, `project/`, `workload/`) reuse them without duplication. Feature-specific +`models`/`transformers`/`validators`/`constants` live in each feature package. Only if a `shared/` helper +proves useful to the existing `local`/`replication` commands would it also be surfaced via `helpers/` (per +spec §11 "introduce new shared utilities only when they provide value across multiple command groups"). + +### 7.1 Visualize pipeline (`graph.py` → `renderer.py` → `runbook.html.tmpl`) + +`migrate runbook definition visualize` and `migrate runbook execution visualize` turn a runbook JSON +document into a **single, self-contained, offline HTML file** (a dependency graph the user can open in a +browser). It is **purely client-side** — the only network call is the one read (`arm_client.get`) that +fetches the JSON; everything after that is local, stdlib-only transformation. The three files split the job +into **parse → render → template** so each stage is independently unit-testable: + +1. **`visualize/graph.py` — JSON → DAG model (data only, no HTML).** + - Input: the definition document (steps + `dependsOn` + workstreams) or an execution document (steps + + per-step `status`/progress). + - Output: an in-memory **DAG** — a list of **nodes** (one per step/workstream, carrying id, name, + type, and for executions the status) and **edges** (one per `dependsOn` link). + - Also: validates the graph (detects cycles / dangling `dependsOn` references) and computes a stable + **topological layering** (which nodes sit in which "row") so the renderer can lay them out + deterministically. This module contains **no HTML and no I/O** — just dict/list → dataclasses, which + makes it trivial to unit-test the graph shape. + +2. **`visualize/renderer.py` — DAG model → HTML string (the security-critical stage).** + - Takes the DAG model and produces the final HTML by filling the template. + - **HTML-escapes every user-controlled value** (step names, descriptions, workstream names) with + `html.escape` **before** substitution — this is the mandatory **XSS guard** (spec §5): a step named + `` must render as inert text, never execute. This is the single most + important line in the feature and is asserted by a unit test. + - Emits nodes/edges as data the template's inline script draws (e.g. a small vanilla-JS layout, or + precomputed SVG/positioned `
`s from the layering in step 1). Status colors for execution graphs + are applied here. + +3. **`visualize/templates/runbook.html.tmpl` — the static shell.** + - A plain HTML skeleton with placeholders plus **inline `